Backend:
- update_service: registry manifest digest check (Docker Hub/ghcr/lscr/private
v2 token auth) vs local RepoDigests; in-memory cache + background loop
- port_service: parse compose ports, check /proc/net/tcp[6] + docker bindings
- template_service + bundled templates (jellyfin/vaultwarden/uptime-kuma/
paperless-ngx/gitea) with {{VAR}} placeholders; custom templates in DB
- compose_edit set_resources (deploy.resources.limits/reservations)
- routers: images, ports, templates, editor/set-resources
- Template model; background update task wired into lifespan
Frontend:
- EnvEditor (table + raw, sensitive masking, quick-insert)
- Images page + UpdateBadge + dashboard 'updates available' banner
- PortConflictDialog pre-deploy check on Deploy
- ResourcePanel (CPU/RAM sliders) as editor Limits tab
- Templates page with per-variable instantiate form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
5.0 KiB
Python
159 lines
5.0 KiB
Python
"""Port conflict detection before deploying a stack."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
from config import settings
|
|
from docker_client import DockerError, get_client, safe_call
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Parse compose `ports:` entries
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def parse_compose_ports(yaml_str: str) -> list[dict]:
|
|
"""Return [{host_port:int, protocol:'tcp'|'udp', service:str}]."""
|
|
try:
|
|
data = yaml.safe_load(yaml_str) or {}
|
|
except yaml.YAMLError:
|
|
return []
|
|
services = data.get("services") or {}
|
|
out: list[dict] = []
|
|
for svc_name, svc in services.items():
|
|
if not isinstance(svc, dict):
|
|
continue
|
|
for entry in svc.get("ports", []) or []:
|
|
parsed = _parse_port_entry(entry)
|
|
if parsed:
|
|
parsed["service"] = svc_name
|
|
out.append(parsed)
|
|
return out
|
|
|
|
|
|
def _parse_port_entry(entry) -> Optional[dict]:
|
|
# Long form: {target, published, protocol}
|
|
if isinstance(entry, dict):
|
|
published = entry.get("published")
|
|
if published is None:
|
|
return None
|
|
try:
|
|
host_port = int(str(published).split("-")[0])
|
|
except ValueError:
|
|
return None
|
|
return {"host_port": host_port, "protocol": entry.get("protocol", "tcp")}
|
|
|
|
# Short form string: "[ip:]host:container[/proto]" or "container"
|
|
s = str(entry)
|
|
proto = "tcp"
|
|
if "/" in s:
|
|
s, proto = s.rsplit("/", 1)
|
|
parts = s.split(":")
|
|
# No host mapping (only container port) -> random host port, no conflict.
|
|
if len(parts) == 1:
|
|
return None
|
|
# host:container or ip:host:container
|
|
host = parts[-2]
|
|
try:
|
|
host_port = int(host.split("-")[0])
|
|
except ValueError:
|
|
return None
|
|
return {"host_port": host_port, "protocol": proto}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Host bound ports
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _read_proc_net(name: str) -> set[int]:
|
|
ports: set[int] = set()
|
|
path = os.path.join(settings.HOST_PROC_PATH, "net", name)
|
|
if not os.path.isfile(path):
|
|
path = os.path.join("/proc/net", name)
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
lines = fh.readlines()[1:]
|
|
except OSError:
|
|
return ports
|
|
for line in lines:
|
|
cols = line.split()
|
|
if len(cols) < 4:
|
|
continue
|
|
local = cols[1] # hexip:hexport
|
|
state = cols[3]
|
|
# TCP listen state is 0A; for UDP accept all.
|
|
if name.startswith("tcp") and state != "0A":
|
|
continue
|
|
try:
|
|
port = int(local.split(":")[1], 16)
|
|
ports.add(port)
|
|
except (IndexError, ValueError):
|
|
continue
|
|
return ports
|
|
|
|
|
|
def host_listening_ports() -> dict[str, set[int]]:
|
|
return {
|
|
"tcp": _read_proc_net("tcp") | _read_proc_net("tcp6"),
|
|
"udp": _read_proc_net("udp") | _read_proc_net("udp6"),
|
|
}
|
|
|
|
|
|
def docker_bound_ports() -> dict[tuple[int, str], str]:
|
|
"""Return {(host_port, proto): container_name}."""
|
|
out: dict[tuple[int, str], str] = {}
|
|
try:
|
|
client = get_client()
|
|
for c in safe_call(client.containers.list):
|
|
bindings = (c.attrs.get("NetworkSettings") or {}).get("Ports") or {}
|
|
for container_port, hosts in bindings.items():
|
|
if not hosts:
|
|
continue
|
|
proto = container_port.split("/")[-1] if "/" in container_port else "tcp"
|
|
for h in hosts:
|
|
hp = h.get("HostPort")
|
|
if hp:
|
|
out[(int(hp), proto)] = c.name
|
|
except (DockerError, ValueError):
|
|
pass
|
|
return out
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Detect conflicts
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def detect_conflicts(yaml_str: str, ignore_stack: Optional[str] = None) -> list[dict]:
|
|
wanted = parse_compose_ports(yaml_str)
|
|
host_ports = host_listening_ports()
|
|
docker_ports = docker_bound_ports()
|
|
|
|
conflicts: list[dict] = []
|
|
for w in wanted:
|
|
port = w["host_port"]
|
|
proto = w.get("protocol", "tcp")
|
|
used_by = None
|
|
owner = docker_ports.get((port, proto))
|
|
if owner:
|
|
# A container from the same stack (re-deploy) is not a conflict.
|
|
if ignore_stack and owner.startswith(f"{ignore_stack}-"):
|
|
continue
|
|
used_by = f"container {owner}"
|
|
elif port in host_ports.get(proto, set()):
|
|
used_by = "host process"
|
|
if used_by:
|
|
conflicts.append(
|
|
{
|
|
"port": port,
|
|
"protocol": proto,
|
|
"service": w.get("service"),
|
|
"used_by": used_by,
|
|
}
|
|
)
|
|
return conflicts
|