Registration is load-bearing, not cosmetic: deploy-plan.py refuses any service it does not know, so SERVICE_ORDER + DANKCLAW_SERVICES are what let `--only` and `--auto` address the pair at all; the dispatch case in deploy-remote.sh runs it; the requirement in check_prerequisites gates a manual deploy; and the dispatch regex in deploy.sh's host-mode branch decides whether a changed path dispatches a service or falls through to "no service mapped" — an omission there is silent, which is exactly how the AGENTS.md deployer-registration law was earned. The deploy seeds /home/dank/Containers/open-notebook/env (0600) from the host env and refuses to start the pair while OPEN_NOTEBOOK_ENCRYPTION_KEY or OPEN_NOTEBOOK_PASSWORD is blank: without the key no provider credential saves and without the password the API — including the episode-audio route — is open to the LAN with no auth. A seeded file is never rewritten, so re-deploying cannot clobber a rotated key, which would silently make every stored credential undecryptable. SURREAL_PASS is derived from SURREAL_PASSWORD in the same write. The gate names paths that answer, not invented ones: the database is judged by its own container health status (/surreal is-ready, verified against the pinned digest) and the app by wait_for_health on /health at the published 5056 mapping — the route is upstream's and is on the auth middleware's exclude list, so it stays 200 after the password is set. 180 s covers Next.js build + startup and a degraded-but-listening first boot, matching the sibling LLM deploys. Both digest pins are checked against local storage before restart, because Pull=never means a missing image would otherwise surface as a unit stuck in "Start request queued" (the 203/EXEC trap's sibling). The interim episode directory is created before the app starts: a missing bind source makes podman create it as root-owned and the app's mkdir would then fail.
494 lines
19 KiB
Python
Executable File
494 lines
19 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",
|
|
"open-notebook",
|
|
"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",
|
|
"calibre-server",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
"frank-dragoon",
|
|
"frank-zealot",
|
|
"frank-archon",
|
|
)
|
|
|
|
DANKCLAW_SERVICES = {
|
|
"hermes-webui",
|
|
"gluetun",
|
|
"searxng-valkey",
|
|
"searxng",
|
|
"marreta",
|
|
"pihole",
|
|
"open-webui",
|
|
"open-notebook",
|
|
"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-dragoon",
|
|
"frank-zealot",
|
|
}
|
|
|
|
DANKSERVER_SERVICES = {
|
|
"romm-mariadb",
|
|
"romm-redis",
|
|
"romm",
|
|
"copyparty",
|
|
"jellyfin",
|
|
"calibre-web",
|
|
"calibre-server",
|
|
"plex",
|
|
"tautulli",
|
|
"prowlarr",
|
|
"qbittorrent",
|
|
"radarr",
|
|
"sonarr",
|
|
"lidarr",
|
|
"readarr",
|
|
"frank-archon",
|
|
"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] == "open-notebook":
|
|
# One deployable owns the app AND its SurrealDB, like the marreta
|
|
# app/companion pair: the app alone cannot write a note and the
|
|
# database alone is a volume nothing reads. Any change under the
|
|
# directory (unit, network, or the interim episode mount) therefore
|
|
# plans the pair, which deploy_open_notebook starts database-first.
|
|
_add(services, "open-notebook")
|
|
return services
|
|
# frank-dragoon 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"},
|
|
"calibre-server": {"calibre-server"},
|
|
"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
|
|
# frank-archon's quadlet-era directory left the tree on 2026-09-26
|
|
# (the Lucebox DS4.1 unit was retired for the llama.cpp root unit —
|
|
# the trio in scripts/ now maps it). Nothing lives under
|
|
# dankserver/frank-archon/ except dated trash, and trash plans
|
|
# nothing (the retire rule above the service blocks).
|
|
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-zealot.env", "frank-zealot.service", "serve-frank-zealot.sh"}:
|
|
# llama.cpp frank-zealot (2026-09-16) keeps its unit, env and serve
|
|
# files here; each one selects frank-zealot 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-zealot")
|
|
if name in {"frank-dragoon.env", "frank-dragoon.service", "serve-frank-dragoon.sh"}:
|
|
# CIRU v4.4.1 frank-dragoon (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-dragoon")
|
|
if name == "qwen-sharp-v22.4.0.jinja":
|
|
# The Qwen-Sharp chat template frank-dragoon loads via
|
|
# --chat-template-file. It lived under dankserver/frank-medium/ until
|
|
# the 2026-09-26 cutover retired that directory; editing it changes
|
|
# what the live frank-dragoon endpoint renders, so it plans
|
|
# frank-dragoon.
|
|
_add(services, "frank-dragoon")
|
|
if name in {"frank-archon.env", "frank-archon.service", "serve-frank-archon.sh"}:
|
|
# llama.cpp frank-archon (2026-09-26, replacing the Lucebox
|
|
# DS4.1 quadlet) keeps its unit, env and serve files here the
|
|
# same way: a root unit on the unsloth b11030-mix prebuilt
|
|
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches
|
|
# the payload, which is host-side state, so it selects nothing.
|
|
_add(services, "frank-archon")
|
|
if name == "open-notebook.env":
|
|
# open-notebook's tunables live here — the only repo-tracked part of
|
|
# either container's environment (the credentials are seeded on the
|
|
# host, so they are never a deploy trigger). Changing it is a
|
|
# restart of the pair, which the single deployable performs.
|
|
_add(services, "open-notebook")
|
|
# 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())
|