"""Docker Compose helpers for whitelisted stacks.""" from __future__ import annotations import json import os import re import subprocess import tempfile from dataclasses import dataclass from pathlib import Path from typing import Any RESERVED_PORTS = frozenset({80, 443, 8090, 18090}) PORT_MIN = 1024 PORT_MAX = 65535 class ComposeError(RuntimeError): def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None: super().__init__(message) self.returncode = returncode self.stderr = stderr class PortConfigError(ValueError): pass @dataclass(frozen=True) class StackConfig: id: str name: str compose_dir: str profile: str container: str ui_port: int | None ui_scheme: str gpu: bool port_env: str | None = None port_default: int | None = None port_editable: bool = True @property def path(self) -> Path: return Path(self.compose_dir) @property def env_path(self) -> Path: return self.path / ".env" def read_env_value(env_path: Path, key: str) -> str | None: if not env_path.exists(): return None prefix = f"{key}=" for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue if line.startswith(prefix): val = line[len(prefix) :].strip() if val.startswith('"') and val.endswith('"') and len(val) >= 2: val = val[1:-1] elif val.startswith("'") and val.endswith("'") and len(val) >= 2: val = val[1:-1] return val return None def write_env_value(env_path: Path, key: str, value: str) -> None: env_path.parent.mkdir(parents=True, exist_ok=True) prefix = f"{key}=" new_line = f"{key}={value}" lines: list[str] = [] found = False if env_path.exists(): for raw in env_path.read_text(encoding="utf-8").splitlines(): stripped = raw.strip() if stripped and not stripped.startswith("#") and stripped.startswith(prefix): lines.append(new_line) found = True else: lines.append(raw) if not found: if lines and lines[-1].strip(): lines.append("") lines.append(new_line) fd, tmp_path = tempfile.mkstemp( dir=env_path.parent, prefix=".env.", suffix=".tmp", ) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write("\n".join(lines)) if lines: fh.write("\n") os.replace(tmp_path, env_path) except Exception: try: os.unlink(tmp_path) except OSError: pass raise def resolve_stack_port(stack: StackConfig) -> int | None: if stack.port_env: raw = read_env_value(stack.env_path, stack.port_env) if raw and raw.isdigit(): return int(raw) if stack.ui_port is not None: return stack.ui_port return stack.port_default def collect_used_ports( stacks: list[StackConfig], server_ui_port: int, exclude_stack_id: str | None = None, ) -> set[int]: used = set(RESERVED_PORTS) | {server_ui_port} for s in stacks: if s.id == exclude_stack_id: continue port = resolve_stack_port(s) if port is not None: used.add(port) return used def validate_port( port: int, stack: StackConfig, stacks: list[StackConfig], server_ui_port: int, ) -> None: if not PORT_MIN <= port <= PORT_MAX: raise PortConfigError(f"Port must be between {PORT_MIN} and {PORT_MAX}") if not stack.port_editable or not stack.port_env: raise PortConfigError(f"Port is not editable for stack: {stack.id}") conflicts = collect_used_ports(stacks, server_ui_port, exclude_stack_id=stack.id) if port in conflicts: raise PortConfigError(f"Port {port} is already in use or reserved") def get_published_host_port(stack: StackConfig) -> int | None: proc = subprocess.run( [ "docker", "port", stack.container, ], capture_output=True, text=True, timeout=15, check=False, ) if proc.returncode != 0 or not proc.stdout.strip(): return None for line in proc.stdout.strip().splitlines(): # 0.0.0.0:8070/tcp or [::]:8070/tcp m = re.search(r":(\d+)/", line) if m: return int(m.group(1)) return None def load_stacks(config_path: Path, stacks_base: Path) -> list[StackConfig]: import yaml # PyYAML optional — parse minimal yaml ourselves if missing text = config_path.read_text(encoding="utf-8") try: data = yaml.safe_load(text) except Exception: data = _parse_stacks_yaml_simple(text) items = data.get("stacks", []) if isinstance(data, dict) else [] stacks: list[StackConfig] = [] for item in items: compose_rel = item["compose_dir"] full = stacks_base / compose_rel stacks.append( StackConfig( id=item["id"], name=item["name"], compose_dir=str(full), profile=item["profile"], container=item.get("container", item["id"]), ui_port=item.get("ui_port"), ui_scheme=item.get("ui_scheme") or "http", gpu=bool(item.get("gpu", False)), port_env=item.get("port_env"), port_default=item.get("port_default"), port_editable=bool(item.get("port_editable", item.get("port_env") is not None)), ) ) return stacks def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]: """Minimal parser fallback when PyYAML is not installed.""" import re stacks: list[dict[str, Any]] = [] current: dict[str, Any] | None = None for line in text.splitlines(): if re.match(r"^\s*-\s+id:\s*", line): if current: stacks.append(current) current = {"id": line.split(":", 1)[1].strip()} elif current is not None and ":" in line: key, _, val = line.strip().partition(":") key = key.strip() val = val.strip() if val == "null": val = None elif val == "true": val = True elif val == "false": val = False elif val.isdigit(): val = int(val) current[key] = val if current: stacks.append(current) return {"stacks": stacks} def _run_compose( stack: StackConfig, *args: str, timeout: int = 120, ) -> subprocess.CompletedProcess[str]: cmd = [ "docker", "compose", "--project-directory", stack.compose_dir, "--profile", stack.profile, *args, ] return subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, check=False, ) def get_container_state(stack: StackConfig) -> dict[str, Any]: proc = _run_compose( stack, "ps", "--format", "json", timeout=30, ) running = False status = "stopped" health = None if proc.returncode == 0 and proc.stdout.strip(): for line in proc.stdout.strip().splitlines(): try: row = json.loads(line) except json.JSONDecodeError: continue name = row.get("Name", "") if stack.container in name or name == stack.container: state = row.get("State", "").lower() health = row.get("Health") if state == "running": running = True status = "running" elif state: status = state break else: ps = subprocess.run( [ "docker", "ps", "-a", "--filter", f"name=^{stack.container}$", "--format", "{{.State}}", ], capture_output=True, text=True, timeout=15, check=False, ) if ps.stdout.strip(): status = ps.stdout.strip().lower() running = status == "running" ui_port = resolve_stack_port(stack) published = get_published_host_port(stack) if running else None port_pending_restart = bool( running and ui_port is not None and published is not None and published != ui_port ) return { "id": stack.id, "name": stack.name, "profile": stack.profile, "container": stack.container, "ui_port": ui_port, "ui_scheme": stack.ui_scheme, "gpu": stack.gpu, "running": running, "status": status, "health": health, "compose_dir": stack.compose_dir, "port_env": stack.port_env, "port_editable": stack.port_editable and bool(stack.port_env), "port_default": stack.port_default, "published_port": published, "port_pending_restart": port_pending_restart, } def get_logs(stack: StackConfig, tail: int = 100) -> str: proc = _run_compose( stack, "logs", "--tail", str(tail), "--no-color", timeout=60, ) if proc.returncode != 0: raise ComposeError( proc.stderr.strip() or "Failed to fetch logs", proc.returncode, proc.stderr, ) return proc.stdout def stack_start(stack: StackConfig) -> str: proc = _run_compose(stack, "up", "-d", timeout=300) if proc.returncode != 0: raise ComposeError( proc.stderr.strip() or proc.stdout.strip() or "start failed", proc.returncode, proc.stderr, ) return (proc.stdout + proc.stderr).strip() def stack_stop(stack: StackConfig) -> str: proc = _run_compose(stack, "stop", timeout=120) if proc.returncode != 0: raise ComposeError( proc.stderr.strip() or "stop failed", proc.returncode, proc.stderr, ) return (proc.stdout + proc.stderr).strip() def stack_restart(stack: StackConfig) -> str: proc = _run_compose(stack, "restart", timeout=180) if proc.returncode != 0: raise ComposeError( proc.stderr.strip() or "restart failed", proc.returncode, proc.stderr, ) return (proc.stdout + proc.stderr).strip() def stack_recreate(stack: StackConfig) -> str: proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300) if proc.returncode != 0: raise ComposeError( proc.stderr.strip() or proc.stdout.strip() or "recreate failed", proc.returncode, proc.stderr, ) return (proc.stdout + proc.stderr).strip() def set_stack_port( stack: StackConfig, port: int, stacks: list[StackConfig], server_ui_port: int, *, recreate: bool = False, ) -> dict[str, Any]: validate_port(port, stack, stacks, server_ui_port) assert stack.port_env is not None write_env_value(stack.env_path, stack.port_env, str(port)) state = get_container_state(stack) recreated = False requires_restart = state["running"] if recreate and state["running"]: stack_recreate(stack) recreated = True requires_restart = False state = get_container_state(stack) return { "ok": True, "stack_id": stack.id, "port": port, "port_env": stack.port_env, "requires_restart": requires_restart and not recreated, "recreated": recreated, "running": state["running"], } def find_running_gpu_stacks(stacks: list[StackConfig], exclude_id: str | None = None) -> list[str]: running: list[str] = [] for s in stacks: if not s.gpu or s.id == exclude_id: continue state = get_container_state(s) if state["running"]: running.append(s.id) return running