mirror of
https://github.com/MAGICGrants/truenas-apps.git
synced 2026-01-09 20:47:58 -05:00
apps: bump library (#1353)
This commit is contained in:
@@ -10,8 +10,8 @@ icon: https://media.sys.truenas.net/apps/actual-budget/icons/icon.png
|
||||
keywords:
|
||||
- finance
|
||||
- budget
|
||||
lib_version: 2.1.8
|
||||
lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44
|
||||
lib_version: 2.1.9
|
||||
lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125
|
||||
maintainers:
|
||||
- email: dev@ixsystems.com
|
||||
name: truenas
|
||||
@@ -32,4 +32,4 @@ sources:
|
||||
- https://hub.docker.com/r/actualbudget/actual-server
|
||||
title: Actual Budget
|
||||
train: community
|
||||
version: 1.2.9
|
||||
version: 1.2.10
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
|
||||
|
||||
class Ports:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._ports: dict[str, dict] = {}
|
||||
|
||||
def add_port(self, host_port: int, container_port: int, config: dict | None = None):
|
||||
config = config or {}
|
||||
host_port = valid_port_or_raise(host_port)
|
||||
container_port = valid_port_or_raise(container_port)
|
||||
proto = valid_port_protocol_or_raise(config.get("protocol", "tcp"))
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0"))
|
||||
|
||||
key = f"{host_port}_{host_ip}_{proto}"
|
||||
if key in self._ports.keys():
|
||||
raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]")
|
||||
|
||||
if host_ip != "0.0.0.0":
|
||||
# If the port we are adding is not going to use 0.0.0.0
|
||||
# Make sure that we don't have already added that port/proto to 0.0.0.0
|
||||
search_key = f"{host_port}_0.0.0.0_{proto}"
|
||||
if search_key in self._ports.keys():
|
||||
raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]")
|
||||
elif host_ip == "0.0.0.0":
|
||||
# If the port we are adding is going to use 0.0.0.0
|
||||
# Make sure that we don't have already added that port/proto to a specific ip
|
||||
for p in self._ports.values():
|
||||
if p["published"] == host_port and p["protocol"] == proto:
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]"
|
||||
)
|
||||
|
||||
self._ports[key] = {
|
||||
"published": host_port,
|
||||
"target": container_port,
|
||||
"protocol": proto,
|
||||
"mode": mode,
|
||||
"host_ip": host_ip,
|
||||
}
|
||||
|
||||
def has_ports(self):
|
||||
return len(self._ports) > 0
|
||||
|
||||
def render(self):
|
||||
return [config for _, config in sorted(self._ports.items())]
|
||||
@@ -1,110 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_add_ports(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080)
|
||||
c1.ports.add_port(8082, 8080, {"protocol": "udp"})
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_ports(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080)
|
||||
c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080)
|
||||
|
||||
|
||||
def test_add_duplicate_ports_with_different_host_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"})
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"})
|
||||
|
||||
|
||||
def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"})
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_protocol(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_mode(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_host_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(-1, 8080)
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_container_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, -1)
|
||||
@@ -235,10 +235,13 @@ class Container:
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ip = config.get("host_ip", "0.0.0.0")
|
||||
host_ips = config.get("host_ips", ["0.0.0.0", "::"])
|
||||
if not isinstance(host_ips, list):
|
||||
raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]")
|
||||
|
||||
if bind_mode == "published":
|
||||
self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip})
|
||||
for host_ip in host_ips:
|
||||
self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip})
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import ipaddress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
|
||||
|
||||
class Ports:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._ports: dict[str, dict] = {}
|
||||
|
||||
def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str:
|
||||
return f"{host_port}_{host_ip}_{proto}_{ip_family}"
|
||||
|
||||
def _is_wildcard_ip(self, ip: str) -> bool:
|
||||
return ip in ["0.0.0.0", "::"]
|
||||
|
||||
def _get_opposite_wildcard(self, ip: str) -> str:
|
||||
return "0.0.0.0" if ip == "::" else "::"
|
||||
|
||||
def _get_sort_key(self, p: dict) -> str:
|
||||
return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}"
|
||||
|
||||
def _is_ports_same(self, port1: dict, port2: dict) -> bool:
|
||||
return (
|
||||
port1["published"] == port2["published"]
|
||||
and port1["target"] == port2["target"]
|
||||
and port1["protocol"] == port2["protocol"]
|
||||
and port1.get("host_ip", "_") == port2.get("host_ip", "_")
|
||||
)
|
||||
|
||||
def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool:
|
||||
comparison_port = port_config.copy()
|
||||
comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"])
|
||||
for p in wildcard_ports.values():
|
||||
if self._is_ports_same(comparison_port, p):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None:
|
||||
host_port = port_config["published"]
|
||||
host_ip = port_config["host_ip"]
|
||||
proto = port_config["protocol"]
|
||||
|
||||
key = self._gen_port_key(host_port, host_ip, proto, ip_family)
|
||||
|
||||
if key in self._ports.keys():
|
||||
raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]")
|
||||
|
||||
wildcard_ip = "0.0.0.0" if ip_family == 4 else "::"
|
||||
if host_ip != wildcard_ip:
|
||||
# Check if there is a port with same details but with wildcard IP of the same family
|
||||
wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family)
|
||||
if wildcard_key in self._ports.keys():
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], "
|
||||
f"already bound to [{wildcard_ip}]"
|
||||
)
|
||||
else:
|
||||
# We are adding a port with wildcard IP
|
||||
# Check if there is a port with same details but with specific IP of the same family
|
||||
for p in self._ports.values():
|
||||
# Skip if the port is not for the same family
|
||||
if ip_family != ipaddress.ip_address(p["host_ip"]).version:
|
||||
continue
|
||||
|
||||
# Make a copy of the port config
|
||||
search_port = p.copy()
|
||||
# Replace the host IP with wildcard IP
|
||||
search_port["host_ip"] = wildcard_ip
|
||||
# If the ports match, means that a port for specific IP is already added
|
||||
# and we are trying to add it again with wildcard IP. Raise an error
|
||||
if self._is_ports_same(search_port, port_config):
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], "
|
||||
f"already bound to [{p['host_ip']}]"
|
||||
)
|
||||
|
||||
def add_port(self, host_port: int, container_port: int, config: dict | None = None):
|
||||
config = config or {}
|
||||
host_port = valid_port_or_raise(host_port)
|
||||
container_port = valid_port_or_raise(container_port)
|
||||
proto = valid_port_protocol_or_raise(config.get("protocol", "tcp"))
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
|
||||
# TODO: Once all apps stop using this function directly, (ie using the container.add_port function)
|
||||
# Remove this, and let container.add_port call this for each host_ip
|
||||
host_ip = config.get("host_ip", None)
|
||||
if host_ip is None:
|
||||
self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"})
|
||||
self.add_port(host_port, container_port, config | {"host_ip": "::"})
|
||||
return
|
||||
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", None))
|
||||
ip = ipaddress.ip_address(host_ip)
|
||||
|
||||
port_config = {
|
||||
"published": host_port,
|
||||
"target": container_port,
|
||||
"protocol": proto,
|
||||
"mode": mode,
|
||||
"host_ip": host_ip,
|
||||
}
|
||||
self._check_port_conflicts(port_config, ip.version)
|
||||
|
||||
key = self._gen_port_key(host_port, host_ip, proto, ip.version)
|
||||
self._ports[key] = port_config
|
||||
|
||||
def has_ports(self):
|
||||
return len(self._ports) > 0
|
||||
|
||||
def render(self):
|
||||
specific_ports = []
|
||||
wildcard_ports = {}
|
||||
|
||||
for port_config in self._ports.values():
|
||||
if self._is_wildcard_ip(port_config["host_ip"]):
|
||||
wildcard_ports[id(port_config)] = port_config.copy()
|
||||
else:
|
||||
specific_ports.append(port_config.copy())
|
||||
|
||||
processed_ports = specific_ports.copy()
|
||||
for wild_port in wildcard_ports.values():
|
||||
processed_port = wild_port.copy()
|
||||
|
||||
# Check if there's a matching wildcard port for the opposite IP family
|
||||
has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports)
|
||||
|
||||
if has_opposite_family:
|
||||
processed_port.pop("host_ip")
|
||||
|
||||
if processed_port not in processed_ports:
|
||||
processed_ports.append(processed_port)
|
||||
|
||||
return sorted(processed_ports, key=self._get_sort_key)
|
||||
@@ -354,8 +354,16 @@ def test_add_ports(mock_values):
|
||||
)
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"},
|
||||
{"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"},
|
||||
]
|
||||
assert output["services"]["test_container"]["expose"] == ["8080/tcp"]
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_host_ips(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"})
|
||||
@@ -0,0 +1,209 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
tests = [
|
||||
{
|
||||
"name": "add_ports_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080), "expect_error": False},
|
||||
{"values": (8082, 8080, {"protocol": "udp"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "add_duplicate_ports_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080), "expect_error": False},
|
||||
{"values": (8081, 8080), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_different_protocol_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080), "expect_error": False},
|
||||
{"values": (8081, 8080, {"protocol": "udp"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"},
|
||||
{"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_same_port_for_both_wildcard_families_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v4_ip_and_v4_wildcard_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v4_wildcard_and_v4_ip_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "0.0.0.0"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v4_wildcard_and_v6_ip_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{
|
||||
"published": 8081,
|
||||
"target": 8080,
|
||||
"protocol": "tcp",
|
||||
"mode": "ingress",
|
||||
"host_ip": "fd00:1234:5678:abcd::10",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v6_wildcard_and_v4_ip_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "::"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v6_wildcard_and_v6_ip_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "::"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_for_v6_ip_and_v6_wildcard_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "::"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_port_with_different_v4_ip_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False},
|
||||
],
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_port_with_invalid_protocol_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"protocol": "invalid_protocol"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_port_with_invalid_mode_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"mode": "invalid_mode"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_port_with_invalid_ip_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "invalid_ip"}), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_port_with_invalid_host_port_should_fail",
|
||||
"inputs": [
|
||||
{"values": (-1, 8080), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_port_with_invalid_container_port_should_fail",
|
||||
"inputs": [
|
||||
{"values": (8081, -1), "expect_error": True},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "adding_duplicate_ports_with_different_host_ip_should_work",
|
||||
"inputs": [
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.10", "protocol": "udp"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.11"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "192.168.1.11", "protocol": "udp"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::10"}), "expect_error": False},
|
||||
{"values": (8081, 8080, {"host_ip": "fd00:1234:5678:abcd::11"}), "expect_error": False},
|
||||
],
|
||||
# fmt: off
|
||||
"expected": [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::10"}, # noqa
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "fd00:1234:5678:abcd::11"}, # noqa
|
||||
{"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "192.168.1.11"},
|
||||
],
|
||||
# fmt: on
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("test", tests)
|
||||
def test_ports(test):
|
||||
mock_values = {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
|
||||
errored = False
|
||||
for input in test["inputs"]:
|
||||
if input["expect_error"]:
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(*input["values"])
|
||||
errored = True
|
||||
else:
|
||||
c1.ports.add_port(*input["values"])
|
||||
|
||||
errored = True if [i["expect_error"] for i in test["inputs"]].count(True) > 0 else False
|
||||
if errored:
|
||||
return
|
||||
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == test["expected"]
|
||||
@@ -20,8 +20,8 @@ icon: https://media.sys.truenas.net/apps/adguard-home/icons/icon.svg
|
||||
keywords:
|
||||
- dns
|
||||
- adblock
|
||||
lib_version: 2.1.8
|
||||
lib_version_hash: a3251fe8d434248fc48ce7e1a29de015dab56de3687a93b925e79c986db2be44
|
||||
lib_version: 2.1.9
|
||||
lib_version_hash: 6bd4d433db7dce2d4b8cc456a0f7874b45d52a6e2b145d5a1f48f327654a7125
|
||||
maintainers:
|
||||
- email: dev@ixsystems.com
|
||||
name: truenas
|
||||
@@ -42,4 +42,4 @@ sources:
|
||||
- https://hub.docker.com/r/adguard/adguardhome
|
||||
title: AdGuard Home
|
||||
train: community
|
||||
version: 1.1.11
|
||||
version: 1.1.12
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
|
||||
|
||||
class Ports:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._ports: dict[str, dict] = {}
|
||||
|
||||
def add_port(self, host_port: int, container_port: int, config: dict | None = None):
|
||||
config = config or {}
|
||||
host_port = valid_port_or_raise(host_port)
|
||||
container_port = valid_port_or_raise(container_port)
|
||||
proto = valid_port_protocol_or_raise(config.get("protocol", "tcp"))
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", "0.0.0.0"))
|
||||
|
||||
key = f"{host_port}_{host_ip}_{proto}"
|
||||
if key in self._ports.keys():
|
||||
raise RenderError(f"Port [{host_port}/{proto}] already added for [{host_ip}]")
|
||||
|
||||
if host_ip != "0.0.0.0":
|
||||
# If the port we are adding is not going to use 0.0.0.0
|
||||
# Make sure that we don't have already added that port/proto to 0.0.0.0
|
||||
search_key = f"{host_port}_0.0.0.0_{proto}"
|
||||
if search_key in self._ports.keys():
|
||||
raise RenderError(f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [0.0.0.0]")
|
||||
elif host_ip == "0.0.0.0":
|
||||
# If the port we are adding is going to use 0.0.0.0
|
||||
# Make sure that we don't have already added that port/proto to a specific ip
|
||||
for p in self._ports.values():
|
||||
if p["published"] == host_port and p["protocol"] == proto:
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}] to [{host_ip}], already bound to [{p['host_ip']}]"
|
||||
)
|
||||
|
||||
self._ports[key] = {
|
||||
"published": host_port,
|
||||
"target": container_port,
|
||||
"protocol": proto,
|
||||
"mode": mode,
|
||||
"host_ip": host_ip,
|
||||
}
|
||||
|
||||
def has_ports(self):
|
||||
return len(self._ports) > 0
|
||||
|
||||
def render(self):
|
||||
return [config for _, config in sorted(self._ports.items())]
|
||||
@@ -1,110 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_add_ports(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080)
|
||||
c1.ports.add_port(8082, 8080, {"protocol": "udp"})
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_ports(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080)
|
||||
c1.ports.add_port(8081, 8080, {"protocol": "udp"}) # This should not raise
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080)
|
||||
|
||||
|
||||
def test_add_duplicate_ports_with_different_host_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.11"})
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.10"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "192.168.1.11"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_ports_to_specific_host_ip_binds_to_0_0_0_0(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"})
|
||||
|
||||
|
||||
def test_add_duplicate_ports_to_0_0_0_0_binds_to_specific_host_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "0.0.0.0"})
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "192.168.1.10"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_protocol(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"protocol": "invalid_protocol"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_mode(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"mode": "invalid_mode"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_ip(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, 8080, {"host_ip": "invalid_ip"})
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_host_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(-1, 8080)
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_container_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.ports.add_port(8081, -1)
|
||||
@@ -235,10 +235,13 @@ class Container:
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ip = config.get("host_ip", "0.0.0.0")
|
||||
host_ips = config.get("host_ips", ["0.0.0.0", "::"])
|
||||
if not isinstance(host_ips, list):
|
||||
raise RenderError(f"Expected [host_ips] to be a list, got [{host_ips}]")
|
||||
|
||||
if bind_mode == "published":
|
||||
self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip})
|
||||
for host_ip in host_ips:
|
||||
self.ports.add_port(host_port, container_port, {"protocol": protocol, "host_ip": host_ip})
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import ipaddress
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import (
|
||||
valid_ip_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_port_or_raise,
|
||||
valid_port_protocol_or_raise,
|
||||
)
|
||||
|
||||
|
||||
class Ports:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._ports: dict[str, dict] = {}
|
||||
|
||||
def _gen_port_key(self, host_port: int, host_ip: str, proto: str, ip_family: int) -> str:
|
||||
return f"{host_port}_{host_ip}_{proto}_{ip_family}"
|
||||
|
||||
def _is_wildcard_ip(self, ip: str) -> bool:
|
||||
return ip in ["0.0.0.0", "::"]
|
||||
|
||||
def _get_opposite_wildcard(self, ip: str) -> str:
|
||||
return "0.0.0.0" if ip == "::" else "::"
|
||||
|
||||
def _get_sort_key(self, p: dict) -> str:
|
||||
return f"{p['published']}_{p['target']}_{p['protocol']}_{p.get('host_ip', '_')}"
|
||||
|
||||
def _is_ports_same(self, port1: dict, port2: dict) -> bool:
|
||||
return (
|
||||
port1["published"] == port2["published"]
|
||||
and port1["target"] == port2["target"]
|
||||
and port1["protocol"] == port2["protocol"]
|
||||
and port1.get("host_ip", "_") == port2.get("host_ip", "_")
|
||||
)
|
||||
|
||||
def _has_opposite_family_port(self, port_config: dict, wildcard_ports: dict) -> bool:
|
||||
comparison_port = port_config.copy()
|
||||
comparison_port["host_ip"] = self._get_opposite_wildcard(port_config["host_ip"])
|
||||
for p in wildcard_ports.values():
|
||||
if self._is_ports_same(comparison_port, p):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_port_conflicts(self, port_config: dict, ip_family: int) -> None:
|
||||
host_port = port_config["published"]
|
||||
host_ip = port_config["host_ip"]
|
||||
proto = port_config["protocol"]
|
||||
|
||||
key = self._gen_port_key(host_port, host_ip, proto, ip_family)
|
||||
|
||||
if key in self._ports.keys():
|
||||
raise RenderError(f"Port [{host_port}/{proto}/ipv{ip_family}] already added for [{host_ip}]")
|
||||
|
||||
wildcard_ip = "0.0.0.0" if ip_family == 4 else "::"
|
||||
if host_ip != wildcard_ip:
|
||||
# Check if there is a port with same details but with wildcard IP of the same family
|
||||
wildcard_key = self._gen_port_key(host_port, wildcard_ip, proto, ip_family)
|
||||
if wildcard_key in self._ports.keys():
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], "
|
||||
f"already bound to [{wildcard_ip}]"
|
||||
)
|
||||
else:
|
||||
# We are adding a port with wildcard IP
|
||||
# Check if there is a port with same details but with specific IP of the same family
|
||||
for p in self._ports.values():
|
||||
# Skip if the port is not for the same family
|
||||
if ip_family != ipaddress.ip_address(p["host_ip"]).version:
|
||||
continue
|
||||
|
||||
# Make a copy of the port config
|
||||
search_port = p.copy()
|
||||
# Replace the host IP with wildcard IP
|
||||
search_port["host_ip"] = wildcard_ip
|
||||
# If the ports match, means that a port for specific IP is already added
|
||||
# and we are trying to add it again with wildcard IP. Raise an error
|
||||
if self._is_ports_same(search_port, port_config):
|
||||
raise RenderError(
|
||||
f"Cannot bind port [{host_port}/{proto}/ipv{ip_family}] to [{host_ip}], "
|
||||
f"already bound to [{p['host_ip']}]"
|
||||
)
|
||||
|
||||
def add_port(self, host_port: int, container_port: int, config: dict | None = None):
|
||||
config = config or {}
|
||||
host_port = valid_port_or_raise(host_port)
|
||||
container_port = valid_port_or_raise(container_port)
|
||||
proto = valid_port_protocol_or_raise(config.get("protocol", "tcp"))
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
|
||||
# TODO: Once all apps stop using this function directly, (ie using the container.add_port function)
|
||||
# Remove this, and let container.add_port call this for each host_ip
|
||||
host_ip = config.get("host_ip", None)
|
||||
if host_ip is None:
|
||||
self.add_port(host_port, container_port, config | {"host_ip": "0.0.0.0"})
|
||||
self.add_port(host_port, container_port, config | {"host_ip": "::"})
|
||||
return
|
||||
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", None))
|
||||
ip = ipaddress.ip_address(host_ip)
|
||||
|
||||
port_config = {
|
||||
"published": host_port,
|
||||
"target": container_port,
|
||||
"protocol": proto,
|
||||
"mode": mode,
|
||||
"host_ip": host_ip,
|
||||
}
|
||||
self._check_port_conflicts(port_config, ip.version)
|
||||
|
||||
key = self._gen_port_key(host_port, host_ip, proto, ip.version)
|
||||
self._ports[key] = port_config
|
||||
|
||||
def has_ports(self):
|
||||
return len(self._ports) > 0
|
||||
|
||||
def render(self):
|
||||
specific_ports = []
|
||||
wildcard_ports = {}
|
||||
|
||||
for port_config in self._ports.values():
|
||||
if self._is_wildcard_ip(port_config["host_ip"]):
|
||||
wildcard_ports[id(port_config)] = port_config.copy()
|
||||
else:
|
||||
specific_ports.append(port_config.copy())
|
||||
|
||||
processed_ports = specific_ports.copy()
|
||||
for wild_port in wildcard_ports.values():
|
||||
processed_port = wild_port.copy()
|
||||
|
||||
# Check if there's a matching wildcard port for the opposite IP family
|
||||
has_opposite_family = self._has_opposite_family_port(wild_port, wildcard_ports)
|
||||
|
||||
if has_opposite_family:
|
||||
processed_port.pop("host_ip")
|
||||
|
||||
if processed_port not in processed_ports:
|
||||
processed_ports.append(processed_port)
|
||||
|
||||
return sorted(processed_ports, key=self._get_sort_key)
|
||||
@@ -354,8 +354,16 @@ def test_add_ports(mock_values):
|
||||
)
|
||||
output = render.render()
|
||||
assert output["services"]["test_container"]["ports"] == [
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress", "host_ip": "0.0.0.0"},
|
||||
{"published": 8081, "target": 8080, "protocol": "tcp", "mode": "ingress"},
|
||||
{"published": 8082, "target": 8080, "protocol": "udp", "mode": "ingress"},
|
||||
{"published": 9091, "target": 9092, "protocol": "udp", "mode": "ingress"},
|
||||
]
|
||||
assert output["services"]["test_container"]["expose"] == ["8080/tcp"]
|
||||
|
||||
|
||||
def test_add_ports_with_invalid_host_ips(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
with pytest.raises(Exception):
|
||||
c1.add_port({"port_number": 8081, "container_port": 8080, "bind_mode": "published", "host_ips": "invalid"})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user