The vLLM detour (a2b39b1, same day) is retired: the Q4_K_M llama.cpp setup
is the requested shape and the vLLM KV-quant options on sm_86 all measured
dead (fp8 hardware-impossible, int8 page-size unification, turboquant
mm-prefix). Root unit restored, vLLM quadlet/Dockerfile/fetch script moved
to .AGENTTRASH, runbook listed as same-day-reverted. Rollback target:
frank-small-vllm-gemma4.
467 lines
18 KiB
Python
Executable File
467 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
|
|
if len(parts) >= 2 and parts[1] == "frank-large":
|
|
# The service is a rootless Quadlet container since 2026-09-11;
|
|
# its unit and fleet settings live in one .container file.
|
|
_add(services, "frank-large")
|
|
return services
|
|
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")
|
|
# 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())
|