Operator call 2026-09-19: switch the iGPU endpoint from halogen-flash-server to jcbtc/Qwen3.8-Flash-CIRU-STRIX-Orca on the CIRU v4.4.1 runtime with the QSA window hotfix (host build against the private ROCm 10 SDK), one stream of the full 262144 trained window. The service leaves the quadlet shape for a root unit (the same shape as frank-small and frank-medium: a private-SDK host binary has no image to install and no generated unit to dodge); the halogen quadlet is archived, the deploy handler retires it on the host, gates on 7770, and its stray-run sweep matches the model path so it cannot take frank-small down.
470 lines
18 KiB
Python
Executable File
470 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Plan runtime services affected by repository changes.
|
|
|
|
The planner is intentionally side-effect free. It accepts either an explicit
|
|
path list or two Git revisions. The latter lets compose-file changes resolve
|
|
to only the service definitions that actually changed instead of restarting an
|
|
entire compose project.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
# Dependency-sensitive services come before their consumers. Independent
|
|
# compose services follow. LLM services are deliberately last and are never
|
|
# implied by a generic host, stack, documentation, or config change.
|
|
SERVICE_ORDER = (
|
|
"gluetun",
|
|
"searxng-valkey",
|
|
"searxng",
|
|
"marreta",
|
|
"pihole",
|
|
"open-webui",
|
|
"gitea-postgres",
|
|
"gitea-server",
|
|
"comfyui",
|
|
"seerr",
|
|
"hermes",
|
|
"hermes-dashboard",
|
|
"hermes-webui",
|
|
"prometheus",
|
|
"grafana",
|
|
"loki",
|
|
"alloy",
|
|
"node-exporter",
|
|
"alertmanager",
|
|
"authentik-postgres",
|
|
"authentik-server",
|
|
"authentik-worker",
|
|
"authentik-outpost",
|
|
"authentik-outpost-ldap",
|
|
"caddy",
|
|
"homepage",
|
|
"romm-mariadb",
|
|
"romm-redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
"frank-large",
|
|
"frank-small",
|
|
"frank-medium",
|
|
)
|
|
|
|
DANKCLAW_SERVICES = {
|
|
"hermes-webui",
|
|
"gluetun",
|
|
"searxng-valkey",
|
|
"searxng",
|
|
"marreta",
|
|
"pihole",
|
|
"open-webui",
|
|
"gitea-postgres",
|
|
"gitea-server",
|
|
"comfyui",
|
|
"seerr",
|
|
"hermes",
|
|
"hermes-dashboard",
|
|
"prometheus",
|
|
"grafana",
|
|
"loki",
|
|
"alloy",
|
|
"node-exporter",
|
|
"alertmanager",
|
|
"authentik-postgres",
|
|
"authentik-server",
|
|
"authentik-worker",
|
|
"authentik-outpost",
|
|
"authentik-outpost-ldap",
|
|
"caddy",
|
|
"homepage",
|
|
"frank-large",
|
|
"frank-small",
|
|
}
|
|
|
|
DANKSERVER_SERVICES = {
|
|
"romm-mariadb",
|
|
"romm-redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
"frank-medium",
|
|
"node-exporter",
|
|
}
|
|
|
|
# FrankBook-Pro runs the *arr stack + qBittorrent inside a podman machine on
|
|
# macOS. Service names overlap the Linux hosts (gluetun, qbittorrent, ...);
|
|
# the per-target sets and the frankbook-pro intercept in deploy-remote.sh
|
|
# keep dispatch unambiguous.
|
|
FRANKBOOK_SERVICES = {
|
|
"gluetun",
|
|
"qbittorrent",
|
|
"prowlarr",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
}
|
|
|
|
DANKCLAW_COMPOSE_SERVICES = set()
|
|
# Dankserver's compose file is still the source of truth for its media stack;
|
|
# Dankclaw's is gone (deleted 2026-09-16). Every service on Dankclaw is a
|
|
# Quadlet unit now — the last compose services were the hermes trio, whose units
|
|
# have owned their containers since 2026-09-12 and whose compose blocks were
|
|
# dead weight that could only create a duplicate container.
|
|
|
|
DANKSERVER_COMPOSE_SERVICES = {
|
|
"mariadb",
|
|
"redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
}
|
|
COMPOSE_NAME_MAP = {"mariadb": "romm-mariadb", "redis": "romm-redis"}
|
|
|
|
|
|
|
|
def _add(services: set[str], *names: str) -> None:
|
|
services.update(names)
|
|
|
|
|
|
def target_services(target: str) -> set[str]:
|
|
if target == "dankclaw":
|
|
return DANKCLAW_SERVICES
|
|
if target == "dankserver":
|
|
return DANKSERVER_SERVICES
|
|
if target == "frankbook-pro":
|
|
return FRANKBOOK_SERVICES
|
|
raise ValueError(f"unknown target: {target}")
|
|
|
|
|
|
def _compose_services(target: str) -> set[str]:
|
|
return DANKCLAW_COMPOSE_SERVICES if target == "dankclaw" else DANKSERVER_COMPOSE_SERVICES
|
|
|
|
|
|
def _git_show(repo: str, revision: str, path: str) -> str | None:
|
|
result = subprocess.run(
|
|
["git", "-C", repo, "show", f"{revision}:{path}"],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
return result.stdout if result.returncode == 0 else None
|
|
|
|
|
|
def _yaml_document(contents: str | None) -> dict[str, Any]:
|
|
if contents is None:
|
|
return {}
|
|
try:
|
|
data = yaml.safe_load(contents) or {}
|
|
except yaml.YAMLError:
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _yaml_services(contents: str | None) -> dict[str, Any]:
|
|
services = _yaml_document(contents).get("services", {})
|
|
return services if isinstance(services, dict) else {}
|
|
|
|
|
|
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
|
|
"""Return only compose services whose YAML definitions or dependencies changed.
|
|
|
|
A target with no compose file (Dankclaw since 2026-09-16) plans nothing:
|
|
its services are Quadlet units, selected by their own paths.
|
|
"""
|
|
path = f"{target}/compose.yaml"
|
|
if not _git_show(repo, head, path) and not _git_show(repo, base, path):
|
|
return set()
|
|
old_doc = _yaml_document(_git_show(repo, base, path))
|
|
new_doc = _yaml_document(_git_show(repo, head, path))
|
|
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
|
|
new = new_doc.get("services", {}) if isinstance(new_doc.get("services", {}), dict) else {}
|
|
names = set(old) | set(new)
|
|
allowed = _compose_services(target)
|
|
|
|
# An unavailable or malformed revision is safer as a broad compose plan
|
|
# than as a silent no-op. It still cannot include LLM units.
|
|
if (old_doc and not new_doc) or (new_doc and not old_doc and names):
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
|
|
|
|
# Top-level networks/configuration can affect every compose service even
|
|
# when no individual service mapping changed. Ignore the services key while
|
|
# comparing the rest of the Compose document.
|
|
old_globals = {key: value for key, value in old_doc.items() if key != "services"}
|
|
new_globals = {key: value for key, value in new_doc.items() if key != "services"}
|
|
if old_globals != new_globals:
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
|
|
|
|
changed = {name for name in names if name in allowed and old.get(name) != new.get(name)}
|
|
return {COMPOSE_NAME_MAP.get(n, n) for n in changed}
|
|
|
|
|
|
def services_for_path(path: str, target: str) -> set[str]:
|
|
"""Return services affected by one repository-relative path."""
|
|
path = path.replace("\\", "/")
|
|
p = PurePosixPath(path)
|
|
parts = p.parts
|
|
services: set[str] = set()
|
|
if not parts:
|
|
return services
|
|
|
|
# Non-runtime material is deliberately a no-op.
|
|
if parts[0] in {"docs", "tests", "old", ".githooks"}:
|
|
return services
|
|
if path in {"README.md", "AGENTS.md"} or parts[0] == ".obsidian":
|
|
return services
|
|
|
|
if parts[0] == "shared":
|
|
if len(parts) < 2:
|
|
return services
|
|
group = parts[1]
|
|
if group == "searxng":
|
|
_add(services, "searxng")
|
|
elif group == "prometheus":
|
|
_add(services, "prometheus")
|
|
if len(parts) >= 3 and parts[2] == "alertmanager.yml":
|
|
# The Alertmanager config ships from this directory: a change
|
|
# to it must redeploy the service that installs it.
|
|
_add(services, "alertmanager")
|
|
elif group == "loki":
|
|
_add(services, "loki")
|
|
elif group == "alloy":
|
|
_add(services, "alloy")
|
|
elif group == "grafana":
|
|
# Provisioned datasources and dashboards: git is the source of
|
|
# truth, so a change here must restart the service that reads them.
|
|
_add(services, "grafana")
|
|
elif group == "homepage":
|
|
# Homepage is a Dankclaw-owned deployment. Shared homepage files
|
|
# are intentionally not copied during a Dankserver-only run.
|
|
if target == "dankclaw":
|
|
_add(services, "homepage")
|
|
return services
|
|
|
|
if parts[0] == "dankclaw":
|
|
if len(parts) >= 2 and parts[1] == "compose.yaml":
|
|
# Dankclaw has no compose file since 2026-09-16: every service is a
|
|
# Quadlet unit, each selected by its own path. A change (or a
|
|
# deletion) of this path therefore restarts nothing.
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "Caddyfile":
|
|
_add(services, "caddy")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
|
|
_add(services, "comfyui")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
|
|
_add(services, parts[1])
|
|
return services
|
|
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
|
|
_add(services, name)
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "searxng":
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3]
|
|
if name == "gluetun.container":
|
|
# SearXNG and Marreta (+ its browser companion) share
|
|
# Gluetun's network namespace. A Gluetun recreation
|
|
# tears the netns down, so every sharer must restart.
|
|
_add(services, "gluetun", "searxng", "marreta")
|
|
elif name == "searxng.container":
|
|
_add(services, "searxng")
|
|
elif name == "searxng-valkey.container":
|
|
_add(services, "searxng-valkey", "searxng")
|
|
elif name == "searxng.network":
|
|
_add(services, "gluetun", "searxng-valkey", "searxng", "marreta")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "marreta":
|
|
# One deployable owns both the app and its browser companion;
|
|
# both live in gluetun's netns. A change to either unit selects
|
|
# the marreta service, which restarts browser-then-app.
|
|
_add(services, "marreta")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "pihole":
|
|
_add(services, "pihole")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "open-webui":
|
|
_add(services, "open-webui")
|
|
return services
|
|
# frank-large left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
|
# build, root unit): its files live in scripts/ and map there.
|
|
if len(parts) >= 2 and parts[1] == "observability":
|
|
# The five observability containers became Quadlet units on
|
|
# 2026-09-11. A unit change selects exactly its own service;
|
|
# SERVICE_ORDER already puts each dependency ahead of its
|
|
# consumer (prometheus before grafana, loki before alloy).
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name in {"prometheus", "grafana", "loki", "alloy", "node-exporter", "alertmanager"}:
|
|
_add(services, name)
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "gitea":
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3]
|
|
if name == "gitea-postgres.container":
|
|
_add(services, "gitea-postgres")
|
|
elif name == "gitea-server.container":
|
|
_add(services, "gitea-server")
|
|
elif name == "gitea-caddy.container":
|
|
# Removing the old split proxy requires the canonical
|
|
# Caddy handler to retire its installed unit/container
|
|
# before it claims ports 80/443.
|
|
_add(services, "caddy")
|
|
elif name == "gitea.network":
|
|
_add(services, "gitea-postgres", "gitea-server")
|
|
return services
|
|
return services
|
|
|
|
if parts[0] == "dankserver":
|
|
if len(parts) >= 2 and parts[1] == "compose.yaml":
|
|
# Compose service keys map onto the deployed unit names.
|
|
_add(services, *(COMPOSE_NAME_MAP.get(n, n) for n in DANKSERVER_COMPOSE_SERVICES))
|
|
return services
|
|
if len(parts) >= 3 and parts[2] == "quadlet":
|
|
quadlet_services = {
|
|
"romm": {"romm", "romm-mariadb", "romm-redis"},
|
|
"jellyfin": {"jellyfin"},
|
|
"calibre-web": {"calibre-web"},
|
|
"copyparty": {"copyparty"},
|
|
"plex": {"plex"},
|
|
"tautulli": {"tautulli"},
|
|
}
|
|
_add(services, *quadlet_services.get(parts[1], set()))
|
|
if len(parts) >= 2 and parts[1] == "observability":
|
|
# Dankserver's node-exporter became a Quadlet unit on 2026-09-16
|
|
# (serverAppConfigs-docs/Infrastructure/Observability.md): its
|
|
# compose service died at a reboot and stayed dead, so Prometheus'
|
|
# node-dankserver job had nothing to scrape.
|
|
if len(parts) >= 4 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name == "node-exporter":
|
|
_add(services, "node-exporter")
|
|
return services
|
|
if len(parts) >= 2 and parts[1] == "frank-medium":
|
|
# frank-medium is a ROOT llama.cpp unit since 2026-09-16: its
|
|
# runtime is a native gfx1201 binary, not a container, so there is
|
|
# nothing under dankserver/frank-medium/ to plan any more. The
|
|
# retired vllm-radiance quadlets live in .AGENTTRASH/, which is
|
|
# archive material and plans nothing.
|
|
return services
|
|
return services
|
|
|
|
if parts[0] == "frankbook-pro":
|
|
if len(parts) >= 3 and parts[2] == "quadlet":
|
|
name = parts[3].removesuffix(".container")
|
|
if name == "gluetun":
|
|
# Every *arr + qBittorrent container shares gluetun's
|
|
# network namespace; a gluetun recreation kills all of
|
|
# them, so the plan restarts the whole netns family.
|
|
_add(services, "gluetun", "qbittorrent",
|
|
"prowlarr", "sonarr", "radarr", "lidarr", "readarr")
|
|
elif name in FRANKBOOK_SERVICES:
|
|
_add(services, name)
|
|
return services
|
|
|
|
if parts[0] == "scripts":
|
|
name = p.name
|
|
if name in {"homepage-status.py", "homepage-status.service", "homepage-status.timer"}:
|
|
_add(services, "homepage")
|
|
if name in {"signal-relay.py", "signal-relay.service"}:
|
|
# The Signal relay is installed by the alertmanager deploy, so a
|
|
# change to it redeploying alertmanager is the only path that
|
|
# reaches it on the host.
|
|
_add(services, "alertmanager")
|
|
if name in {"frank-small.env", "frank-small.service", "serve-frank-small.sh"}:
|
|
# llama.cpp frank-small (2026-09-16) keeps its unit, env and serve
|
|
# files here; each one selects frank-small only. The ik_llama.cpp
|
|
# trio that lived here for two days is in scripts/.AGENTTRASH/,
|
|
# which is archive material and plans nothing.
|
|
_add(services, "frank-small")
|
|
if name in {"frank-medium.env", "frank-medium.service", "serve-frank-medium.sh"}:
|
|
# llama.cpp frank-medium (2026-09-16) keeps its unit, env and serve
|
|
# files here the same way. The files that BUILD and FETCH its
|
|
# runtime and payload are host-side one-offs: they change no
|
|
# deployed state, so they select nothing.
|
|
_add(services, "frank-medium")
|
|
if name in {"frank-large.env", "frank-large.service", "serve-frank-large.sh"}:
|
|
# CIRU v4.4.1 frank-large (2026-09-19) keeps its unit, env and
|
|
# serve files here the same way: a root unit on a host-built
|
|
# runtime. The download script fetches the payload, which is
|
|
# host-side state, so it selects nothing.
|
|
_add(services, "frank-large")
|
|
# Deployment/test/benchmark scripts and other repo tooling do not
|
|
# restart a runtime service.
|
|
return services
|
|
|
|
# Examples and env templates are not deployed runtime state.
|
|
if p.name in {".env.example", "env.example"}:
|
|
return services
|
|
return services
|
|
|
|
|
|
def changed_paths(repo: str, base: str, head: str) -> list[str]:
|
|
result = subprocess.run(
|
|
["git", "-C", repo, "diff", "--name-only", "--diff-filter=ACDMRTUXB", base, head],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
check=True,
|
|
)
|
|
return [line for line in result.stdout.splitlines() if line]
|
|
|
|
|
|
def plan(paths: list[str], target: str, repo: str | None = None,
|
|
base: str | None = None, head: str | None = None) -> list[str]:
|
|
services: set[str] = set()
|
|
for path in paths:
|
|
if path == f"{target}/compose.yaml" and repo and base and head:
|
|
services.update(compose_changes(repo, target, base, head))
|
|
else:
|
|
services.update(services_for_path(path, target))
|
|
allowed = target_services(target)
|
|
return [name for name in SERVICE_ORDER if name in services and name in allowed]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--target", choices=("dankclaw", "dankserver", "frankbook-pro"), required=True)
|
|
parser.add_argument("--paths", nargs="+")
|
|
parser.add_argument("--repo", help="repository path for revision-aware planning")
|
|
parser.add_argument("--base", help="base Git revision")
|
|
parser.add_argument("--head", help="head Git revision")
|
|
args = parser.parse_args()
|
|
|
|
if not args.paths and not (args.repo and args.base and args.head):
|
|
parser.error("provide --paths or all of --repo, --base, and --head")
|
|
paths = args.paths or changed_paths(args.repo, args.base, args.head)
|
|
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|