Initial import: bare-metal stacks, Server UI, GPU fan, tutorials.
Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI, control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user