73e4fc005e
Server UI gains Update on stack cards, ComfyUI Models tab with workflow scan and downloads, and centralized comfyui_config. Model catalog and download scripts move from stacks/comfyui to server-ui so ComfyUI stays a minimal Docker wrapper for easier image updates.
479 lines
14 KiB
Python
479 lines
14 KiB
Python
"""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 find_compose_file(stack_dir: Path) -> Path | None:
|
|
for name in ("compose.yaml", "docker-compose.yml", "docker-compose.yaml"):
|
|
path = stack_dir / name
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def _run_compose(
|
|
stack: StackConfig,
|
|
*args: str,
|
|
timeout: int = 120,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
cmd = [
|
|
"docker",
|
|
"compose",
|
|
"--project-directory",
|
|
stack.compose_dir,
|
|
]
|
|
compose_file = find_compose_file(stack.path)
|
|
if compose_file is not None:
|
|
cmd.extend(["-f", str(compose_file)])
|
|
cmd.extend(
|
|
[
|
|
"--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 stack_update(stack: StackConfig) -> str:
|
|
state = get_container_state(stack)
|
|
was_running = state["running"]
|
|
|
|
pull = _run_compose(stack, "pull", timeout=600)
|
|
if pull.returncode != 0:
|
|
raise ComposeError(
|
|
pull.stderr.strip() or pull.stdout.strip() or "pull failed",
|
|
pull.returncode,
|
|
pull.stderr,
|
|
)
|
|
output = (pull.stdout + pull.stderr).strip()
|
|
|
|
if not was_running:
|
|
suffix = "\n[server-ui] Obraz pobrany. Stack zatrzymany — użyj Start."
|
|
return (output + suffix).strip() if output else suffix.strip()
|
|
|
|
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,
|
|
)
|
|
recreate_out = (proc.stdout + proc.stderr).strip()
|
|
if recreate_out:
|
|
output = f"{output}\n{recreate_out}".strip() if output else recreate_out
|
|
return output
|
|
|
|
|
|
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
|