mirror of
https://github.com/MAGICGrants/truenas-apps.git
synced 2026-01-09 20:47:58 -05:00
remove refs and add range (#2504)
* lib: add port validation via tn client * lets try to match it * explain why * move validation inside the func * make it more robust * flake * better handling * fix unrelated bug * remove refs and add range * rm old lib * bump versions * bump ddns-updater local lib * add new lib * update lib * update lib
This commit is contained in:
@@ -14,8 +14,8 @@ icon: https://media.sys.truenas.net/apps/actual-budget/icons/icon.png
|
||||
keywords:
|
||||
- finance
|
||||
- budget
|
||||
lib_version: 2.1.33
|
||||
lib_version_hash: e1e627d9dcfb3c3810f1d1ebaa7978729a5ecad54ddcffe112ac1f3e9b1acba5
|
||||
lib_version: 2.1.35
|
||||
lib_version_hash: 1bd4e0058fbd4d7c207df2cae606580065e8e6dba3e232f41bc1b006848b05d2
|
||||
maintainers:
|
||||
- email: dev@ixsystems.com
|
||||
name: truenas
|
||||
@@ -36,4 +36,4 @@ sources:
|
||||
- https://hub.docker.com/r/actualbudget/actual-server
|
||||
title: Actual Budget
|
||||
train: community
|
||||
version: 1.3.4
|
||||
version: 1.3.5
|
||||
|
||||
@@ -98,9 +98,9 @@ questions:
|
||||
schema:
|
||||
type: int
|
||||
default: 31012
|
||||
min: 1
|
||||
max: 65535
|
||||
required: true
|
||||
$ref:
|
||||
- definitions/port
|
||||
- variable: host_ips
|
||||
label: Host IPs
|
||||
description: IPs on the host to bind this port
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
from storage import IxStorage
|
||||
|
||||
try:
|
||||
from .configs import ContainerConfigs
|
||||
from .depends import Depends
|
||||
from .deploy import Deploy
|
||||
from .device_cgroup_rules import DeviceCGroupRules
|
||||
from .devices import Devices
|
||||
from .dns import Dns
|
||||
from .environment import Environment
|
||||
from .error import RenderError
|
||||
from .expose import Expose
|
||||
from .extra_hosts import ExtraHosts
|
||||
from .formatter import escape_dollar, get_image_with_hashed_data
|
||||
from .healthcheck import Healthcheck
|
||||
from .labels import Labels
|
||||
from .ports import Ports
|
||||
from .restart import RestartPolicy
|
||||
from .tmpfs import Tmpfs
|
||||
from .validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from .security_opts import SecurityOpts
|
||||
from .storage import Storage
|
||||
from .sysctls import Sysctls
|
||||
except ImportError:
|
||||
from configs import ContainerConfigs
|
||||
from depends import Depends
|
||||
from deploy import Deploy
|
||||
from device_cgroup_rules import DeviceCGroupRules
|
||||
from devices import Devices
|
||||
from dns import Dns
|
||||
from environment import Environment
|
||||
from error import RenderError
|
||||
from expose import Expose
|
||||
from extra_hosts import ExtraHosts
|
||||
from formatter import escape_dollar, get_image_with_hashed_data
|
||||
from healthcheck import Healthcheck
|
||||
from labels import Labels
|
||||
from ports import Ports
|
||||
from restart import RestartPolicy
|
||||
from tmpfs import Tmpfs
|
||||
from validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from security_opts import SecurityOpts
|
||||
from storage import Storage
|
||||
from sysctls import Sysctls
|
||||
|
||||
|
||||
class Container:
|
||||
def __init__(self, render_instance: "Render", name: str, image: str):
|
||||
self._render_instance = render_instance
|
||||
|
||||
self._name: str = name
|
||||
self._image: str = self._resolve_image(image)
|
||||
self._build_image: str = ""
|
||||
self._pull_policy: str = ""
|
||||
self._user: str = ""
|
||||
self._tty: bool = False
|
||||
self._stdin_open: bool = False
|
||||
self._init: bool | None = None
|
||||
self._read_only: bool | None = None
|
||||
self._extra_hosts: ExtraHosts = ExtraHosts(self._render_instance)
|
||||
self._hostname: str = ""
|
||||
self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly
|
||||
self._cap_add: set[str] = set()
|
||||
self._security_opt: SecurityOpts = SecurityOpts(self._render_instance)
|
||||
self._privileged: bool = False
|
||||
self._group_add: set[int | str] = set()
|
||||
self._network_mode: str = ""
|
||||
self._entrypoint: list[str] = []
|
||||
self._command: list[str] = []
|
||||
self._grace_period: int | None = None
|
||||
self._shm_size: int | None = None
|
||||
self._storage: Storage = Storage(self._render_instance, self)
|
||||
self._tmpfs: Tmpfs = Tmpfs(self._render_instance, self)
|
||||
self._ipc_mode: str | None = None
|
||||
self._cgroup: str | None = None
|
||||
self._device_cgroup_rules: DeviceCGroupRules = DeviceCGroupRules(self._render_instance)
|
||||
self.sysctls: Sysctls = Sysctls(self._render_instance, self)
|
||||
self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs)
|
||||
self.deploy: Deploy = Deploy(self._render_instance)
|
||||
self.networks: set[str] = set()
|
||||
self.devices: Devices = Devices(self._render_instance)
|
||||
self.environment: Environment = Environment(self._render_instance, self.deploy.resources)
|
||||
self.dns: Dns = Dns(self._render_instance)
|
||||
self.depends: Depends = Depends(self._render_instance)
|
||||
self.healthcheck: Healthcheck = Healthcheck(self._render_instance)
|
||||
self.labels: Labels = Labels(self._render_instance)
|
||||
self.restart: RestartPolicy = RestartPolicy(self._render_instance)
|
||||
self.ports: Ports = Ports(self._render_instance)
|
||||
self.expose: Expose = Expose(self._render_instance)
|
||||
|
||||
self._auto_set_network_mode()
|
||||
self._auto_add_labels()
|
||||
self._auto_add_groups()
|
||||
|
||||
def _auto_add_groups(self):
|
||||
self.add_group(568)
|
||||
|
||||
def _auto_set_network_mode(self):
|
||||
if self._render_instance.values.get("network", {}).get("host_network", False):
|
||||
self.set_network_mode("host")
|
||||
|
||||
def _auto_add_labels(self):
|
||||
labels = self._render_instance.values.get("labels", [])
|
||||
if not labels:
|
||||
return
|
||||
|
||||
for label in labels:
|
||||
containers = label.get("containers", [])
|
||||
if not containers:
|
||||
raise RenderError(f'Label [{label.get("key", "")}] must have at least one container')
|
||||
|
||||
if self._name in containers:
|
||||
self.labels.add_label(label["key"], label["value"])
|
||||
|
||||
def _resolve_image(self, image: str):
|
||||
images = self._render_instance.values["images"]
|
||||
if image not in images:
|
||||
raise RenderError(
|
||||
f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]"
|
||||
)
|
||||
repo = images[image].get("repository", "")
|
||||
tag = images[image].get("tag", "")
|
||||
|
||||
if not repo:
|
||||
raise RenderError(f"Repository not found for image [{image}]")
|
||||
if not tag:
|
||||
raise RenderError(f"Tag not found for image [{image}]")
|
||||
|
||||
return f"{repo}:{tag}"
|
||||
|
||||
def build_image(self, content: list[str | None]):
|
||||
dockerfile = f"FROM {self._image}\n"
|
||||
for line in content:
|
||||
line = line.strip() if line else ""
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("FROM"):
|
||||
# TODO: This will also block multi-stage builds
|
||||
# We can revisit this later if we need it
|
||||
raise RenderError(
|
||||
"FROM cannot be used in build image. Define the base image when creating the container."
|
||||
)
|
||||
dockerfile += line + "\n"
|
||||
|
||||
self._build_image = dockerfile
|
||||
self._image = get_image_with_hashed_data(self._image, dockerfile)
|
||||
|
||||
def set_pull_policy(self, pull_policy: str):
|
||||
self._pull_policy = valid_pull_policy_or_raise(pull_policy)
|
||||
|
||||
def set_user(self, user: int, group: int):
|
||||
for i in (user, group):
|
||||
if not isinstance(i, int) or i < 0:
|
||||
raise RenderError(f"User/Group [{i}] is not valid")
|
||||
self._user = f"{user}:{group}"
|
||||
|
||||
def add_extra_host(self, host: str, ip: str):
|
||||
self._extra_hosts.add_host(host, ip)
|
||||
|
||||
def add_group(self, group: int | str):
|
||||
if isinstance(group, str):
|
||||
group = str(group).strip()
|
||||
if group.isdigit():
|
||||
raise RenderError(f"Group is a number [{group}] but passed as a string")
|
||||
|
||||
if group in self._group_add:
|
||||
raise RenderError(f"Group [{group}] already added")
|
||||
self._group_add.add(group)
|
||||
|
||||
def get_additional_groups(self) -> list[int | str]:
|
||||
result = []
|
||||
if self.deploy.resources.has_gpus() or self.devices.has_gpus():
|
||||
result.append(44) # video
|
||||
result.append(107) # render
|
||||
return result
|
||||
|
||||
def get_current_groups(self) -> list[str]:
|
||||
result = [str(g) for g in self._group_add]
|
||||
result.extend([str(g) for g in self.get_additional_groups()])
|
||||
return result
|
||||
|
||||
def set_tty(self, enabled: bool = False):
|
||||
self._tty = enabled
|
||||
|
||||
def set_stdin(self, enabled: bool = False):
|
||||
self._stdin_open = enabled
|
||||
|
||||
def set_ipc_mode(self, ipc_mode: str):
|
||||
self._ipc_mode = valid_ipc_mode_or_raise(ipc_mode, self._render_instance.container_names())
|
||||
|
||||
def add_device_cgroup_rule(self, dev_grp_rule: str):
|
||||
self._device_cgroup_rules.add_rule(dev_grp_rule)
|
||||
|
||||
def set_cgroup(self, cgroup: str):
|
||||
self._cgroup = valid_cgroup_or_raise(cgroup)
|
||||
|
||||
def set_init(self, enabled: bool = False):
|
||||
self._init = enabled
|
||||
|
||||
def set_read_only(self, enabled: bool = False):
|
||||
self._read_only = enabled
|
||||
|
||||
def set_hostname(self, hostname: str):
|
||||
self._hostname = hostname
|
||||
|
||||
def set_grace_period(self, grace_period: int):
|
||||
if grace_period < 0:
|
||||
raise RenderError(f"Grace period [{grace_period}] cannot be negative")
|
||||
self._grace_period = grace_period
|
||||
|
||||
def set_privileged(self, enabled: bool = False):
|
||||
self._privileged = enabled
|
||||
|
||||
def clear_caps(self):
|
||||
self._cap_add.clear()
|
||||
self._cap_drop.clear()
|
||||
|
||||
def add_caps(self, caps: list[str]):
|
||||
for c in caps:
|
||||
if c in self._cap_add:
|
||||
raise RenderError(f"Capability [{c}] already added")
|
||||
self._cap_add.add(valid_cap_or_raise(c))
|
||||
|
||||
def add_security_opt(self, key: str, value: str | bool | None = None, arg: str | None = None):
|
||||
self._security_opt.add_opt(key, value, arg)
|
||||
|
||||
def remove_security_opt(self, key: str):
|
||||
self._security_opt.remove_opt(key)
|
||||
|
||||
def set_network_mode(self, mode: str):
|
||||
self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names())
|
||||
|
||||
def add_port(self, port_config: dict | None = None, dev_config: dict | None = None):
|
||||
port_config = port_config or {}
|
||||
dev_config = dev_config or {}
|
||||
# Merge port_config and dev_config (dev_config has precedence)
|
||||
config = port_config | dev_config
|
||||
|
||||
bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", ""))
|
||||
# Skip port if its neither published nor exposed
|
||||
if not bind_mode:
|
||||
return
|
||||
|
||||
# Collect port config
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ips = config.get("host_ips") or ["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":
|
||||
for host_ip in host_ips:
|
||||
self.ports._add_port(
|
||||
host_port, container_port, {"protocol": protocol, "host_ip": host_ip, "mode": mode}
|
||||
)
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
def set_entrypoint(self, entrypoint: list[str]):
|
||||
self._entrypoint = [escape_dollar(str(e)) for e in entrypoint]
|
||||
|
||||
def set_command(self, command: list[str]):
|
||||
self._command = [escape_dollar(str(e)) for e in command]
|
||||
|
||||
def add_storage(self, mount_path: str, config: "IxStorage"):
|
||||
if config.get("type", "") == "tmpfs":
|
||||
self._tmpfs.add(mount_path, config)
|
||||
else:
|
||||
self._storage.add(mount_path, config)
|
||||
|
||||
def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"):
|
||||
self.add_group(999)
|
||||
self._storage._add_docker_socket(read_only, mount_path)
|
||||
|
||||
def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"):
|
||||
self._storage._add_udev(read_only, mount_path)
|
||||
|
||||
def add_tun_device(self):
|
||||
self.devices._add_tun_device()
|
||||
|
||||
def add_snd_device(self):
|
||||
self.add_group(29)
|
||||
self.devices._add_snd_device()
|
||||
|
||||
def set_shm_size_mb(self, size: int):
|
||||
self._shm_size = size
|
||||
|
||||
# Easily remove devices from the container
|
||||
# Useful in dependencies like postgres and redis
|
||||
# where there is no need to pass devices to them
|
||||
def remove_devices(self):
|
||||
self.deploy.resources.remove_devices()
|
||||
self.devices.remove_devices()
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
return self._storage
|
||||
|
||||
def render(self) -> dict[str, Any]:
|
||||
if self._network_mode and self.networks:
|
||||
raise RenderError("Cannot set both [network_mode] and [networks]")
|
||||
|
||||
result = {
|
||||
"image": self._image,
|
||||
"platform": "linux/amd64",
|
||||
"tty": self._tty,
|
||||
"stdin_open": self._stdin_open,
|
||||
"restart": self.restart.render(),
|
||||
}
|
||||
|
||||
if self._pull_policy:
|
||||
result["pull_policy"] = self._pull_policy
|
||||
|
||||
if self.healthcheck.has_healthcheck():
|
||||
result["healthcheck"] = self.healthcheck.render()
|
||||
|
||||
if self._hostname:
|
||||
result["hostname"] = self._hostname
|
||||
|
||||
if self._build_image:
|
||||
result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image}
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
if self._ipc_mode is not None:
|
||||
result["ipc"] = self._ipc_mode
|
||||
|
||||
if self._device_cgroup_rules.has_rules():
|
||||
result["device_cgroup_rules"] = self._device_cgroup_rules.render()
|
||||
|
||||
if self._cgroup is not None:
|
||||
result["cgroup"] = self._cgroup
|
||||
|
||||
if self._extra_hosts.has_hosts():
|
||||
result["extra_hosts"] = self._extra_hosts.render()
|
||||
|
||||
if self._init is not None:
|
||||
result["init"] = self._init
|
||||
|
||||
if self._read_only is not None:
|
||||
result["read_only"] = self._read_only
|
||||
|
||||
if self._grace_period is not None:
|
||||
result["stop_grace_period"] = f"{self._grace_period}s"
|
||||
|
||||
if self._user:
|
||||
result["user"] = self._user
|
||||
|
||||
for g in self.get_additional_groups():
|
||||
self.add_group(g)
|
||||
|
||||
if self._group_add:
|
||||
result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g))
|
||||
|
||||
if self._shm_size is not None:
|
||||
result["shm_size"] = f"{self._shm_size}M"
|
||||
|
||||
if self._privileged is not None:
|
||||
result["privileged"] = self._privileged
|
||||
|
||||
if self._cap_drop:
|
||||
result["cap_drop"] = sorted(self._cap_drop)
|
||||
|
||||
if self._cap_add:
|
||||
result["cap_add"] = sorted(self._cap_add)
|
||||
|
||||
if self._security_opt.has_opts():
|
||||
result["security_opt"] = self._security_opt.render()
|
||||
|
||||
if self._network_mode:
|
||||
result["network_mode"] = self._network_mode
|
||||
|
||||
if self.sysctls.has_sysctls():
|
||||
result["sysctls"] = self.sysctls.render()
|
||||
|
||||
if self._network_mode != "host":
|
||||
if self.ports.has_ports():
|
||||
result["ports"] = self.ports.render()
|
||||
|
||||
if self.expose.has_ports():
|
||||
result["expose"] = self.expose.render()
|
||||
|
||||
if self._entrypoint:
|
||||
result["entrypoint"] = self._entrypoint
|
||||
|
||||
if self._command:
|
||||
result["command"] = self._command
|
||||
|
||||
if self.devices.has_devices():
|
||||
result["devices"] = self.devices.render()
|
||||
|
||||
if self.deploy.has_deploy():
|
||||
result["deploy"] = self.deploy.render()
|
||||
|
||||
if self.environment.has_variables():
|
||||
result["environment"] = self.environment.render()
|
||||
|
||||
if self.labels.has_labels():
|
||||
result["labels"] = self.labels.render()
|
||||
|
||||
if self.dns.has_dns_nameservers():
|
||||
result["dns"] = self.dns.render_dns_nameservers()
|
||||
|
||||
if self.dns.has_dns_searches():
|
||||
result["dns_search"] = self.dns.render_dns_searches()
|
||||
|
||||
if self.dns.has_dns_opts():
|
||||
result["dns_opt"] = self.dns.render_dns_opts()
|
||||
|
||||
if self.depends.has_dependencies():
|
||||
result["depends_on"] = self.depends.render()
|
||||
|
||||
if self._storage.has_mounts():
|
||||
result["volumes"] = self._storage.render()
|
||||
|
||||
if self._tmpfs.has_tmpfs():
|
||||
result["tmpfs"] = self._tmpfs.render()
|
||||
|
||||
return result
|
||||
@@ -1,68 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import copy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
|
||||
|
||||
class Portals:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._portals: set[Portal] = set()
|
||||
|
||||
def add(self, port: dict, config: dict | None = None):
|
||||
config = copy.deepcopy((config or {}))
|
||||
port = copy.deepcopy((port or {}))
|
||||
name = config.get("name", "Web UI")
|
||||
host = config.get("host", None)
|
||||
|
||||
host_ips = port.get("host_ips", [])
|
||||
if not isinstance(host_ips, list):
|
||||
raise RenderError("Expected [host_ips] to be a list of strings")
|
||||
|
||||
# Remove wildcard IPs
|
||||
if "::" in host_ips:
|
||||
host_ips.remove("::")
|
||||
if "0.0.0.0" in host_ips:
|
||||
host_ips.remove("0.0.0.0")
|
||||
|
||||
# If host is not set, use the first host_ip (if it exists)
|
||||
if not host and len(host_ips) >= 1:
|
||||
host = host_ips[0]
|
||||
|
||||
config["host"] = host
|
||||
if config.get("port") is None:
|
||||
config["port"] = port.get("port_number", 0)
|
||||
|
||||
if name in [p._name for p in self._portals]:
|
||||
raise RenderError(f"Portal [{name}] already added")
|
||||
self._portals.add(Portal(name, config))
|
||||
|
||||
def render(self):
|
||||
return [p.render() for _, p in sorted([(p._name, p) for p in self._portals])]
|
||||
|
||||
|
||||
class Portal:
|
||||
def __init__(self, name: str, config: dict):
|
||||
self._name = name
|
||||
self._scheme = valid_portal_scheme_or_raise(config.get("scheme", "http"))
|
||||
self._host = config.get("host", "0.0.0.0") or "0.0.0.0"
|
||||
self._port = valid_port_or_raise(config.get("port", 0))
|
||||
self._path = valid_http_path_or_raise(config.get("path", "/"))
|
||||
|
||||
def render(self):
|
||||
return {
|
||||
"name": self._name,
|
||||
"scheme": self._scheme,
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"path": self._path,
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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"))
|
||||
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", ""))
|
||||
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)
|
||||
@@ -1,95 +0,0 @@
|
||||
import copy
|
||||
|
||||
try:
|
||||
from .configs import Configs
|
||||
from .container import Container
|
||||
from .deps import Deps
|
||||
from .error import RenderError
|
||||
from .functions import Functions
|
||||
from .notes import Notes
|
||||
from .portals import Portals
|
||||
from .volumes import Volumes
|
||||
except ImportError:
|
||||
from configs import Configs
|
||||
from container import Container
|
||||
from deps import Deps
|
||||
from error import RenderError
|
||||
from functions import Functions
|
||||
from notes import Notes
|
||||
from portals import Portals
|
||||
from volumes import Volumes
|
||||
|
||||
|
||||
class Render(object):
|
||||
def __init__(self, values):
|
||||
self._containers: dict[str, Container] = {}
|
||||
self.values = values
|
||||
self._add_images_internal_use()
|
||||
# Make a copy after we inject the images
|
||||
self._original_values: dict = copy.deepcopy(self.values)
|
||||
|
||||
self.deps: Deps = Deps(self)
|
||||
|
||||
self.configs = Configs(render_instance=self)
|
||||
self.funcs = Functions(render_instance=self).func_map()
|
||||
self.portals: Portals = Portals(render_instance=self)
|
||||
self.notes: Notes = Notes(render_instance=self)
|
||||
self.volumes = Volumes(render_instance=self)
|
||||
|
||||
def _add_images_internal_use(self):
|
||||
if not self.values.get("images"):
|
||||
self.values["images"] = {}
|
||||
|
||||
if "python_permissions_image" not in self.values["images"]:
|
||||
self.values["images"]["python_permissions_image"] = {"repository": "python", "tag": "3.13.0-slim-bookworm"}
|
||||
|
||||
if "postgres_upgrade_image" not in self.values["images"]:
|
||||
self.values["images"]["postgres_upgrade_image"] = {
|
||||
"repository": "ixsystems/postgres-upgrade",
|
||||
"tag": "1.0.1",
|
||||
}
|
||||
|
||||
def container_names(self):
|
||||
return list(self._containers.keys())
|
||||
|
||||
def add_container(self, name: str, image: str):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise RenderError("Container name cannot be empty")
|
||||
container = Container(self, name, image)
|
||||
if name in self._containers:
|
||||
raise RenderError(f"Container {name} already exists.")
|
||||
self._containers[name] = container
|
||||
return container
|
||||
|
||||
def render(self):
|
||||
if self.values != self._original_values:
|
||||
raise RenderError("Values have been modified since the renderer was created.")
|
||||
|
||||
if not self._containers:
|
||||
raise RenderError("No containers added.")
|
||||
|
||||
result: dict = {
|
||||
"x-notes": self.notes.render(),
|
||||
"x-portals": self.portals.render(),
|
||||
"services": {c._name: c.render() for c in self._containers.values()},
|
||||
}
|
||||
|
||||
# Make sure that after services are rendered
|
||||
# there are no labels that target a non-existent container
|
||||
# This is to prevent typos
|
||||
for label in self.values.get("labels", []):
|
||||
for c in label.get("containers", []):
|
||||
if c not in self.container_names():
|
||||
raise RenderError(f"Label [{label['key']}] references container [{c}] which does not exist")
|
||||
|
||||
if self.volumes.has_volumes():
|
||||
result["volumes"] = self.volumes.render()
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
# if self.networks:
|
||||
# result["networks"] = {...}
|
||||
|
||||
return result
|
||||
@@ -1,90 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_no_portals(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
output = render.render()
|
||||
assert output["x-portals"] == []
|
||||
|
||||
|
||||
def test_add_portal_with_host_ips(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
port2 = {"port_number": 8081, "host_ips": ["::", "0.0.0.0"]}
|
||||
port3 = {"port_number": 8081, "host_ips": ["1.2.3.4"]}
|
||||
render.portals.add(port)
|
||||
render.portals.add(port, {"name": "test", "host": "my-host.com"})
|
||||
render.portals.add(port2, {"name": "test2"})
|
||||
render.portals.add(port3, {"name": "test3", "port": None})
|
||||
render.portals.add(port3, {"name": "test4", "port": 1234})
|
||||
output = render.render()
|
||||
assert output["x-portals"] == [
|
||||
{"name": "Web UI", "scheme": "http", "host": "1.2.3.4", "port": 8080, "path": "/"},
|
||||
{"name": "test", "scheme": "http", "host": "my-host.com", "port": 8080, "path": "/"},
|
||||
{"name": "test2", "scheme": "http", "host": "0.0.0.0", "port": 8081, "path": "/"},
|
||||
{"name": "test3", "scheme": "http", "host": "1.2.3.4", "port": 8081, "path": "/"},
|
||||
{"name": "test4", "scheme": "http", "host": "1.2.3.4", "port": 1234, "path": "/"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_portal(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port)
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port)
|
||||
|
||||
|
||||
def test_add_duplicate_portal_with_explicit_name(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_scheme(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"scheme": "invalid_scheme"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "invalid_path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path_double_slash(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "/some//path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"port": -1})
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
|
||||
|
||||
def is_truenas_system():
|
||||
"""Check if we're running on a TrueNAS system"""
|
||||
return "truenas" in os.uname().release
|
||||
|
||||
|
||||
# Import based on system detection
|
||||
if is_truenas_system():
|
||||
from truenas_api_client import Client as TrueNASClient
|
||||
|
||||
try:
|
||||
# 25.04 and later
|
||||
from truenas_api_client.exc import ValidationErrors
|
||||
except ImportError:
|
||||
# 24.10 and earlier
|
||||
from truenas_api_client import ValidationErrors
|
||||
else:
|
||||
# Mock classes for non-TrueNAS systems
|
||||
class TrueNASClient:
|
||||
def call(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
class ValidationErrors(Exception):
|
||||
def __init__(self, errors):
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self.client = TrueNASClient()
|
||||
self._render_instance = render_instance
|
||||
self._app_name: str = self._render_instance.values.get("ix_context", {}).get("app_name", "") or "unknown"
|
||||
|
||||
def validate_ip_port_combo(self, ip: str, port: int) -> None:
|
||||
# Example of an error messages:
|
||||
# The port is being used by following services: 1) "0.0.0.0:80" used by WebUI Service
|
||||
# The port is being used by following services: 1) "0.0.0.0:9998" used by Applications ('$app_name' application)
|
||||
try:
|
||||
self.client.call("port.validate_port", f"render.{self._app_name}.schema", port, ip, None, True)
|
||||
except ValidationErrors as e:
|
||||
err_str = str(e)
|
||||
# If the IP:port combo appears more than once in the error message,
|
||||
# means that the port is used by more than one service/app.
|
||||
# This shouldn't happen in a well-configured system.
|
||||
# Notice that the ip portion is not included check,
|
||||
# because input might be a specific IP, but another service or app
|
||||
# might be using the same port on a wildcard IP
|
||||
if err_str.count(f':{port}" used by') > 1:
|
||||
raise RenderError(err_str) from None
|
||||
|
||||
# If the error complains about the current app, we ignore it
|
||||
# This is to handle cases where the app is being updated or edited
|
||||
if f"Applications ('{self._app_name}' application)" in err_str:
|
||||
# During upgrade, we want to ignore the error if it is related to the current app
|
||||
return
|
||||
|
||||
raise RenderError(err_str) from None
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,441 @@
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
from storage import IxStorage
|
||||
|
||||
try:
|
||||
from .configs import ContainerConfigs
|
||||
from .depends import Depends
|
||||
from .deploy import Deploy
|
||||
from .device_cgroup_rules import DeviceCGroupRules
|
||||
from .devices import Devices
|
||||
from .dns import Dns
|
||||
from .environment import Environment
|
||||
from .error import RenderError
|
||||
from .expose import Expose
|
||||
from .extra_hosts import ExtraHosts
|
||||
from .formatter import escape_dollar, get_image_with_hashed_data
|
||||
from .healthcheck import Healthcheck
|
||||
from .labels import Labels
|
||||
from .ports import Ports
|
||||
from .restart import RestartPolicy
|
||||
from .tmpfs import Tmpfs
|
||||
from .validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from .security_opts import SecurityOpts
|
||||
from .storage import Storage
|
||||
from .sysctls import Sysctls
|
||||
except ImportError:
|
||||
from configs import ContainerConfigs
|
||||
from depends import Depends
|
||||
from deploy import Deploy
|
||||
from device_cgroup_rules import DeviceCGroupRules
|
||||
from devices import Devices
|
||||
from dns import Dns
|
||||
from environment import Environment
|
||||
from error import RenderError
|
||||
from expose import Expose
|
||||
from extra_hosts import ExtraHosts
|
||||
from formatter import escape_dollar, get_image_with_hashed_data
|
||||
from healthcheck import Healthcheck
|
||||
from labels import Labels
|
||||
from ports import Ports
|
||||
from restart import RestartPolicy
|
||||
from tmpfs import Tmpfs
|
||||
from validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from security_opts import SecurityOpts
|
||||
from storage import Storage
|
||||
from sysctls import Sysctls
|
||||
|
||||
|
||||
class Container:
|
||||
def __init__(self, render_instance: "Render", name: str, image: str):
|
||||
self._render_instance = render_instance
|
||||
|
||||
self._name: str = name
|
||||
self._image: str = self._resolve_image(image)
|
||||
self._build_image: str = ""
|
||||
self._pull_policy: str = ""
|
||||
self._user: str = ""
|
||||
self._tty: bool = False
|
||||
self._stdin_open: bool = False
|
||||
self._init: bool | None = None
|
||||
self._read_only: bool | None = None
|
||||
self._extra_hosts: ExtraHosts = ExtraHosts(self._render_instance)
|
||||
self._hostname: str = ""
|
||||
self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly
|
||||
self._cap_add: set[str] = set()
|
||||
self._security_opt: SecurityOpts = SecurityOpts(self._render_instance)
|
||||
self._privileged: bool = False
|
||||
self._group_add: set[int | str] = set()
|
||||
self._network_mode: str = ""
|
||||
self._entrypoint: list[str] = []
|
||||
self._command: list[str] = []
|
||||
self._grace_period: int | None = None
|
||||
self._shm_size: int | None = None
|
||||
self._storage: Storage = Storage(self._render_instance, self)
|
||||
self._tmpfs: Tmpfs = Tmpfs(self._render_instance, self)
|
||||
self._ipc_mode: str | None = None
|
||||
self._cgroup: str | None = None
|
||||
self._device_cgroup_rules: DeviceCGroupRules = DeviceCGroupRules(self._render_instance)
|
||||
self.sysctls: Sysctls = Sysctls(self._render_instance, self)
|
||||
self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs)
|
||||
self.deploy: Deploy = Deploy(self._render_instance)
|
||||
self.networks: set[str] = set()
|
||||
self.devices: Devices = Devices(self._render_instance)
|
||||
self.environment: Environment = Environment(self._render_instance, self.deploy.resources)
|
||||
self.dns: Dns = Dns(self._render_instance)
|
||||
self.depends: Depends = Depends(self._render_instance)
|
||||
self.healthcheck: Healthcheck = Healthcheck(self._render_instance)
|
||||
self.labels: Labels = Labels(self._render_instance)
|
||||
self.restart: RestartPolicy = RestartPolicy(self._render_instance)
|
||||
self.ports: Ports = Ports(self._render_instance)
|
||||
self.expose: Expose = Expose(self._render_instance)
|
||||
|
||||
self._auto_set_network_mode()
|
||||
self._auto_add_labels()
|
||||
self._auto_add_groups()
|
||||
|
||||
def _auto_add_groups(self):
|
||||
self.add_group(568)
|
||||
|
||||
def _auto_set_network_mode(self):
|
||||
if self._render_instance.values.get("network", {}).get("host_network", False):
|
||||
self.set_network_mode("host")
|
||||
|
||||
def _auto_add_labels(self):
|
||||
labels = self._render_instance.values.get("labels", [])
|
||||
if not labels:
|
||||
return
|
||||
|
||||
for label in labels:
|
||||
containers = label.get("containers", [])
|
||||
if not containers:
|
||||
raise RenderError(f'Label [{label.get("key", "")}] must have at least one container')
|
||||
|
||||
if self._name in containers:
|
||||
self.labels.add_label(label["key"], label["value"])
|
||||
|
||||
def _resolve_image(self, image: str):
|
||||
images = self._render_instance.values["images"]
|
||||
if image not in images:
|
||||
raise RenderError(
|
||||
f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]"
|
||||
)
|
||||
repo = images[image].get("repository", "")
|
||||
tag = images[image].get("tag", "")
|
||||
|
||||
if not repo:
|
||||
raise RenderError(f"Repository not found for image [{image}]")
|
||||
if not tag:
|
||||
raise RenderError(f"Tag not found for image [{image}]")
|
||||
|
||||
return f"{repo}:{tag}"
|
||||
|
||||
def build_image(self, content: list[str | None]):
|
||||
dockerfile = f"FROM {self._image}\n"
|
||||
for line in content:
|
||||
line = line.strip() if line else ""
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("FROM"):
|
||||
# TODO: This will also block multi-stage builds
|
||||
# We can revisit this later if we need it
|
||||
raise RenderError(
|
||||
"FROM cannot be used in build image. Define the base image when creating the container."
|
||||
)
|
||||
dockerfile += line + "\n"
|
||||
|
||||
self._build_image = dockerfile
|
||||
self._image = get_image_with_hashed_data(self._image, dockerfile)
|
||||
|
||||
def set_pull_policy(self, pull_policy: str):
|
||||
self._pull_policy = valid_pull_policy_or_raise(pull_policy)
|
||||
|
||||
def set_user(self, user: int, group: int):
|
||||
for i in (user, group):
|
||||
if not isinstance(i, int) or i < 0:
|
||||
raise RenderError(f"User/Group [{i}] is not valid")
|
||||
self._user = f"{user}:{group}"
|
||||
|
||||
def add_extra_host(self, host: str, ip: str):
|
||||
self._extra_hosts.add_host(host, ip)
|
||||
|
||||
def add_group(self, group: int | str):
|
||||
if isinstance(group, str):
|
||||
group = str(group).strip()
|
||||
if group.isdigit():
|
||||
raise RenderError(f"Group is a number [{group}] but passed as a string")
|
||||
|
||||
if group in self._group_add:
|
||||
raise RenderError(f"Group [{group}] already added")
|
||||
self._group_add.add(group)
|
||||
|
||||
def get_additional_groups(self) -> list[int | str]:
|
||||
result = []
|
||||
if self.deploy.resources.has_gpus() or self.devices.has_gpus():
|
||||
result.append(44) # video
|
||||
result.append(107) # render
|
||||
return result
|
||||
|
||||
def get_current_groups(self) -> list[str]:
|
||||
result = [str(g) for g in self._group_add]
|
||||
result.extend([str(g) for g in self.get_additional_groups()])
|
||||
return result
|
||||
|
||||
def set_tty(self, enabled: bool = False):
|
||||
self._tty = enabled
|
||||
|
||||
def set_stdin(self, enabled: bool = False):
|
||||
self._stdin_open = enabled
|
||||
|
||||
def set_ipc_mode(self, ipc_mode: str):
|
||||
self._ipc_mode = valid_ipc_mode_or_raise(ipc_mode, self._render_instance.container_names())
|
||||
|
||||
def add_device_cgroup_rule(self, dev_grp_rule: str):
|
||||
self._device_cgroup_rules.add_rule(dev_grp_rule)
|
||||
|
||||
def set_cgroup(self, cgroup: str):
|
||||
self._cgroup = valid_cgroup_or_raise(cgroup)
|
||||
|
||||
def set_init(self, enabled: bool = False):
|
||||
self._init = enabled
|
||||
|
||||
def set_read_only(self, enabled: bool = False):
|
||||
self._read_only = enabled
|
||||
|
||||
def set_hostname(self, hostname: str):
|
||||
self._hostname = hostname
|
||||
|
||||
def set_grace_period(self, grace_period: int):
|
||||
if grace_period < 0:
|
||||
raise RenderError(f"Grace period [{grace_period}] cannot be negative")
|
||||
self._grace_period = grace_period
|
||||
|
||||
def set_privileged(self, enabled: bool = False):
|
||||
self._privileged = enabled
|
||||
|
||||
def clear_caps(self):
|
||||
self._cap_add.clear()
|
||||
self._cap_drop.clear()
|
||||
|
||||
def add_caps(self, caps: list[str]):
|
||||
for c in caps:
|
||||
if c in self._cap_add:
|
||||
raise RenderError(f"Capability [{c}] already added")
|
||||
self._cap_add.add(valid_cap_or_raise(c))
|
||||
|
||||
def add_security_opt(self, key: str, value: str | bool | None = None, arg: str | None = None):
|
||||
self._security_opt.add_opt(key, value, arg)
|
||||
|
||||
def remove_security_opt(self, key: str):
|
||||
self._security_opt.remove_opt(key)
|
||||
|
||||
def set_network_mode(self, mode: str):
|
||||
self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names())
|
||||
|
||||
def add_port(self, port_config: dict | None = None, dev_config: dict | None = None):
|
||||
port_config = port_config or {}
|
||||
dev_config = dev_config or {}
|
||||
# Merge port_config and dev_config (dev_config has precedence)
|
||||
config = port_config | dev_config
|
||||
bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", ""))
|
||||
# Skip port if its neither published nor exposed
|
||||
if not bind_mode:
|
||||
return
|
||||
|
||||
# Collect port config
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ips = config.get("host_ips") or ["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":
|
||||
for host_ip in host_ips:
|
||||
self.ports._add_port(
|
||||
host_port, container_port, {"protocol": protocol, "host_ip": host_ip, "mode": mode}
|
||||
)
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
def set_entrypoint(self, entrypoint: list[str]):
|
||||
self._entrypoint = [escape_dollar(str(e)) for e in entrypoint]
|
||||
|
||||
def set_command(self, command: list[str]):
|
||||
self._command = [escape_dollar(str(e)) for e in command]
|
||||
|
||||
def add_storage(self, mount_path: str, config: "IxStorage"):
|
||||
if config.get("type", "") == "tmpfs":
|
||||
self._tmpfs.add(mount_path, config)
|
||||
else:
|
||||
self._storage.add(mount_path, config)
|
||||
|
||||
def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"):
|
||||
self.add_group(999)
|
||||
self._storage._add_docker_socket(read_only, mount_path)
|
||||
|
||||
def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"):
|
||||
self._storage._add_udev(read_only, mount_path)
|
||||
|
||||
def add_tun_device(self):
|
||||
self.devices._add_tun_device()
|
||||
|
||||
def add_snd_device(self):
|
||||
self.add_group(29)
|
||||
self.devices._add_snd_device()
|
||||
|
||||
def set_shm_size_mb(self, size: int):
|
||||
self._shm_size = size
|
||||
|
||||
# Easily remove devices from the container
|
||||
# Useful in dependencies like postgres and redis
|
||||
# where there is no need to pass devices to them
|
||||
def remove_devices(self):
|
||||
self.deploy.resources.remove_devices()
|
||||
self.devices.remove_devices()
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
return self._storage
|
||||
|
||||
def render(self) -> dict[str, Any]:
|
||||
if self._network_mode and self.networks:
|
||||
raise RenderError("Cannot set both [network_mode] and [networks]")
|
||||
|
||||
result = {
|
||||
"image": self._image,
|
||||
"platform": "linux/amd64",
|
||||
"tty": self._tty,
|
||||
"stdin_open": self._stdin_open,
|
||||
"restart": self.restart.render(),
|
||||
}
|
||||
|
||||
if self._pull_policy:
|
||||
result["pull_policy"] = self._pull_policy
|
||||
|
||||
if self.healthcheck.has_healthcheck():
|
||||
result["healthcheck"] = self.healthcheck.render()
|
||||
|
||||
if self._hostname:
|
||||
result["hostname"] = self._hostname
|
||||
|
||||
if self._build_image:
|
||||
result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image}
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
if self._ipc_mode is not None:
|
||||
result["ipc"] = self._ipc_mode
|
||||
|
||||
if self._device_cgroup_rules.has_rules():
|
||||
result["device_cgroup_rules"] = self._device_cgroup_rules.render()
|
||||
|
||||
if self._cgroup is not None:
|
||||
result["cgroup"] = self._cgroup
|
||||
|
||||
if self._extra_hosts.has_hosts():
|
||||
result["extra_hosts"] = self._extra_hosts.render()
|
||||
|
||||
if self._init is not None:
|
||||
result["init"] = self._init
|
||||
|
||||
if self._read_only is not None:
|
||||
result["read_only"] = self._read_only
|
||||
|
||||
if self._grace_period is not None:
|
||||
result["stop_grace_period"] = f"{self._grace_period}s"
|
||||
|
||||
if self._user:
|
||||
result["user"] = self._user
|
||||
|
||||
for g in self.get_additional_groups():
|
||||
self.add_group(g)
|
||||
|
||||
if self._group_add:
|
||||
result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g))
|
||||
|
||||
if self._shm_size is not None:
|
||||
result["shm_size"] = f"{self._shm_size}M"
|
||||
|
||||
if self._privileged is not None:
|
||||
result["privileged"] = self._privileged
|
||||
|
||||
if self._cap_drop:
|
||||
result["cap_drop"] = sorted(self._cap_drop)
|
||||
|
||||
if self._cap_add:
|
||||
result["cap_add"] = sorted(self._cap_add)
|
||||
|
||||
if self._security_opt.has_opts():
|
||||
result["security_opt"] = self._security_opt.render()
|
||||
|
||||
if self._network_mode:
|
||||
result["network_mode"] = self._network_mode
|
||||
|
||||
if self.sysctls.has_sysctls():
|
||||
result["sysctls"] = self.sysctls.render()
|
||||
|
||||
if self._network_mode != "host":
|
||||
if self.ports.has_ports():
|
||||
result["ports"] = self.ports.render()
|
||||
|
||||
if self.expose.has_ports():
|
||||
result["expose"] = self.expose.render()
|
||||
|
||||
if self._entrypoint:
|
||||
result["entrypoint"] = self._entrypoint
|
||||
|
||||
if self._command:
|
||||
result["command"] = self._command
|
||||
|
||||
if self.devices.has_devices():
|
||||
result["devices"] = self.devices.render()
|
||||
|
||||
if self.deploy.has_deploy():
|
||||
result["deploy"] = self.deploy.render()
|
||||
|
||||
if self.environment.has_variables():
|
||||
result["environment"] = self.environment.render()
|
||||
|
||||
if self.labels.has_labels():
|
||||
result["labels"] = self.labels.render()
|
||||
|
||||
if self.dns.has_dns_nameservers():
|
||||
result["dns"] = self.dns.render_dns_nameservers()
|
||||
|
||||
if self.dns.has_dns_searches():
|
||||
result["dns_search"] = self.dns.render_dns_searches()
|
||||
|
||||
if self.dns.has_dns_opts():
|
||||
result["dns_opt"] = self.dns.render_dns_opts()
|
||||
|
||||
if self.depends.has_dependencies():
|
||||
result["depends_on"] = self.depends.render()
|
||||
|
||||
if self._storage.has_mounts():
|
||||
result["volumes"] = self._storage.render()
|
||||
|
||||
if self._tmpfs.has_tmpfs():
|
||||
result["tmpfs"] = self._tmpfs.render()
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import copy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
|
||||
|
||||
class Portals:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._portals: set[Portal] = set()
|
||||
|
||||
def add(self, port: dict, config: dict | None = None):
|
||||
config = copy.deepcopy((config or {}))
|
||||
port = copy.deepcopy((port or {}))
|
||||
# If its not published, portal does not make sense
|
||||
if port.get("bind_mode", "") != "published":
|
||||
return
|
||||
|
||||
name = config.get("name", "Web UI")
|
||||
|
||||
if name in [p._name for p in self._portals]:
|
||||
raise RenderError(f"Portal [{name}] already added")
|
||||
|
||||
host = config.get("host", None)
|
||||
host_ips = port.get("host_ips", [])
|
||||
if not isinstance(host_ips, list):
|
||||
raise RenderError("Expected [host_ips] to be a list of strings")
|
||||
|
||||
# Remove wildcard IPs
|
||||
if "::" in host_ips:
|
||||
host_ips.remove("::")
|
||||
if "0.0.0.0" in host_ips:
|
||||
host_ips.remove("0.0.0.0")
|
||||
|
||||
# If host is not set, use the first host_ip (if it exists)
|
||||
if not host and len(host_ips) >= 1:
|
||||
host = host_ips[0]
|
||||
|
||||
config["host"] = host
|
||||
if not config.get("port"):
|
||||
config["port"] = port.get("port_number", 0)
|
||||
|
||||
self._portals.add(Portal(name, config))
|
||||
|
||||
def render(self):
|
||||
return [p.render() for _, p in sorted([(p._name, p) for p in self._portals])]
|
||||
|
||||
|
||||
class Portal:
|
||||
def __init__(self, name: str, config: dict):
|
||||
self._name = name
|
||||
self._scheme = valid_portal_scheme_or_raise(config.get("scheme", "http"))
|
||||
self._host = config.get("host", "0.0.0.0") or "0.0.0.0"
|
||||
self._port = valid_port_or_raise(config.get("port", 0))
|
||||
self._path = valid_http_path_or_raise(config.get("path", "/"))
|
||||
|
||||
def render(self):
|
||||
return {
|
||||
"name": self._name,
|
||||
"scheme": self._scheme,
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"path": self._path,
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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"))
|
||||
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", ""))
|
||||
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
|
||||
# After all the local validations, lets validate the port with the TrueNAS API
|
||||
self._render_instance.client.validate_ip_port_combo(host_ip, host_port)
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,99 @@
|
||||
import copy
|
||||
|
||||
try:
|
||||
from .client import Client
|
||||
from .configs import Configs
|
||||
from .container import Container
|
||||
from .deps import Deps
|
||||
from .error import RenderError
|
||||
from .functions import Functions
|
||||
from .notes import Notes
|
||||
from .portals import Portals
|
||||
from .volumes import Volumes
|
||||
except ImportError:
|
||||
from client import Client
|
||||
from configs import Configs
|
||||
from container import Container
|
||||
from deps import Deps
|
||||
from error import RenderError
|
||||
from functions import Functions
|
||||
from notes import Notes
|
||||
from portals import Portals
|
||||
from volumes import Volumes
|
||||
|
||||
|
||||
class Render(object):
|
||||
def __init__(self, values):
|
||||
self._containers: dict[str, Container] = {}
|
||||
self.values = values
|
||||
self._add_images_internal_use()
|
||||
# Make a copy after we inject the images
|
||||
self._original_values: dict = copy.deepcopy(self.values)
|
||||
|
||||
self.deps: Deps = Deps(self)
|
||||
|
||||
self.client: Client = Client(render_instance=self)
|
||||
|
||||
self.configs = Configs(render_instance=self)
|
||||
self.funcs = Functions(render_instance=self).func_map()
|
||||
self.portals: Portals = Portals(render_instance=self)
|
||||
self.notes: Notes = Notes(render_instance=self)
|
||||
self.volumes = Volumes(render_instance=self)
|
||||
|
||||
def _add_images_internal_use(self):
|
||||
if not self.values.get("images"):
|
||||
self.values["images"] = {}
|
||||
|
||||
if "python_permissions_image" not in self.values["images"]:
|
||||
self.values["images"]["python_permissions_image"] = {"repository": "python", "tag": "3.13.0-slim-bookworm"}
|
||||
|
||||
if "postgres_upgrade_image" not in self.values["images"]:
|
||||
self.values["images"]["postgres_upgrade_image"] = {
|
||||
"repository": "ixsystems/postgres-upgrade",
|
||||
"tag": "1.0.1",
|
||||
}
|
||||
|
||||
def container_names(self):
|
||||
return list(self._containers.keys())
|
||||
|
||||
def add_container(self, name: str, image: str):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise RenderError("Container name cannot be empty")
|
||||
container = Container(self, name, image)
|
||||
if name in self._containers:
|
||||
raise RenderError(f"Container {name} already exists.")
|
||||
self._containers[name] = container
|
||||
return container
|
||||
|
||||
def render(self):
|
||||
if self.values != self._original_values:
|
||||
raise RenderError("Values have been modified since the renderer was created.")
|
||||
|
||||
if not self._containers:
|
||||
raise RenderError("No containers added.")
|
||||
|
||||
result: dict = {
|
||||
"x-notes": self.notes.render(),
|
||||
"x-portals": self.portals.render(),
|
||||
"services": {c._name: c.render() for c in self._containers.values()},
|
||||
}
|
||||
|
||||
# Make sure that after services are rendered
|
||||
# there are no labels that target a non-existent container
|
||||
# This is to prevent typos
|
||||
for label in self.values.get("labels", []):
|
||||
for c in label.get("containers", []):
|
||||
if c not in self.container_names():
|
||||
raise RenderError(f"Label [{label['key']}] references container [{c}] which does not exist")
|
||||
|
||||
if self.volumes.has_volumes():
|
||||
result["volumes"] = self.volumes.render()
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
# if self.networks:
|
||||
# result["networks"] = {...}
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,93 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_no_portals(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
output = render.render()
|
||||
assert output["x-portals"] == []
|
||||
|
||||
|
||||
def test_add_portal_with_host_ips(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
port1 = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
port2 = {"bind_mode": "published", "port_number": 8081, "host_ips": ["::", "0.0.0.0"]}
|
||||
port3 = {"bind_mode": "published", "port_number": 8081, "host_ips": ["1.2.3.4"]}
|
||||
port3 = {"bind_mode": "published", "port_number": 8081, "host_ips": ["1.2.3.4"]}
|
||||
port4 = {"bind_mode": "exposed", "port_number": 1234, "host_ips": ["1.2.3.4"]}
|
||||
render.portals.add(port1)
|
||||
render.portals.add(port1, {"name": "test1", "host": "my-host.com"})
|
||||
render.portals.add(port2, {"name": "test2"})
|
||||
render.portals.add(port3, {"name": "test3", "port": None})
|
||||
render.portals.add(port3, {"name": "test4", "port": 1234})
|
||||
render.portals.add(port4, {"name": "test5", "port": 1234})
|
||||
output = render.render()
|
||||
assert output["x-portals"] == [
|
||||
{"name": "Web UI", "scheme": "http", "host": "1.2.3.4", "port": 8080, "path": "/"},
|
||||
{"name": "test1", "scheme": "http", "host": "my-host.com", "port": 8080, "path": "/"},
|
||||
{"name": "test2", "scheme": "http", "host": "0.0.0.0", "port": 8081, "path": "/"},
|
||||
{"name": "test3", "scheme": "http", "host": "1.2.3.4", "port": 8081, "path": "/"},
|
||||
{"name": "test4", "scheme": "http", "host": "1.2.3.4", "port": 1234, "path": "/"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_portal(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port)
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port)
|
||||
|
||||
|
||||
def test_add_duplicate_portal_with_explicit_name(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_scheme(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"scheme": "invalid_scheme"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "invalid_path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path_double_slash(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "/some//path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"bind_mode": "published", "port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"port": -1})
|
||||
@@ -24,8 +24,8 @@ icon: https://media.sys.truenas.net/apps/adguard-home/icons/icon.svg
|
||||
keywords:
|
||||
- dns
|
||||
- adblock
|
||||
lib_version: 2.1.33
|
||||
lib_version_hash: e1e627d9dcfb3c3810f1d1ebaa7978729a5ecad54ddcffe112ac1f3e9b1acba5
|
||||
lib_version: 2.1.35
|
||||
lib_version_hash: 1bd4e0058fbd4d7c207df2cae606580065e8e6dba3e232f41bc1b006848b05d2
|
||||
maintainers:
|
||||
- email: dev@ixsystems.com
|
||||
name: truenas
|
||||
@@ -45,4 +45,4 @@ sources:
|
||||
- https://hub.docker.com/r/adguard/adguardhome
|
||||
title: AdGuard Home
|
||||
train: community
|
||||
version: 1.2.2
|
||||
version: 1.2.3
|
||||
|
||||
@@ -94,9 +94,9 @@ questions:
|
||||
schema:
|
||||
type: int
|
||||
default: 30004
|
||||
min: 1
|
||||
max: 65535
|
||||
required: true
|
||||
$ref:
|
||||
- definitions/port
|
||||
- variable: host_ips
|
||||
label: Host IPs
|
||||
description: IPs on the host to bind this port
|
||||
@@ -142,9 +142,9 @@ questions:
|
||||
type: int
|
||||
show_if: [["bind_mode", "=", "published"]]
|
||||
default: 53
|
||||
min: 1
|
||||
max: 65535
|
||||
required: true
|
||||
$ref:
|
||||
- definitions/port
|
||||
- variable: host_ips
|
||||
label: Host IPs
|
||||
description: IPs on the host to bind this port
|
||||
@@ -206,9 +206,9 @@ questions:
|
||||
label: Port Number
|
||||
schema:
|
||||
type: int
|
||||
min: 1
|
||||
max: 65535
|
||||
required: true
|
||||
$ref:
|
||||
- definitions/port
|
||||
- variable: container_port
|
||||
label: Container Port
|
||||
schema:
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
from storage import IxStorage
|
||||
|
||||
try:
|
||||
from .configs import ContainerConfigs
|
||||
from .depends import Depends
|
||||
from .deploy import Deploy
|
||||
from .device_cgroup_rules import DeviceCGroupRules
|
||||
from .devices import Devices
|
||||
from .dns import Dns
|
||||
from .environment import Environment
|
||||
from .error import RenderError
|
||||
from .expose import Expose
|
||||
from .extra_hosts import ExtraHosts
|
||||
from .formatter import escape_dollar, get_image_with_hashed_data
|
||||
from .healthcheck import Healthcheck
|
||||
from .labels import Labels
|
||||
from .ports import Ports
|
||||
from .restart import RestartPolicy
|
||||
from .tmpfs import Tmpfs
|
||||
from .validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from .security_opts import SecurityOpts
|
||||
from .storage import Storage
|
||||
from .sysctls import Sysctls
|
||||
except ImportError:
|
||||
from configs import ContainerConfigs
|
||||
from depends import Depends
|
||||
from deploy import Deploy
|
||||
from device_cgroup_rules import DeviceCGroupRules
|
||||
from devices import Devices
|
||||
from dns import Dns
|
||||
from environment import Environment
|
||||
from error import RenderError
|
||||
from expose import Expose
|
||||
from extra_hosts import ExtraHosts
|
||||
from formatter import escape_dollar, get_image_with_hashed_data
|
||||
from healthcheck import Healthcheck
|
||||
from labels import Labels
|
||||
from ports import Ports
|
||||
from restart import RestartPolicy
|
||||
from tmpfs import Tmpfs
|
||||
from validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from security_opts import SecurityOpts
|
||||
from storage import Storage
|
||||
from sysctls import Sysctls
|
||||
|
||||
|
||||
class Container:
|
||||
def __init__(self, render_instance: "Render", name: str, image: str):
|
||||
self._render_instance = render_instance
|
||||
|
||||
self._name: str = name
|
||||
self._image: str = self._resolve_image(image)
|
||||
self._build_image: str = ""
|
||||
self._pull_policy: str = ""
|
||||
self._user: str = ""
|
||||
self._tty: bool = False
|
||||
self._stdin_open: bool = False
|
||||
self._init: bool | None = None
|
||||
self._read_only: bool | None = None
|
||||
self._extra_hosts: ExtraHosts = ExtraHosts(self._render_instance)
|
||||
self._hostname: str = ""
|
||||
self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly
|
||||
self._cap_add: set[str] = set()
|
||||
self._security_opt: SecurityOpts = SecurityOpts(self._render_instance)
|
||||
self._privileged: bool = False
|
||||
self._group_add: set[int | str] = set()
|
||||
self._network_mode: str = ""
|
||||
self._entrypoint: list[str] = []
|
||||
self._command: list[str] = []
|
||||
self._grace_period: int | None = None
|
||||
self._shm_size: int | None = None
|
||||
self._storage: Storage = Storage(self._render_instance, self)
|
||||
self._tmpfs: Tmpfs = Tmpfs(self._render_instance, self)
|
||||
self._ipc_mode: str | None = None
|
||||
self._cgroup: str | None = None
|
||||
self._device_cgroup_rules: DeviceCGroupRules = DeviceCGroupRules(self._render_instance)
|
||||
self.sysctls: Sysctls = Sysctls(self._render_instance, self)
|
||||
self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs)
|
||||
self.deploy: Deploy = Deploy(self._render_instance)
|
||||
self.networks: set[str] = set()
|
||||
self.devices: Devices = Devices(self._render_instance)
|
||||
self.environment: Environment = Environment(self._render_instance, self.deploy.resources)
|
||||
self.dns: Dns = Dns(self._render_instance)
|
||||
self.depends: Depends = Depends(self._render_instance)
|
||||
self.healthcheck: Healthcheck = Healthcheck(self._render_instance)
|
||||
self.labels: Labels = Labels(self._render_instance)
|
||||
self.restart: RestartPolicy = RestartPolicy(self._render_instance)
|
||||
self.ports: Ports = Ports(self._render_instance)
|
||||
self.expose: Expose = Expose(self._render_instance)
|
||||
|
||||
self._auto_set_network_mode()
|
||||
self._auto_add_labels()
|
||||
self._auto_add_groups()
|
||||
|
||||
def _auto_add_groups(self):
|
||||
self.add_group(568)
|
||||
|
||||
def _auto_set_network_mode(self):
|
||||
if self._render_instance.values.get("network", {}).get("host_network", False):
|
||||
self.set_network_mode("host")
|
||||
|
||||
def _auto_add_labels(self):
|
||||
labels = self._render_instance.values.get("labels", [])
|
||||
if not labels:
|
||||
return
|
||||
|
||||
for label in labels:
|
||||
containers = label.get("containers", [])
|
||||
if not containers:
|
||||
raise RenderError(f'Label [{label.get("key", "")}] must have at least one container')
|
||||
|
||||
if self._name in containers:
|
||||
self.labels.add_label(label["key"], label["value"])
|
||||
|
||||
def _resolve_image(self, image: str):
|
||||
images = self._render_instance.values["images"]
|
||||
if image not in images:
|
||||
raise RenderError(
|
||||
f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]"
|
||||
)
|
||||
repo = images[image].get("repository", "")
|
||||
tag = images[image].get("tag", "")
|
||||
|
||||
if not repo:
|
||||
raise RenderError(f"Repository not found for image [{image}]")
|
||||
if not tag:
|
||||
raise RenderError(f"Tag not found for image [{image}]")
|
||||
|
||||
return f"{repo}:{tag}"
|
||||
|
||||
def build_image(self, content: list[str | None]):
|
||||
dockerfile = f"FROM {self._image}\n"
|
||||
for line in content:
|
||||
line = line.strip() if line else ""
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("FROM"):
|
||||
# TODO: This will also block multi-stage builds
|
||||
# We can revisit this later if we need it
|
||||
raise RenderError(
|
||||
"FROM cannot be used in build image. Define the base image when creating the container."
|
||||
)
|
||||
dockerfile += line + "\n"
|
||||
|
||||
self._build_image = dockerfile
|
||||
self._image = get_image_with_hashed_data(self._image, dockerfile)
|
||||
|
||||
def set_pull_policy(self, pull_policy: str):
|
||||
self._pull_policy = valid_pull_policy_or_raise(pull_policy)
|
||||
|
||||
def set_user(self, user: int, group: int):
|
||||
for i in (user, group):
|
||||
if not isinstance(i, int) or i < 0:
|
||||
raise RenderError(f"User/Group [{i}] is not valid")
|
||||
self._user = f"{user}:{group}"
|
||||
|
||||
def add_extra_host(self, host: str, ip: str):
|
||||
self._extra_hosts.add_host(host, ip)
|
||||
|
||||
def add_group(self, group: int | str):
|
||||
if isinstance(group, str):
|
||||
group = str(group).strip()
|
||||
if group.isdigit():
|
||||
raise RenderError(f"Group is a number [{group}] but passed as a string")
|
||||
|
||||
if group in self._group_add:
|
||||
raise RenderError(f"Group [{group}] already added")
|
||||
self._group_add.add(group)
|
||||
|
||||
def get_additional_groups(self) -> list[int | str]:
|
||||
result = []
|
||||
if self.deploy.resources.has_gpus() or self.devices.has_gpus():
|
||||
result.append(44) # video
|
||||
result.append(107) # render
|
||||
return result
|
||||
|
||||
def get_current_groups(self) -> list[str]:
|
||||
result = [str(g) for g in self._group_add]
|
||||
result.extend([str(g) for g in self.get_additional_groups()])
|
||||
return result
|
||||
|
||||
def set_tty(self, enabled: bool = False):
|
||||
self._tty = enabled
|
||||
|
||||
def set_stdin(self, enabled: bool = False):
|
||||
self._stdin_open = enabled
|
||||
|
||||
def set_ipc_mode(self, ipc_mode: str):
|
||||
self._ipc_mode = valid_ipc_mode_or_raise(ipc_mode, self._render_instance.container_names())
|
||||
|
||||
def add_device_cgroup_rule(self, dev_grp_rule: str):
|
||||
self._device_cgroup_rules.add_rule(dev_grp_rule)
|
||||
|
||||
def set_cgroup(self, cgroup: str):
|
||||
self._cgroup = valid_cgroup_or_raise(cgroup)
|
||||
|
||||
def set_init(self, enabled: bool = False):
|
||||
self._init = enabled
|
||||
|
||||
def set_read_only(self, enabled: bool = False):
|
||||
self._read_only = enabled
|
||||
|
||||
def set_hostname(self, hostname: str):
|
||||
self._hostname = hostname
|
||||
|
||||
def set_grace_period(self, grace_period: int):
|
||||
if grace_period < 0:
|
||||
raise RenderError(f"Grace period [{grace_period}] cannot be negative")
|
||||
self._grace_period = grace_period
|
||||
|
||||
def set_privileged(self, enabled: bool = False):
|
||||
self._privileged = enabled
|
||||
|
||||
def clear_caps(self):
|
||||
self._cap_add.clear()
|
||||
self._cap_drop.clear()
|
||||
|
||||
def add_caps(self, caps: list[str]):
|
||||
for c in caps:
|
||||
if c in self._cap_add:
|
||||
raise RenderError(f"Capability [{c}] already added")
|
||||
self._cap_add.add(valid_cap_or_raise(c))
|
||||
|
||||
def add_security_opt(self, key: str, value: str | bool | None = None, arg: str | None = None):
|
||||
self._security_opt.add_opt(key, value, arg)
|
||||
|
||||
def remove_security_opt(self, key: str):
|
||||
self._security_opt.remove_opt(key)
|
||||
|
||||
def set_network_mode(self, mode: str):
|
||||
self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names())
|
||||
|
||||
def add_port(self, port_config: dict | None = None, dev_config: dict | None = None):
|
||||
port_config = port_config or {}
|
||||
dev_config = dev_config or {}
|
||||
# Merge port_config and dev_config (dev_config has precedence)
|
||||
config = port_config | dev_config
|
||||
|
||||
bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", ""))
|
||||
# Skip port if its neither published nor exposed
|
||||
if not bind_mode:
|
||||
return
|
||||
|
||||
# Collect port config
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ips = config.get("host_ips") or ["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":
|
||||
for host_ip in host_ips:
|
||||
self.ports._add_port(
|
||||
host_port, container_port, {"protocol": protocol, "host_ip": host_ip, "mode": mode}
|
||||
)
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
def set_entrypoint(self, entrypoint: list[str]):
|
||||
self._entrypoint = [escape_dollar(str(e)) for e in entrypoint]
|
||||
|
||||
def set_command(self, command: list[str]):
|
||||
self._command = [escape_dollar(str(e)) for e in command]
|
||||
|
||||
def add_storage(self, mount_path: str, config: "IxStorage"):
|
||||
if config.get("type", "") == "tmpfs":
|
||||
self._tmpfs.add(mount_path, config)
|
||||
else:
|
||||
self._storage.add(mount_path, config)
|
||||
|
||||
def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"):
|
||||
self.add_group(999)
|
||||
self._storage._add_docker_socket(read_only, mount_path)
|
||||
|
||||
def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"):
|
||||
self._storage._add_udev(read_only, mount_path)
|
||||
|
||||
def add_tun_device(self):
|
||||
self.devices._add_tun_device()
|
||||
|
||||
def add_snd_device(self):
|
||||
self.add_group(29)
|
||||
self.devices._add_snd_device()
|
||||
|
||||
def set_shm_size_mb(self, size: int):
|
||||
self._shm_size = size
|
||||
|
||||
# Easily remove devices from the container
|
||||
# Useful in dependencies like postgres and redis
|
||||
# where there is no need to pass devices to them
|
||||
def remove_devices(self):
|
||||
self.deploy.resources.remove_devices()
|
||||
self.devices.remove_devices()
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
return self._storage
|
||||
|
||||
def render(self) -> dict[str, Any]:
|
||||
if self._network_mode and self.networks:
|
||||
raise RenderError("Cannot set both [network_mode] and [networks]")
|
||||
|
||||
result = {
|
||||
"image": self._image,
|
||||
"platform": "linux/amd64",
|
||||
"tty": self._tty,
|
||||
"stdin_open": self._stdin_open,
|
||||
"restart": self.restart.render(),
|
||||
}
|
||||
|
||||
if self._pull_policy:
|
||||
result["pull_policy"] = self._pull_policy
|
||||
|
||||
if self.healthcheck.has_healthcheck():
|
||||
result["healthcheck"] = self.healthcheck.render()
|
||||
|
||||
if self._hostname:
|
||||
result["hostname"] = self._hostname
|
||||
|
||||
if self._build_image:
|
||||
result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image}
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
if self._ipc_mode is not None:
|
||||
result["ipc"] = self._ipc_mode
|
||||
|
||||
if self._device_cgroup_rules.has_rules():
|
||||
result["device_cgroup_rules"] = self._device_cgroup_rules.render()
|
||||
|
||||
if self._cgroup is not None:
|
||||
result["cgroup"] = self._cgroup
|
||||
|
||||
if self._extra_hosts.has_hosts():
|
||||
result["extra_hosts"] = self._extra_hosts.render()
|
||||
|
||||
if self._init is not None:
|
||||
result["init"] = self._init
|
||||
|
||||
if self._read_only is not None:
|
||||
result["read_only"] = self._read_only
|
||||
|
||||
if self._grace_period is not None:
|
||||
result["stop_grace_period"] = f"{self._grace_period}s"
|
||||
|
||||
if self._user:
|
||||
result["user"] = self._user
|
||||
|
||||
for g in self.get_additional_groups():
|
||||
self.add_group(g)
|
||||
|
||||
if self._group_add:
|
||||
result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g))
|
||||
|
||||
if self._shm_size is not None:
|
||||
result["shm_size"] = f"{self._shm_size}M"
|
||||
|
||||
if self._privileged is not None:
|
||||
result["privileged"] = self._privileged
|
||||
|
||||
if self._cap_drop:
|
||||
result["cap_drop"] = sorted(self._cap_drop)
|
||||
|
||||
if self._cap_add:
|
||||
result["cap_add"] = sorted(self._cap_add)
|
||||
|
||||
if self._security_opt.has_opts():
|
||||
result["security_opt"] = self._security_opt.render()
|
||||
|
||||
if self._network_mode:
|
||||
result["network_mode"] = self._network_mode
|
||||
|
||||
if self.sysctls.has_sysctls():
|
||||
result["sysctls"] = self.sysctls.render()
|
||||
|
||||
if self._network_mode != "host":
|
||||
if self.ports.has_ports():
|
||||
result["ports"] = self.ports.render()
|
||||
|
||||
if self.expose.has_ports():
|
||||
result["expose"] = self.expose.render()
|
||||
|
||||
if self._entrypoint:
|
||||
result["entrypoint"] = self._entrypoint
|
||||
|
||||
if self._command:
|
||||
result["command"] = self._command
|
||||
|
||||
if self.devices.has_devices():
|
||||
result["devices"] = self.devices.render()
|
||||
|
||||
if self.deploy.has_deploy():
|
||||
result["deploy"] = self.deploy.render()
|
||||
|
||||
if self.environment.has_variables():
|
||||
result["environment"] = self.environment.render()
|
||||
|
||||
if self.labels.has_labels():
|
||||
result["labels"] = self.labels.render()
|
||||
|
||||
if self.dns.has_dns_nameservers():
|
||||
result["dns"] = self.dns.render_dns_nameservers()
|
||||
|
||||
if self.dns.has_dns_searches():
|
||||
result["dns_search"] = self.dns.render_dns_searches()
|
||||
|
||||
if self.dns.has_dns_opts():
|
||||
result["dns_opt"] = self.dns.render_dns_opts()
|
||||
|
||||
if self.depends.has_dependencies():
|
||||
result["depends_on"] = self.depends.render()
|
||||
|
||||
if self._storage.has_mounts():
|
||||
result["volumes"] = self._storage.render()
|
||||
|
||||
if self._tmpfs.has_tmpfs():
|
||||
result["tmpfs"] = self._tmpfs.render()
|
||||
|
||||
return result
|
||||
@@ -1,68 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import copy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
from .validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
from validations import valid_portal_scheme_or_raise, valid_http_path_or_raise, valid_port_or_raise
|
||||
|
||||
|
||||
class Portals:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self._render_instance = render_instance
|
||||
self._portals: set[Portal] = set()
|
||||
|
||||
def add(self, port: dict, config: dict | None = None):
|
||||
config = copy.deepcopy((config or {}))
|
||||
port = copy.deepcopy((port or {}))
|
||||
name = config.get("name", "Web UI")
|
||||
host = config.get("host", None)
|
||||
|
||||
host_ips = port.get("host_ips", [])
|
||||
if not isinstance(host_ips, list):
|
||||
raise RenderError("Expected [host_ips] to be a list of strings")
|
||||
|
||||
# Remove wildcard IPs
|
||||
if "::" in host_ips:
|
||||
host_ips.remove("::")
|
||||
if "0.0.0.0" in host_ips:
|
||||
host_ips.remove("0.0.0.0")
|
||||
|
||||
# If host is not set, use the first host_ip (if it exists)
|
||||
if not host and len(host_ips) >= 1:
|
||||
host = host_ips[0]
|
||||
|
||||
config["host"] = host
|
||||
if config.get("port") is None:
|
||||
config["port"] = port.get("port_number", 0)
|
||||
|
||||
if name in [p._name for p in self._portals]:
|
||||
raise RenderError(f"Portal [{name}] already added")
|
||||
self._portals.add(Portal(name, config))
|
||||
|
||||
def render(self):
|
||||
return [p.render() for _, p in sorted([(p._name, p) for p in self._portals])]
|
||||
|
||||
|
||||
class Portal:
|
||||
def __init__(self, name: str, config: dict):
|
||||
self._name = name
|
||||
self._scheme = valid_portal_scheme_or_raise(config.get("scheme", "http"))
|
||||
self._host = config.get("host", "0.0.0.0") or "0.0.0.0"
|
||||
self._port = valid_port_or_raise(config.get("port", 0))
|
||||
self._path = valid_http_path_or_raise(config.get("path", "/"))
|
||||
|
||||
def render(self):
|
||||
return {
|
||||
"name": self._name,
|
||||
"scheme": self._scheme,
|
||||
"host": self._host,
|
||||
"port": self._port,
|
||||
"path": self._path,
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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"))
|
||||
|
||||
host_ip = valid_ip_or_raise(config.get("host_ip", ""))
|
||||
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)
|
||||
@@ -1,95 +0,0 @@
|
||||
import copy
|
||||
|
||||
try:
|
||||
from .configs import Configs
|
||||
from .container import Container
|
||||
from .deps import Deps
|
||||
from .error import RenderError
|
||||
from .functions import Functions
|
||||
from .notes import Notes
|
||||
from .portals import Portals
|
||||
from .volumes import Volumes
|
||||
except ImportError:
|
||||
from configs import Configs
|
||||
from container import Container
|
||||
from deps import Deps
|
||||
from error import RenderError
|
||||
from functions import Functions
|
||||
from notes import Notes
|
||||
from portals import Portals
|
||||
from volumes import Volumes
|
||||
|
||||
|
||||
class Render(object):
|
||||
def __init__(self, values):
|
||||
self._containers: dict[str, Container] = {}
|
||||
self.values = values
|
||||
self._add_images_internal_use()
|
||||
# Make a copy after we inject the images
|
||||
self._original_values: dict = copy.deepcopy(self.values)
|
||||
|
||||
self.deps: Deps = Deps(self)
|
||||
|
||||
self.configs = Configs(render_instance=self)
|
||||
self.funcs = Functions(render_instance=self).func_map()
|
||||
self.portals: Portals = Portals(render_instance=self)
|
||||
self.notes: Notes = Notes(render_instance=self)
|
||||
self.volumes = Volumes(render_instance=self)
|
||||
|
||||
def _add_images_internal_use(self):
|
||||
if not self.values.get("images"):
|
||||
self.values["images"] = {}
|
||||
|
||||
if "python_permissions_image" not in self.values["images"]:
|
||||
self.values["images"]["python_permissions_image"] = {"repository": "python", "tag": "3.13.0-slim-bookworm"}
|
||||
|
||||
if "postgres_upgrade_image" not in self.values["images"]:
|
||||
self.values["images"]["postgres_upgrade_image"] = {
|
||||
"repository": "ixsystems/postgres-upgrade",
|
||||
"tag": "1.0.1",
|
||||
}
|
||||
|
||||
def container_names(self):
|
||||
return list(self._containers.keys())
|
||||
|
||||
def add_container(self, name: str, image: str):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise RenderError("Container name cannot be empty")
|
||||
container = Container(self, name, image)
|
||||
if name in self._containers:
|
||||
raise RenderError(f"Container {name} already exists.")
|
||||
self._containers[name] = container
|
||||
return container
|
||||
|
||||
def render(self):
|
||||
if self.values != self._original_values:
|
||||
raise RenderError("Values have been modified since the renderer was created.")
|
||||
|
||||
if not self._containers:
|
||||
raise RenderError("No containers added.")
|
||||
|
||||
result: dict = {
|
||||
"x-notes": self.notes.render(),
|
||||
"x-portals": self.portals.render(),
|
||||
"services": {c._name: c.render() for c in self._containers.values()},
|
||||
}
|
||||
|
||||
# Make sure that after services are rendered
|
||||
# there are no labels that target a non-existent container
|
||||
# This is to prevent typos
|
||||
for label in self.values.get("labels", []):
|
||||
for c in label.get("containers", []):
|
||||
if c not in self.container_names():
|
||||
raise RenderError(f"Label [{label['key']}] references container [{c}] which does not exist")
|
||||
|
||||
if self.volumes.has_volumes():
|
||||
result["volumes"] = self.volumes.render()
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
# if self.networks:
|
||||
# result["networks"] = {...}
|
||||
|
||||
return result
|
||||
@@ -1,90 +0,0 @@
|
||||
import pytest
|
||||
|
||||
|
||||
from render import Render
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_values():
|
||||
return {
|
||||
"images": {
|
||||
"test_image": {
|
||||
"repository": "nginx",
|
||||
"tag": "latest",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_no_portals(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
output = render.render()
|
||||
assert output["x-portals"] == []
|
||||
|
||||
|
||||
def test_add_portal_with_host_ips(mock_values):
|
||||
render = Render(mock_values)
|
||||
c1 = render.add_container("test_container", "test_image")
|
||||
c1.healthcheck.disable()
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
port2 = {"port_number": 8081, "host_ips": ["::", "0.0.0.0"]}
|
||||
port3 = {"port_number": 8081, "host_ips": ["1.2.3.4"]}
|
||||
render.portals.add(port)
|
||||
render.portals.add(port, {"name": "test", "host": "my-host.com"})
|
||||
render.portals.add(port2, {"name": "test2"})
|
||||
render.portals.add(port3, {"name": "test3", "port": None})
|
||||
render.portals.add(port3, {"name": "test4", "port": 1234})
|
||||
output = render.render()
|
||||
assert output["x-portals"] == [
|
||||
{"name": "Web UI", "scheme": "http", "host": "1.2.3.4", "port": 8080, "path": "/"},
|
||||
{"name": "test", "scheme": "http", "host": "my-host.com", "port": 8080, "path": "/"},
|
||||
{"name": "test2", "scheme": "http", "host": "0.0.0.0", "port": 8081, "path": "/"},
|
||||
{"name": "test3", "scheme": "http", "host": "1.2.3.4", "port": 8081, "path": "/"},
|
||||
{"name": "test4", "scheme": "http", "host": "1.2.3.4", "port": 1234, "path": "/"},
|
||||
]
|
||||
|
||||
|
||||
def test_add_duplicate_portal(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port)
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port)
|
||||
|
||||
|
||||
def test_add_duplicate_portal_with_explicit_name(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"name": "Some Portal"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_scheme(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"scheme": "invalid_scheme"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "invalid_path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_path_double_slash(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"path": "/some//path"})
|
||||
|
||||
|
||||
def test_add_portal_with_invalid_port(mock_values):
|
||||
render = Render(mock_values)
|
||||
port = {"port_number": 8080, "host_ips": ["1.2.3.4", "5.6.7.8"]}
|
||||
with pytest.raises(Exception):
|
||||
render.portals.add(port, {"port": -1})
|
||||
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
|
||||
try:
|
||||
from .error import RenderError
|
||||
except ImportError:
|
||||
from error import RenderError
|
||||
|
||||
|
||||
def is_truenas_system():
|
||||
"""Check if we're running on a TrueNAS system"""
|
||||
return "truenas" in os.uname().release
|
||||
|
||||
|
||||
# Import based on system detection
|
||||
if is_truenas_system():
|
||||
from truenas_api_client import Client as TrueNASClient
|
||||
|
||||
try:
|
||||
# 25.04 and later
|
||||
from truenas_api_client.exc import ValidationErrors
|
||||
except ImportError:
|
||||
# 24.10 and earlier
|
||||
from truenas_api_client import ValidationErrors
|
||||
else:
|
||||
# Mock classes for non-TrueNAS systems
|
||||
class TrueNASClient:
|
||||
def call(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
class ValidationErrors(Exception):
|
||||
def __init__(self, errors):
|
||||
self.errors = errors
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, render_instance: "Render"):
|
||||
self.client = TrueNASClient()
|
||||
self._render_instance = render_instance
|
||||
self._app_name: str = self._render_instance.values.get("ix_context", {}).get("app_name", "") or "unknown"
|
||||
|
||||
def validate_ip_port_combo(self, ip: str, port: int) -> None:
|
||||
# Example of an error messages:
|
||||
# The port is being used by following services: 1) "0.0.0.0:80" used by WebUI Service
|
||||
# The port is being used by following services: 1) "0.0.0.0:9998" used by Applications ('$app_name' application)
|
||||
try:
|
||||
self.client.call("port.validate_port", f"render.{self._app_name}.schema", port, ip, None, True)
|
||||
except ValidationErrors as e:
|
||||
err_str = str(e)
|
||||
# If the IP:port combo appears more than once in the error message,
|
||||
# means that the port is used by more than one service/app.
|
||||
# This shouldn't happen in a well-configured system.
|
||||
# Notice that the ip portion is not included check,
|
||||
# because input might be a specific IP, but another service or app
|
||||
# might be using the same port on a wildcard IP
|
||||
if err_str.count(f':{port}" used by') > 1:
|
||||
raise RenderError(err_str) from None
|
||||
|
||||
# If the error complains about the current app, we ignore it
|
||||
# This is to handle cases where the app is being updated or edited
|
||||
if f"Applications ('{self._app_name}' application)" in err_str:
|
||||
# During upgrade, we want to ignore the error if it is related to the current app
|
||||
return
|
||||
|
||||
raise RenderError(err_str) from None
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,441 @@
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from render import Render
|
||||
from storage import IxStorage
|
||||
|
||||
try:
|
||||
from .configs import ContainerConfigs
|
||||
from .depends import Depends
|
||||
from .deploy import Deploy
|
||||
from .device_cgroup_rules import DeviceCGroupRules
|
||||
from .devices import Devices
|
||||
from .dns import Dns
|
||||
from .environment import Environment
|
||||
from .error import RenderError
|
||||
from .expose import Expose
|
||||
from .extra_hosts import ExtraHosts
|
||||
from .formatter import escape_dollar, get_image_with_hashed_data
|
||||
from .healthcheck import Healthcheck
|
||||
from .labels import Labels
|
||||
from .ports import Ports
|
||||
from .restart import RestartPolicy
|
||||
from .tmpfs import Tmpfs
|
||||
from .validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from .security_opts import SecurityOpts
|
||||
from .storage import Storage
|
||||
from .sysctls import Sysctls
|
||||
except ImportError:
|
||||
from configs import ContainerConfigs
|
||||
from depends import Depends
|
||||
from deploy import Deploy
|
||||
from device_cgroup_rules import DeviceCGroupRules
|
||||
from devices import Devices
|
||||
from dns import Dns
|
||||
from environment import Environment
|
||||
from error import RenderError
|
||||
from expose import Expose
|
||||
from extra_hosts import ExtraHosts
|
||||
from formatter import escape_dollar, get_image_with_hashed_data
|
||||
from healthcheck import Healthcheck
|
||||
from labels import Labels
|
||||
from ports import Ports
|
||||
from restart import RestartPolicy
|
||||
from tmpfs import Tmpfs
|
||||
from validations import (
|
||||
valid_cap_or_raise,
|
||||
valid_cgroup_or_raise,
|
||||
valid_ipc_mode_or_raise,
|
||||
valid_network_mode_or_raise,
|
||||
valid_port_bind_mode_or_raise,
|
||||
valid_port_mode_or_raise,
|
||||
valid_pull_policy_or_raise,
|
||||
)
|
||||
from security_opts import SecurityOpts
|
||||
from storage import Storage
|
||||
from sysctls import Sysctls
|
||||
|
||||
|
||||
class Container:
|
||||
def __init__(self, render_instance: "Render", name: str, image: str):
|
||||
self._render_instance = render_instance
|
||||
|
||||
self._name: str = name
|
||||
self._image: str = self._resolve_image(image)
|
||||
self._build_image: str = ""
|
||||
self._pull_policy: str = ""
|
||||
self._user: str = ""
|
||||
self._tty: bool = False
|
||||
self._stdin_open: bool = False
|
||||
self._init: bool | None = None
|
||||
self._read_only: bool | None = None
|
||||
self._extra_hosts: ExtraHosts = ExtraHosts(self._render_instance)
|
||||
self._hostname: str = ""
|
||||
self._cap_drop: set[str] = set(["ALL"]) # Drop all capabilities by default and add caps granularly
|
||||
self._cap_add: set[str] = set()
|
||||
self._security_opt: SecurityOpts = SecurityOpts(self._render_instance)
|
||||
self._privileged: bool = False
|
||||
self._group_add: set[int | str] = set()
|
||||
self._network_mode: str = ""
|
||||
self._entrypoint: list[str] = []
|
||||
self._command: list[str] = []
|
||||
self._grace_period: int | None = None
|
||||
self._shm_size: int | None = None
|
||||
self._storage: Storage = Storage(self._render_instance, self)
|
||||
self._tmpfs: Tmpfs = Tmpfs(self._render_instance, self)
|
||||
self._ipc_mode: str | None = None
|
||||
self._cgroup: str | None = None
|
||||
self._device_cgroup_rules: DeviceCGroupRules = DeviceCGroupRules(self._render_instance)
|
||||
self.sysctls: Sysctls = Sysctls(self._render_instance, self)
|
||||
self.configs: ContainerConfigs = ContainerConfigs(self._render_instance, self._render_instance.configs)
|
||||
self.deploy: Deploy = Deploy(self._render_instance)
|
||||
self.networks: set[str] = set()
|
||||
self.devices: Devices = Devices(self._render_instance)
|
||||
self.environment: Environment = Environment(self._render_instance, self.deploy.resources)
|
||||
self.dns: Dns = Dns(self._render_instance)
|
||||
self.depends: Depends = Depends(self._render_instance)
|
||||
self.healthcheck: Healthcheck = Healthcheck(self._render_instance)
|
||||
self.labels: Labels = Labels(self._render_instance)
|
||||
self.restart: RestartPolicy = RestartPolicy(self._render_instance)
|
||||
self.ports: Ports = Ports(self._render_instance)
|
||||
self.expose: Expose = Expose(self._render_instance)
|
||||
|
||||
self._auto_set_network_mode()
|
||||
self._auto_add_labels()
|
||||
self._auto_add_groups()
|
||||
|
||||
def _auto_add_groups(self):
|
||||
self.add_group(568)
|
||||
|
||||
def _auto_set_network_mode(self):
|
||||
if self._render_instance.values.get("network", {}).get("host_network", False):
|
||||
self.set_network_mode("host")
|
||||
|
||||
def _auto_add_labels(self):
|
||||
labels = self._render_instance.values.get("labels", [])
|
||||
if not labels:
|
||||
return
|
||||
|
||||
for label in labels:
|
||||
containers = label.get("containers", [])
|
||||
if not containers:
|
||||
raise RenderError(f'Label [{label.get("key", "")}] must have at least one container')
|
||||
|
||||
if self._name in containers:
|
||||
self.labels.add_label(label["key"], label["value"])
|
||||
|
||||
def _resolve_image(self, image: str):
|
||||
images = self._render_instance.values["images"]
|
||||
if image not in images:
|
||||
raise RenderError(
|
||||
f"Image [{image}] not found in values. " f"Available images: [{', '.join(images.keys())}]"
|
||||
)
|
||||
repo = images[image].get("repository", "")
|
||||
tag = images[image].get("tag", "")
|
||||
|
||||
if not repo:
|
||||
raise RenderError(f"Repository not found for image [{image}]")
|
||||
if not tag:
|
||||
raise RenderError(f"Tag not found for image [{image}]")
|
||||
|
||||
return f"{repo}:{tag}"
|
||||
|
||||
def build_image(self, content: list[str | None]):
|
||||
dockerfile = f"FROM {self._image}\n"
|
||||
for line in content:
|
||||
line = line.strip() if line else ""
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("FROM"):
|
||||
# TODO: This will also block multi-stage builds
|
||||
# We can revisit this later if we need it
|
||||
raise RenderError(
|
||||
"FROM cannot be used in build image. Define the base image when creating the container."
|
||||
)
|
||||
dockerfile += line + "\n"
|
||||
|
||||
self._build_image = dockerfile
|
||||
self._image = get_image_with_hashed_data(self._image, dockerfile)
|
||||
|
||||
def set_pull_policy(self, pull_policy: str):
|
||||
self._pull_policy = valid_pull_policy_or_raise(pull_policy)
|
||||
|
||||
def set_user(self, user: int, group: int):
|
||||
for i in (user, group):
|
||||
if not isinstance(i, int) or i < 0:
|
||||
raise RenderError(f"User/Group [{i}] is not valid")
|
||||
self._user = f"{user}:{group}"
|
||||
|
||||
def add_extra_host(self, host: str, ip: str):
|
||||
self._extra_hosts.add_host(host, ip)
|
||||
|
||||
def add_group(self, group: int | str):
|
||||
if isinstance(group, str):
|
||||
group = str(group).strip()
|
||||
if group.isdigit():
|
||||
raise RenderError(f"Group is a number [{group}] but passed as a string")
|
||||
|
||||
if group in self._group_add:
|
||||
raise RenderError(f"Group [{group}] already added")
|
||||
self._group_add.add(group)
|
||||
|
||||
def get_additional_groups(self) -> list[int | str]:
|
||||
result = []
|
||||
if self.deploy.resources.has_gpus() or self.devices.has_gpus():
|
||||
result.append(44) # video
|
||||
result.append(107) # render
|
||||
return result
|
||||
|
||||
def get_current_groups(self) -> list[str]:
|
||||
result = [str(g) for g in self._group_add]
|
||||
result.extend([str(g) for g in self.get_additional_groups()])
|
||||
return result
|
||||
|
||||
def set_tty(self, enabled: bool = False):
|
||||
self._tty = enabled
|
||||
|
||||
def set_stdin(self, enabled: bool = False):
|
||||
self._stdin_open = enabled
|
||||
|
||||
def set_ipc_mode(self, ipc_mode: str):
|
||||
self._ipc_mode = valid_ipc_mode_or_raise(ipc_mode, self._render_instance.container_names())
|
||||
|
||||
def add_device_cgroup_rule(self, dev_grp_rule: str):
|
||||
self._device_cgroup_rules.add_rule(dev_grp_rule)
|
||||
|
||||
def set_cgroup(self, cgroup: str):
|
||||
self._cgroup = valid_cgroup_or_raise(cgroup)
|
||||
|
||||
def set_init(self, enabled: bool = False):
|
||||
self._init = enabled
|
||||
|
||||
def set_read_only(self, enabled: bool = False):
|
||||
self._read_only = enabled
|
||||
|
||||
def set_hostname(self, hostname: str):
|
||||
self._hostname = hostname
|
||||
|
||||
def set_grace_period(self, grace_period: int):
|
||||
if grace_period < 0:
|
||||
raise RenderError(f"Grace period [{grace_period}] cannot be negative")
|
||||
self._grace_period = grace_period
|
||||
|
||||
def set_privileged(self, enabled: bool = False):
|
||||
self._privileged = enabled
|
||||
|
||||
def clear_caps(self):
|
||||
self._cap_add.clear()
|
||||
self._cap_drop.clear()
|
||||
|
||||
def add_caps(self, caps: list[str]):
|
||||
for c in caps:
|
||||
if c in self._cap_add:
|
||||
raise RenderError(f"Capability [{c}] already added")
|
||||
self._cap_add.add(valid_cap_or_raise(c))
|
||||
|
||||
def add_security_opt(self, key: str, value: str | bool | None = None, arg: str | None = None):
|
||||
self._security_opt.add_opt(key, value, arg)
|
||||
|
||||
def remove_security_opt(self, key: str):
|
||||
self._security_opt.remove_opt(key)
|
||||
|
||||
def set_network_mode(self, mode: str):
|
||||
self._network_mode = valid_network_mode_or_raise(mode, self._render_instance.container_names())
|
||||
|
||||
def add_port(self, port_config: dict | None = None, dev_config: dict | None = None):
|
||||
port_config = port_config or {}
|
||||
dev_config = dev_config or {}
|
||||
# Merge port_config and dev_config (dev_config has precedence)
|
||||
config = port_config | dev_config
|
||||
bind_mode = valid_port_bind_mode_or_raise(config.get("bind_mode", ""))
|
||||
# Skip port if its neither published nor exposed
|
||||
if not bind_mode:
|
||||
return
|
||||
|
||||
# Collect port config
|
||||
mode = valid_port_mode_or_raise(config.get("mode", "ingress"))
|
||||
host_port = config.get("port_number", 0)
|
||||
container_port = config.get("container_port", 0) or host_port
|
||||
protocol = config.get("protocol", "tcp")
|
||||
host_ips = config.get("host_ips") or ["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":
|
||||
for host_ip in host_ips:
|
||||
self.ports._add_port(
|
||||
host_port, container_port, {"protocol": protocol, "host_ip": host_ip, "mode": mode}
|
||||
)
|
||||
elif bind_mode == "exposed":
|
||||
self.expose.add_port(container_port, protocol)
|
||||
|
||||
def set_entrypoint(self, entrypoint: list[str]):
|
||||
self._entrypoint = [escape_dollar(str(e)) for e in entrypoint]
|
||||
|
||||
def set_command(self, command: list[str]):
|
||||
self._command = [escape_dollar(str(e)) for e in command]
|
||||
|
||||
def add_storage(self, mount_path: str, config: "IxStorage"):
|
||||
if config.get("type", "") == "tmpfs":
|
||||
self._tmpfs.add(mount_path, config)
|
||||
else:
|
||||
self._storage.add(mount_path, config)
|
||||
|
||||
def add_docker_socket(self, read_only: bool = True, mount_path: str = "/var/run/docker.sock"):
|
||||
self.add_group(999)
|
||||
self._storage._add_docker_socket(read_only, mount_path)
|
||||
|
||||
def add_udev(self, read_only: bool = True, mount_path: str = "/run/udev"):
|
||||
self._storage._add_udev(read_only, mount_path)
|
||||
|
||||
def add_tun_device(self):
|
||||
self.devices._add_tun_device()
|
||||
|
||||
def add_snd_device(self):
|
||||
self.add_group(29)
|
||||
self.devices._add_snd_device()
|
||||
|
||||
def set_shm_size_mb(self, size: int):
|
||||
self._shm_size = size
|
||||
|
||||
# Easily remove devices from the container
|
||||
# Useful in dependencies like postgres and redis
|
||||
# where there is no need to pass devices to them
|
||||
def remove_devices(self):
|
||||
self.deploy.resources.remove_devices()
|
||||
self.devices.remove_devices()
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
return self._storage
|
||||
|
||||
def render(self) -> dict[str, Any]:
|
||||
if self._network_mode and self.networks:
|
||||
raise RenderError("Cannot set both [network_mode] and [networks]")
|
||||
|
||||
result = {
|
||||
"image": self._image,
|
||||
"platform": "linux/amd64",
|
||||
"tty": self._tty,
|
||||
"stdin_open": self._stdin_open,
|
||||
"restart": self.restart.render(),
|
||||
}
|
||||
|
||||
if self._pull_policy:
|
||||
result["pull_policy"] = self._pull_policy
|
||||
|
||||
if self.healthcheck.has_healthcheck():
|
||||
result["healthcheck"] = self.healthcheck.render()
|
||||
|
||||
if self._hostname:
|
||||
result["hostname"] = self._hostname
|
||||
|
||||
if self._build_image:
|
||||
result["build"] = {"tags": [self._image], "dockerfile_inline": self._build_image}
|
||||
|
||||
if self.configs.has_configs():
|
||||
result["configs"] = self.configs.render()
|
||||
|
||||
if self._ipc_mode is not None:
|
||||
result["ipc"] = self._ipc_mode
|
||||
|
||||
if self._device_cgroup_rules.has_rules():
|
||||
result["device_cgroup_rules"] = self._device_cgroup_rules.render()
|
||||
|
||||
if self._cgroup is not None:
|
||||
result["cgroup"] = self._cgroup
|
||||
|
||||
if self._extra_hosts.has_hosts():
|
||||
result["extra_hosts"] = self._extra_hosts.render()
|
||||
|
||||
if self._init is not None:
|
||||
result["init"] = self._init
|
||||
|
||||
if self._read_only is not None:
|
||||
result["read_only"] = self._read_only
|
||||
|
||||
if self._grace_period is not None:
|
||||
result["stop_grace_period"] = f"{self._grace_period}s"
|
||||
|
||||
if self._user:
|
||||
result["user"] = self._user
|
||||
|
||||
for g in self.get_additional_groups():
|
||||
self.add_group(g)
|
||||
|
||||
if self._group_add:
|
||||
result["group_add"] = sorted(self._group_add, key=lambda g: (isinstance(g, str), g))
|
||||
|
||||
if self._shm_size is not None:
|
||||
result["shm_size"] = f"{self._shm_size}M"
|
||||
|
||||
if self._privileged is not None:
|
||||
result["privileged"] = self._privileged
|
||||
|
||||
if self._cap_drop:
|
||||
result["cap_drop"] = sorted(self._cap_drop)
|
||||
|
||||
if self._cap_add:
|
||||
result["cap_add"] = sorted(self._cap_add)
|
||||
|
||||
if self._security_opt.has_opts():
|
||||
result["security_opt"] = self._security_opt.render()
|
||||
|
||||
if self._network_mode:
|
||||
result["network_mode"] = self._network_mode
|
||||
|
||||
if self.sysctls.has_sysctls():
|
||||
result["sysctls"] = self.sysctls.render()
|
||||
|
||||
if self._network_mode != "host":
|
||||
if self.ports.has_ports():
|
||||
result["ports"] = self.ports.render()
|
||||
|
||||
if self.expose.has_ports():
|
||||
result["expose"] = self.expose.render()
|
||||
|
||||
if self._entrypoint:
|
||||
result["entrypoint"] = self._entrypoint
|
||||
|
||||
if self._command:
|
||||
result["command"] = self._command
|
||||
|
||||
if self.devices.has_devices():
|
||||
result["devices"] = self.devices.render()
|
||||
|
||||
if self.deploy.has_deploy():
|
||||
result["deploy"] = self.deploy.render()
|
||||
|
||||
if self.environment.has_variables():
|
||||
result["environment"] = self.environment.render()
|
||||
|
||||
if self.labels.has_labels():
|
||||
result["labels"] = self.labels.render()
|
||||
|
||||
if self.dns.has_dns_nameservers():
|
||||
result["dns"] = self.dns.render_dns_nameservers()
|
||||
|
||||
if self.dns.has_dns_searches():
|
||||
result["dns_search"] = self.dns.render_dns_searches()
|
||||
|
||||
if self.dns.has_dns_opts():
|
||||
result["dns_opt"] = self.dns.render_dns_opts()
|
||||
|
||||
if self.depends.has_dependencies():
|
||||
result["depends_on"] = self.depends.render()
|
||||
|
||||
if self._storage.has_mounts():
|
||||
result["volumes"] = self._storage.render()
|
||||
|
||||
if self._tmpfs.has_tmpfs():
|
||||
result["tmpfs"] = self._tmpfs.render()
|
||||
|
||||
return result
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user