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:
menzelj
2026-06-07 16:49:38 +00:00
co-authored by Claude Opus 4.8
parent b553c1b861
commit 22d9864436
32 changed files with 1728 additions and 21 deletions
+158
View File
@@ -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