Phase 3: env masking, image updates, port conflicts, resources, templates (0.3.0)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b553c1b861
commit
22d9864436
@@ -0,0 +1,158 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user