feat(inventory): one service inventory drives planner, allow-lists, and seams
rank 3. shared/services.yml is the single source of truth for every deployable service (hosts, unit dirs, units, path claims, tests, scrape/ dns/probe facts, aliases, stacks); scripts/service-map.py is its only reader (hosts/services/allows/plan/seams/owner/units/stack/get/render/ check). deploy-plan.py becomes a thin CLI wrapper; both --service allow-lists (deploy.sh, deploy-remote.sh) and the changed-test mapper are the inventory. Selection semantics: host scoping (a host's tree selects only its own services), the quadlet netns/network coupling graph resolved across directories (gluetun's riders live in their own dirs), stacks, and canonical restart order. Against the pinned old-planner fixture: 921 raw deltas reduced to 17, all intentional (anubis now maps; cross-host paths select nothing; the retired qwen-sharp template plans nothing and the live froggeric template plans frank-archon; dankserver/romm/romm.container, missed by the old filename rule, plans the romm trio). The fixture is regenerated as the new pin. Integrity gates: schema, unit claims, quadlet-file ownership, path-glob liveness, Pi-hole hosts / Prometheus jobs / DNS / homepage probes drift. tests/service_inventory.bats pins all of it plus the plan fixture and the deploy-plan CLI. Allow-list grep seams in hermes_webui/observability_quadlet become behavioral allows calls.
This commit is contained in:
1 parent
156ea71879
commit
bdead570e2
17 files changed
+3435
-699
No files matched your search
@@ -35,11 +35,16 @@ the [runbook index](#runbook-index). The full swap history lives in
|
|||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the media stack
|
- `dankserver/` — quadlet units + Caddyfile for the media stack (the
|
||||||
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the AI stack
|
compose.yaml inventory retired 2026-10-13; its mounts had diverged from
|
||||||
- `shared/` — homepage, Prometheus, Loki, Alloy, SearXNG configs
|
the quadlets)
|
||||||
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh`, LLM serve
|
- `dankclaw/` — quadlet units + Caddyfile for the AI stack (compose retired 2026-09-16)
|
||||||
scripts, systemd units, env tunables, bench harnesses
|
- `shared/` — the service inventory (`services.yml`), homepage, Prometheus,
|
||||||
|
Loki, Alloy, SearXNG configs
|
||||||
|
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh` and
|
||||||
|
`service-map.py` (the inventory's only reader), LLM serve scripts + the
|
||||||
|
shared artifact gates (`scripts/lib/llm-gates.sh`), systemd units, env
|
||||||
|
tunables, bench harnesses
|
||||||
- `old/` — retired LLM scripts (reference only)
|
- `old/` — retired LLM scripts (reference only)
|
||||||
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format).
|
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format).
|
||||||
The managed skill library is distilled there one page per skill-cluster:
|
The managed skill library is distilled there one page per skill-cluster:
|
||||||
@@ -70,8 +75,13 @@ All edits flow through git. Never edit files on a remote machine directly.
|
|||||||
./scripts/deploy.sh dankclaw --dry-run
|
./scripts/deploy.sh dankclaw --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
The planner maps changed paths to services; the remote runner restarts only
|
The inventory (`shared/services.yml`, read only through
|
||||||
the selected service and records its revision outside the checkout.
|
`scripts/service-map.py`) maps changed paths to services; the remote runner
|
||||||
|
restarts only the selected service and records its revision outside the
|
||||||
|
checkout. Both `--service` allow-lists, the planner, and the changed-test
|
||||||
|
seam mapper ARE the inventory — adding, moving, or retiring a service is a
|
||||||
|
data edit, not a script edit
|
||||||
|
(`serverAppConfigs-docs/Architecture/Service Inventory.md`).
|
||||||
Documentation-only changes are a no-op. LLM services are selected only by
|
Documentation-only changes are a no-op. LLM services are selected only by
|
||||||
their own files — every endpoint (`frank-dragoon`/`frank-zealot`/`frank-archon`)
|
their own files — every endpoint (`frank-dragoon`/`frank-zealot`/`frank-archon`)
|
||||||
by its unit/env/serve trio in `scripts/` — so
|
by its unit/env/serve trio in `scripts/` — so
|
||||||
@@ -164,12 +174,17 @@ Tests are mandatory before commit (`bats` required on the dev machine):
|
|||||||
./tests/run.sh
|
./tests/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Seams: compose validity, env consistency (no leaked secrets), Caddyfile
|
Seams: env consistency (no leaked secrets), Caddyfile
|
||||||
validity, quadlet units, docs completeness **including vault link integrity**,
|
validity, quadlet units, docs completeness **including vault link integrity**,
|
||||||
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
|
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
|
||||||
deployment flow, service-scoped planning, homepage config/pages/deploy/checker,
|
LLM artifact gates (`llm_gates`/`llm_deploy_gates` — the shared
|
||||||
and `e2e_scaffold` — which fails if a Playwright spec file's number prefix is in
|
`scripts/lib/llm-gates.sh` every endpoint's serve and deploy gates run on),
|
||||||
no project, because such a spec silently never runs.
|
deployment flow, service-scoped planning, the **service inventory**
|
||||||
|
(`service_inventory.bats`: inventory soundness, the pinned path->service
|
||||||
|
plan fixture, render drift for the Pi-hole hosts file), homepage
|
||||||
|
config/pages/deploy/checker, and `e2e_scaffold` — which fails if a
|
||||||
|
Playwright spec file's number prefix is in no project, because such a spec
|
||||||
|
silently never runs.
|
||||||
See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
|
See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
|
||||||
|
|
||||||
## Secret Rules
|
## Secret Rules
|
||||||
@@ -1041,7 +1056,7 @@ Active:
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| frank-dragoon (flash-server 0.16.2, abliterated Swift1.5 v2 .hgn + MTP always-on, 2×262,144, vision ON, Dankclaw) | `serverAppConfigs-docs/LLM/frank-dragoon-flash-server-v2-swift15.md` |
|
| frank-dragoon (flash-server 0.16.2, abliterated Swift1.5 v2 .hgn + MTP always-on, 2×262,144, vision ON, Dankclaw) | `serverAppConfigs-docs/LLM/frank-dragoon-flash-server-v2-swift15.md` |
|
||||||
| frank-zealot (llama.cpp CUDA, Qwen3.5-9B-Uncensored Q6_K, 2×200,704 exact f16 KV MTP-off, vision ON, Dankclaw) | `serverAppConfigs-docs/LLM/frank-zealot-llama-qwen35-9b-q6k-2x200704.md` |
|
| frank-zealot (llama.cpp CUDA, Qwen3.5-9B-Uncensored Q6_K, 2×200,704 exact f16 KV MTP-off, vision ON, Dankclaw) | `serverAppConfigs-docs/LLM/frank-zealot-llama-qwen35-9b-q6k-2x200704.md` |
|
||||||
| frank-archon (radiance StillDeadcode, Qwen3.8-Flash-Next-FP8 native, 2×R9700 TP=2, Dankserver) | `serverAppConfigs-docs/LLM/frank-archon-radiance-qwen38-native-fp8.md` |
|
| frank-archon (radiance StillDeadcode, Swift1.5-Qwen3.8-Flash-Next GPTQ w4 + bf16-protected experts, int8 trunk, bf16 KV, 2×R9700 TP=2, Dankserver) | `serverAppConfigs-docs/LLM/frank-archon-radiance-swift15-w4dn8.md` |
|
||||||
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
|
| Dankclaw kernel posture (`amd_iommu=off`) | `serverAppConfigs-docs/LLM/dankclaw-iommu-translation-off.md` |
|
||||||
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
|
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
|
||||||
|
|
||||||
@@ -1050,6 +1065,7 @@ Retired (rollback targets and history):
|
|||||||
| Runtime | Host | Window | Runbook |
|
| Runtime | Host | Window | Runbook |
|
||||||
|---------|------|--------|---------|
|
|---------|------|--------|---------|
|
||||||
| llama.cpp CUDA Gemma4-26B-A4B QAT Q4_K_M, 1×200,704 f16-K/q8_0-V MTP-off (`frank-zealot`) | Dankclaw | 2026-10-07 → 10-07 (same-day swap; rollback target — payload on disk; owns the `attention_k_eq_v` pair-billing KV law and the gemma4-assistant drafter-cost boots) | `frank-zealot-llama-gemma4-q4km-200704.md` |
|
| llama.cpp CUDA Gemma4-26B-A4B QAT Q4_K_M, 1×200,704 f16-K/q8_0-V MTP-off (`frank-zealot`) | Dankclaw | 2026-10-07 → 10-07 (same-day swap; rollback target — payload on disk; owns the `attention_k_eq_v` pair-billing KV law and the gemma4-assistant drafter-cost boots) | `frank-zealot-llama-gemma4-q4km-200704.md` |
|
||||||
|
| radiance StillDeadcode Qwen3.8-Flash-Next native-FP8, 2×R9700 TP=2 (`frank-archon`) | Dankserver | 2026-10-08 → w4dn8 swap (rollback target — `.rad` payload on disk) | `frank-archon-radiance-qwen38-native-fp8.md` |
|
||||||
| ROCmFPX llama.cpp fork Qwen3.8-27B Uncensored Q6_0_ROCMFPX_AGENT + MTP (`frank-dragoon`) | Dankclaw | 2026-10-03 → 10-05 (rollback target, payload + fork build on disk) | `frank-dragoon-rocmfpx-q6agent-uncensored-mtp.md` |
|
| ROCmFPX llama.cpp fork Qwen3.8-27B Uncensored Q6_0_ROCMFPX_AGENT + MTP (`frank-dragoon`) | Dankclaw | 2026-10-03 → 10-05 (rollback target, payload + fork build on disk) | `frank-dragoon-rocmfpx-q6agent-uncensored-mtp.md` |
|
||||||
| tcclaviger vLLM fork Qwen3.8-Flash-Next-Uncensored FP8, TP=2, shape G (`frank-archon`) | Dankserver | 2026-09-29 → 10-05 (rollback target — tag `frank-archon-vllm-shapeG-20261003`; trio + env lore in the tag, image in rootless storage) | `dankserver-qwen38-fp8-expert-offload-tuning.md` |
|
| tcclaviger vLLM fork Qwen3.8-Flash-Next-Uncensored FP8, TP=2, shape G (`frank-archon`) | Dankserver | 2026-09-29 → 10-05 (rollback target — tag `frank-archon-vllm-shapeG-20261003`; trio + env lore in the tag, image in rootless storage) | `dankserver-qwen38-fp8-expert-offload-tuning.md` |
|
||||||
| FreeToken PR #587 ROCm, Qwen3.8-Flash-Next-Uncensored-FP8, ONE R9700 (`frank-archon`) | Dankserver | 2026-10-05 → 10-05 (rollback target — tag `frank-archon-freetoken-pr587-20261005`; trio + Dockerfile in the tag, image `localhost/frank-archon-freetoken:pr587-77a7201` and orcarouter payload on disk) | `frank-archon-freetoken-pr587.md` |
|
| FreeToken PR #587 ROCm, Qwen3.8-Flash-Next-Uncensored-FP8, ONE R9700 (`frank-archon`) | Dankserver | 2026-10-05 → 10-05 (rollback target — tag `frank-archon-freetoken-pr587-20261005`; trio + Dockerfile in the tag, image `localhost/frank-archon-freetoken:pr587-77a7201` and orcarouter payload on disk) | `frank-archon-freetoken-pr587.md` |
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+54
-461
@@ -1,475 +1,68 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Plan runtime services affected by repository changes.
|
"""deploy-plan.py — map changed paths to deployable services.
|
||||||
|
|
||||||
The planner is intentionally side-effect free. It accepts either an explicit
|
Thin wrapper over scripts/service-map.py, which owns the path->service
|
||||||
path list or two Git revisions. The latter lets compose-file changes resolve
|
semantics in the shared inventory (shared/services.yml): per-service path
|
||||||
to only the service definitions that actually changed instead of restarting an
|
rules, the quadlet netns/network coupling graph, host scoping (a host's
|
||||||
entire compose project.
|
directory can only select that host's services), and canonical restart
|
||||||
|
order. This file keeps the deployer's CLI contract only.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
deploy-plan.py --target NAME --repo DIR --base REV --head REV
|
||||||
|
deploy-plan.py --target NAME [paths...] (paths also on stdin)
|
||||||
|
|
||||||
|
Prints one service name per line (canonical restart order); exit 0 with no
|
||||||
|
output when nothing relevant changed.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
import os
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SERVICE_MAP = os.path.join(SCRIPT_DIR, "service-map.py")
|
||||||
# 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",
|
|
||||||
"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",
|
|
||||||
"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 git_paths(repo: str, base: str, head: str) -> list[str]:
|
||||||
def _add(services: set[str], *names: str) -> None:
|
proc = subprocess.run(
|
||||||
services.update(names)
|
["git", "-C", repo, "diff", "--name-only", base, head],
|
||||||
|
text=True, capture_output=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
def target_services(target: str) -> set[str]:
|
sys.stderr.write(f"deploy-plan: git diff failed: {proc.stderr}")
|
||||||
if target == "dankclaw":
|
raise SystemExit(1)
|
||||||
return DANKCLAW_SERVICES
|
return [line for line in proc.stdout.splitlines() if line]
|
||||||
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-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")
|
|
||||||
# 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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
args = sys.argv[1:]
|
||||||
parser.add_argument("--target", choices=("dankclaw", "dankserver", "frankbook-pro"), required=True)
|
if not args or args[0] != "--target":
|
||||||
parser.add_argument("--paths", nargs="+")
|
print(__doc__, file=sys.stderr)
|
||||||
parser.add_argument("--repo", help="repository path for revision-aware planning")
|
return 2
|
||||||
parser.add_argument("--base", help="base Git revision")
|
if len(args) < 2:
|
||||||
parser.add_argument("--head", help="head Git revision")
|
print("missing target name after --target", file=sys.stderr)
|
||||||
args = parser.parse_args()
|
return 2
|
||||||
|
target = args[1]
|
||||||
if not args.paths and not (args.repo and args.base and args.head):
|
# `--paths` is the historic seam keyword: a flag, not an option — the
|
||||||
parser.error("provide --paths or all of --repo, --base, and --head")
|
# paths are the positional arguments that follow it.
|
||||||
paths = args.paths or changed_paths(args.repo, args.base, args.head)
|
rest = [a for a in args[2:] if a != "--paths"]
|
||||||
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
|
if "--repo" in rest:
|
||||||
|
try:
|
||||||
|
repo = rest[rest.index("--repo") + 1]
|
||||||
|
base = rest[rest.index("--base") + 1]
|
||||||
|
head = rest[rest.index("--head") + 1]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
print("--repo, --base and --head must appear together", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
paths = git_paths(repo, base, head)
|
||||||
|
else:
|
||||||
|
paths = rest or [line.strip() for line in sys.stdin if line.strip()]
|
||||||
|
if not paths:
|
||||||
|
return 0
|
||||||
|
proc = subprocess.run([sys.executable, SERVICE_MAP, "plan", "--host", target] + paths,
|
||||||
|
text=True, capture_output=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
sys.stderr.write(proc.stderr)
|
||||||
|
return proc.returncode
|
||||||
|
sys.stdout.write(proc.stdout)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-25
@@ -131,31 +131,13 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
valid_service() {
|
valid_service() {
|
||||||
local service="$1"
|
# The inventory is the only allow-list (see scripts/service-map.py);
|
||||||
case "$TARGET:$service" in
|
# service-map's "allows" exit code is the answer. The former literal
|
||||||
dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\
|
# case list here drifted from reality (it still listed the *arr stack
|
||||||
dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\
|
# under dankserver, which moved to frankbook-pro, and a
|
||||||
dankclaw:gitea-server|dankclaw:homepage|\
|
# "searxng-network" name that is a generated systemd unit, not a
|
||||||
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
|
# deployable service).
|
||||||
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
|
python3 "$REPO_ROOT/scripts/service-map.py" allows --host "$TARGET" --service "$1" >/dev/null
|
||||||
dankclaw:alertmanager|\
|
|
||||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
|
||||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
|
||||||
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
|
||||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|\
|
|
||||||
dankserver:frank-archon|\
|
|
||||||
frankbook-pro:gluetun|frankbook-pro:qbittorrent|frankbook-pro:prowlarr|\
|
|
||||||
frankbook-pro:radarr|frankbook-pro:sonarr|frankbook-pro:lidarr|\
|
|
||||||
frankbook-pro:readarr)
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for service in "${EXPLICIT_SERVICES[@]}"; do
|
for service in "${EXPLICIT_SERVICES[@]}"; do
|
||||||
|
|||||||
Executable
+647
@@ -0,0 +1,647 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""service-map.py — the only reader of the service inventory.
|
||||||
|
|
||||||
|
Four lists used to agree by discipline (the planner's path rules, the
|
||||||
|
deployer's allow-list, the changed-test mapper, and the scrape/probe/DNS
|
||||||
|
facts written by hand in three config files). They now all read
|
||||||
|
`shared/services.yml` through this tool:
|
||||||
|
|
||||||
|
hosts the deploy targets
|
||||||
|
services [--host H] deployables a target accepts, canonical order
|
||||||
|
allows --host H --service S exit 0 when H accepts S (silent)
|
||||||
|
plan --host H PATH... services selected by changed paths, order-safe
|
||||||
|
seams [--host H] PATH... the Bats seams a changed path must re-run
|
||||||
|
owner --host H PATH the service that owns a file (empty: none)
|
||||||
|
units --host H --service S "<unit> <unit_dir>" lines the handler installs
|
||||||
|
stack --host H --service S the deployable + members one handler deploys
|
||||||
|
get --service S [--host H] F one field as JSON
|
||||||
|
render pihole-hosts the Pi-hole addn-hosts file
|
||||||
|
check [NAME...] integrity + drift gates (exit 1 on drift)
|
||||||
|
|
||||||
|
Two laws the engine enforces structurally:
|
||||||
|
|
||||||
|
* A path selects only services that RUN on the planned target. The old planner
|
||||||
|
matched paths host-blind and filtered at the end, so a frankbook-pro file
|
||||||
|
could plan a dankserver service (the stale *arr entries made that reachable).
|
||||||
|
* The netns family is DERIVED, never declared: a unit file that says
|
||||||
|
`Network=gluetun.container` rides that tunnel, so a gluetun change selects
|
||||||
|
every sharer. The unit files are the source of that coupling.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_INVENTORY = REPO_ROOT / "shared" / "services.yml"
|
||||||
|
|
||||||
|
REQUIRED_KEYS = ("name", "hosts", "kind", "tests")
|
||||||
|
KINDS = {"quadlet", "root-unit", "vm-quadlet", "host"}
|
||||||
|
UNIT_SUFFIXES = (".container", ".network")
|
||||||
|
|
||||||
|
# Repo material that is never a deployed runtime's state.
|
||||||
|
NON_RUNTIME_ROOTS = {"docs", "report", "old", ".githooks", ".obsidian", ".github",
|
||||||
|
"tests", "serverAppConfigs-docs"}
|
||||||
|
NON_RUNTIME_NAMES = {".env.example"}
|
||||||
|
NON_RUNTIME_PATHS = {"README.md", "AGENTS.md", ".gitignore"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_trash(path: str) -> bool:
|
||||||
|
return ".AGENTTRASH" in PurePosixPath(path).parts
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# inventory
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class Inventory:
|
||||||
|
def __init__(self, doc: dict[str, Any], origin: str) -> None:
|
||||||
|
self.origin = origin
|
||||||
|
self.hosts: dict[str, dict[str, Any]] = doc["hosts"]
|
||||||
|
self.services: list[dict[str, Any]] = doc["services"]
|
||||||
|
self.unowned: dict[str, str] = doc.get("unowned", {})
|
||||||
|
for index, svc in enumerate(self.services):
|
||||||
|
svc["_index"] = index
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str | Path = DEFAULT_INVENTORY) -> "Inventory":
|
||||||
|
doc = yaml.safe_load(Path(path).read_text())
|
||||||
|
if not isinstance(doc, dict) or "services" not in doc:
|
||||||
|
raise SystemExit(f"{path}: not a service inventory document")
|
||||||
|
return cls(doc, str(path))
|
||||||
|
|
||||||
|
def on(self, host: str) -> list[dict[str, Any]]:
|
||||||
|
return [s for s in self.services if host in s["hosts"]]
|
||||||
|
|
||||||
|
def named(self, host: str, name: str) -> list[dict[str, Any]]:
|
||||||
|
return [s for s in self.services if s["name"] == name and host in s["hosts"]]
|
||||||
|
|
||||||
|
def any_named(self, name: str) -> list[dict[str, Any]]:
|
||||||
|
return [s for s in self.services if s["name"] == name]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order(self) -> list[str]:
|
||||||
|
seen: list[str] = []
|
||||||
|
for svc in self.services:
|
||||||
|
if svc["name"] not in seen:
|
||||||
|
seen.append(svc["name"])
|
||||||
|
return seen
|
||||||
|
|
||||||
|
def edge_ip(self) -> str:
|
||||||
|
"""The single address every DNS name resolves at (Caddy's edge)."""
|
||||||
|
for host in self.hosts.values():
|
||||||
|
if "dns_edge" in host:
|
||||||
|
return str(host["dns_edge"])
|
||||||
|
raise SystemExit(f"{self.origin}: no host declares dns_edge")
|
||||||
|
|
||||||
|
def unit_dirs(self, svc: dict[str, Any], host: str | None = None) -> list[str]:
|
||||||
|
"""The repo dirs holding this service's unit files, for one host."""
|
||||||
|
dirs = svc.get("unit_dir")
|
||||||
|
if not dirs:
|
||||||
|
return []
|
||||||
|
if isinstance(dirs, dict):
|
||||||
|
if host is not None:
|
||||||
|
return [dirs[host]] if host in dirs else []
|
||||||
|
return [dirs[h] for h in svc["hosts"] if h in dirs]
|
||||||
|
return [dirs]
|
||||||
|
|
||||||
|
def owns_unit(self, svc: dict[str, Any], host: str, unit: str, directory: str) -> bool:
|
||||||
|
return unit in svc.get("units", []) and directory in self.unit_dirs(svc, host)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# repo facts
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def tracked_files() -> tuple[str, ...]:
|
||||||
|
# Tracked + untracked (a check that only saw HEAD would ignore the very
|
||||||
|
# files a work-in-progress commit is adding).
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", str(REPO_ROOT), "ls-files", "--cached", "--others",
|
||||||
|
"--exclude-standard"],
|
||||||
|
text=True, stdout=subprocess.PIPE, check=True)
|
||||||
|
return tuple(line for line in out.stdout.splitlines() if line)
|
||||||
|
|
||||||
|
|
||||||
|
def _network_values(container: Path) -> list[str]:
|
||||||
|
"""Every `Network=` value a quadlet unit declares (comma lists included)."""
|
||||||
|
values: list[str] = []
|
||||||
|
try:
|
||||||
|
text = container.read_text()
|
||||||
|
except OSError:
|
||||||
|
return values
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("Network="):
|
||||||
|
values.extend(v.strip() for v in line[len("Network="):].split(",") if v.strip())
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class Graph:
|
||||||
|
"""The unit-file coupling, read out of the tree: netns + netns-net users.
|
||||||
|
|
||||||
|
Quadlet installs every user unit flat, so `Network=gluetun.container`
|
||||||
|
names a unit by name, not by path; the same host is the scope, which is
|
||||||
|
also the deployment scope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hosts: list[str]) -> None:
|
||||||
|
self.units: dict[tuple[str, str], str] = {} # (host, unit) -> path
|
||||||
|
self.networks: dict[tuple[str, str], str] = {} # (host, net) -> path
|
||||||
|
self.netns_children: dict[tuple[str, str], set[tuple[str, str]]] = {}
|
||||||
|
self.network_users: dict[tuple[str, str], set[tuple[str, str]]] = {}
|
||||||
|
for path in tracked_files():
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
if not parts or parts[0] not in hosts or is_trash(path):
|
||||||
|
continue
|
||||||
|
host, directory, name = parts[0], str(PurePosixPath(*parts[1:-1])), parts[-1]
|
||||||
|
if name.endswith(".container"):
|
||||||
|
self.units[(host, name[: -len(".container")])] = path
|
||||||
|
elif name.endswith(".network"):
|
||||||
|
self.networks[(host, name[: -len(".network")])] = path
|
||||||
|
for (host, unit), path in self.units.items():
|
||||||
|
for value in _network_values(REPO_ROOT / path):
|
||||||
|
if value.endswith(".container"):
|
||||||
|
parent = (host, value[: -len(".container")])
|
||||||
|
self.netns_children.setdefault(parent, set()).add((host, unit))
|
||||||
|
elif value.endswith(".network"):
|
||||||
|
net = (host, value[: -len(".network")])
|
||||||
|
self.network_users.setdefault(net, set()).add((host, unit))
|
||||||
|
|
||||||
|
def rides(self, start: list[tuple[str, str]]) -> set[tuple[str, str]]:
|
||||||
|
"""The transitive netns closure: a unit, everything riding it, and
|
||||||
|
everything riding those (a netns inside a netns)."""
|
||||||
|
found: set[tuple[str, str]] = set()
|
||||||
|
frontier = list(start)
|
||||||
|
while frontier:
|
||||||
|
node = frontier.pop()
|
||||||
|
if node in found:
|
||||||
|
continue
|
||||||
|
found.add(node)
|
||||||
|
frontier.extend(self.netns_children.get(node, ()))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def graph_for(hosts: tuple[str, ...]) -> Graph:
|
||||||
|
return Graph(list(hosts))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# selection
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _owner_index(inv: Inventory) -> dict[tuple[str, str, int]]:
|
||||||
|
"""(host, unit) -> service index, for unit-file ownership.
|
||||||
|
|
||||||
|
A quadlet unit name is unique per host (``check units`` enforces one
|
||||||
|
claim), so the coupling closure can resolve a riding unit that lives in
|
||||||
|
a DIFFERENT directory than the changed file — gluetun's netns children
|
||||||
|
are the point.
|
||||||
|
"""
|
||||||
|
index: dict[tuple[str, str], int] = {}
|
||||||
|
for svc in inv.services:
|
||||||
|
for host in svc["hosts"]:
|
||||||
|
for unit in svc.get("units", []):
|
||||||
|
key = (host, unit)
|
||||||
|
if key in index and index[key] != svc["_index"]:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{inv.origin}: unit {unit!r} on {host} claimed by "
|
||||||
|
f"{inv.services[index[key]]['name']} and {svc['name']}"
|
||||||
|
)
|
||||||
|
index[key] = svc["_index"]
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def select(inv: Inventory, host: str, paths: list[str]) -> list[str]:
|
||||||
|
"""Canonical-order service names the changed paths select for one host."""
|
||||||
|
if host not in inv.hosts:
|
||||||
|
raise SystemExit(f"{inv.origin}: no host {host!r}")
|
||||||
|
graph = graph_for(tuple(inv.hosts))
|
||||||
|
owners = _owner_index(inv)
|
||||||
|
hits: set[int] = set()
|
||||||
|
|
||||||
|
def add(index: int) -> None:
|
||||||
|
hits.add(index)
|
||||||
|
|
||||||
|
for raw in paths:
|
||||||
|
path = raw.replace("\\", "/")
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
if not parts or is_trash(path) or path in NON_RUNTIME_PATHS:
|
||||||
|
continue
|
||||||
|
if PurePosixPath(path).name in NON_RUNTIME_NAMES:
|
||||||
|
continue
|
||||||
|
head = parts[0]
|
||||||
|
if head in NON_RUNTIME_ROOTS:
|
||||||
|
continue
|
||||||
|
# A host's directory is the scope: a frankbook-pro file can never
|
||||||
|
# select a dankserver service.
|
||||||
|
scoped_host = head if head in inv.hosts else None
|
||||||
|
if scoped_host is not None and scoped_host != host:
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = parts[-1]
|
||||||
|
directory = str(PurePosixPath(*parts[1:-1])) if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
if scoped_host and name.endswith(UNIT_SUFFIXES):
|
||||||
|
stem = name.rsplit(".", 1)[0]
|
||||||
|
key = (scoped_host, stem)
|
||||||
|
if name.endswith(".network") and key in graph.networks:
|
||||||
|
users = graph.network_users.get(key, set())
|
||||||
|
for unit in graph.rides(sorted(users)):
|
||||||
|
index = owners.get(unit)
|
||||||
|
if index is not None:
|
||||||
|
add(index)
|
||||||
|
elif key in graph.units:
|
||||||
|
for unit in graph.rides([key]):
|
||||||
|
index = owners.get(unit)
|
||||||
|
if index is not None:
|
||||||
|
add(index)
|
||||||
|
|
||||||
|
for index in list(hits):
|
||||||
|
for dependent in inv.services[index].get("dependents", []):
|
||||||
|
for svc in inv.named(host, dependent):
|
||||||
|
add(svc["_index"])
|
||||||
|
|
||||||
|
for svc in inv.on(host):
|
||||||
|
if path in svc.get("files", []):
|
||||||
|
add(svc["_index"])
|
||||||
|
if path in svc.get("retires", []):
|
||||||
|
add(svc["_index"])
|
||||||
|
for directory_rule in svc.get("dirs", []):
|
||||||
|
if path == directory_rule or path.startswith(directory_rule + "/"):
|
||||||
|
add(svc["_index"])
|
||||||
|
|
||||||
|
return [svc["name"] for svc in sorted((inv.services[i] for i in hits),
|
||||||
|
key=lambda s: s["_index"])]
|
||||||
|
|
||||||
|
|
||||||
|
def seams_for(inv: Inventory, host: str, paths: list[str]) -> list[str]:
|
||||||
|
"""The Bats seams the selected services require, deduplicated, in order."""
|
||||||
|
seams: list[str] = []
|
||||||
|
for name in select(inv, host, paths):
|
||||||
|
for svc in inv.named(host, name):
|
||||||
|
for seam in svc.get("tests", []):
|
||||||
|
if seam not in seams:
|
||||||
|
seams.append(seam)
|
||||||
|
if seams and "tests/deployment_scoping.bats" not in seams:
|
||||||
|
seams.append("tests/deployment_scoping.bats")
|
||||||
|
return seams
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# renderers + facts
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def render_pihole_hosts(inv: Inventory) -> str:
|
||||||
|
edge = inv.edge_ip()
|
||||||
|
lines = [
|
||||||
|
"# A records for the flat franksdank.cloud scheme (dnsmasq addn-hosts",
|
||||||
|
"# format: <IP> <name>). RENDERED — do not edit by hand:",
|
||||||
|
"#",
|
||||||
|
"# scripts/service-map.py render pihole-hosts > dankclaw/pihole/addn-hosts",
|
||||||
|
"#",
|
||||||
|
"# The source of truth is the `dns:` field of shared/services.yml; the",
|
||||||
|
"# drift gate in tests/service_inventory.bats fails when this file and",
|
||||||
|
"# the inventory disagree. Caddy is consolidated on Dankclaw, so every",
|
||||||
|
"# name — including the services running on dankserver and",
|
||||||
|
"# frankbook-pro — resolves at the Dankclaw edge.",
|
||||||
|
"#",
|
||||||
|
"# dnsmasq strips ONLY whole-line comments in addn-hosts: a trailing",
|
||||||
|
"# comment on a data line becomes a HOSTNAME token. No inline notes",
|
||||||
|
"# here; the service attribution lives in the inventory.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for svc in inv.services:
|
||||||
|
for name in svc.get("dns", []):
|
||||||
|
lines.append(f"{edge} {name}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_facts(inv: Inventory) -> list[dict[str, str]]:
|
||||||
|
facts = []
|
||||||
|
for svc in inv.services:
|
||||||
|
for job in svc.get("scrape", []):
|
||||||
|
facts.append({"job": job["job"], "target": job["target"],
|
||||||
|
"metrics_path": job.get("metrics_path", "/")})
|
||||||
|
return facts
|
||||||
|
|
||||||
|
|
||||||
|
def prometheus_facts(path: Path) -> list[dict[str, str]]:
|
||||||
|
doc = yaml.safe_load(path.read_text())
|
||||||
|
facts = []
|
||||||
|
for job in doc.get("scrape_configs", []):
|
||||||
|
for sc in job.get("static_configs", []):
|
||||||
|
for target in sc.get("targets", []):
|
||||||
|
facts.append({"job": job["job_name"], "target": target,
|
||||||
|
"metrics_path": job.get("metrics_path", "/")})
|
||||||
|
return facts
|
||||||
|
|
||||||
|
|
||||||
|
def probe_facts(inv: Inventory) -> list[str]:
|
||||||
|
facts: list[str] = []
|
||||||
|
for svc in inv.services:
|
||||||
|
probe = svc.get("probe")
|
||||||
|
if isinstance(probe, dict):
|
||||||
|
facts.extend(str(v) for v in probe.values())
|
||||||
|
elif probe:
|
||||||
|
facts.append(str(probe))
|
||||||
|
return facts
|
||||||
|
|
||||||
|
|
||||||
|
def homepage_probes(path: Path) -> list[str]:
|
||||||
|
return sorted({s["probe"] for s in json.loads(path.read_text()) if s.get("probe")})
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# checks
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def check_ownership(inv: Inventory, graph: Graph) -> list[str]:
|
||||||
|
"""Every repo file belongs to a service, a rule, or the unowned list."""
|
||||||
|
problems: list[str] = []
|
||||||
|
exact: set[str] = set()
|
||||||
|
directories: set[str] = set()
|
||||||
|
for svc in inv.services:
|
||||||
|
for host in svc["hosts"]:
|
||||||
|
for directory in inv.unit_dirs(svc, host):
|
||||||
|
for unit in svc.get("units", []):
|
||||||
|
exact.add(f"{directory}/{unit}.container")
|
||||||
|
for field in ("files", "retires"):
|
||||||
|
exact.update(svc.get(field, []))
|
||||||
|
directories.update(svc.get("dirs", []))
|
||||||
|
# A shared network file is claimed by the units that ride it; the unit
|
||||||
|
# graph is the referrer, and check_units proves every reference resolves.
|
||||||
|
exact.update(graph.networks.values())
|
||||||
|
|
||||||
|
def is_claimed(path: str) -> bool:
|
||||||
|
if path in exact:
|
||||||
|
return True
|
||||||
|
first = PurePosixPath(path).parts[0]
|
||||||
|
if first in NON_RUNTIME_ROOTS or path in NON_RUNTIME_PATHS or is_trash(path):
|
||||||
|
return True
|
||||||
|
if PurePosixPath(path).name in NON_RUNTIME_NAMES:
|
||||||
|
return True
|
||||||
|
for owned in directories | set(inv.unowned):
|
||||||
|
if path == owned or path.startswith(owned + "/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
for path in tracked_files():
|
||||||
|
if not is_claimed(path):
|
||||||
|
problems.append(f"file belongs to no service and is not in unowned: {path}")
|
||||||
|
|
||||||
|
# The reverse direction: an unowned entry that names nothing is stale.
|
||||||
|
for directory in inv.unowned:
|
||||||
|
if not any(p == directory or p.startswith(directory + "/") for p in tracked_files()):
|
||||||
|
problems.append(f"unowned entry names no tracked file: {directory}")
|
||||||
|
|
||||||
|
# Every inventory file/dir claim names a tracked file.
|
||||||
|
for svc in inv.services:
|
||||||
|
# `retires` names a file whose DELETION selects the service, so by
|
||||||
|
# definition it is not in the tree.
|
||||||
|
for field in ("files", "dirs"):
|
||||||
|
for entry in svc.get(field, []):
|
||||||
|
if entry not in tracked_files() and not any(
|
||||||
|
p.startswith(entry + "/") for p in tracked_files()):
|
||||||
|
problems.append(f"{svc['name']}: {field} entry names no tracked file: {entry}")
|
||||||
|
del graph
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_units(inv: Inventory, graph: Graph) -> list[str]:
|
||||||
|
"""Every unit file has exactly one owner; every unit reference resolves."""
|
||||||
|
problems: list[str] = []
|
||||||
|
owners: dict[str, list[str]] = {}
|
||||||
|
for svc in inv.services:
|
||||||
|
for host in svc["hosts"]:
|
||||||
|
for directory in inv.unit_dirs(svc, host):
|
||||||
|
for unit in svc.get("units", []):
|
||||||
|
path = f"{directory}/{unit}.container"
|
||||||
|
owners.setdefault(path, []).append(f"{svc['name']}@{host}")
|
||||||
|
if path not in tracked_files():
|
||||||
|
problems.append(f"{svc['name']}: unit file is not tracked: {path}")
|
||||||
|
for path in tracked_files():
|
||||||
|
if not path.endswith(".container") or is_trash(path):
|
||||||
|
continue
|
||||||
|
found = owners.get(path, [])
|
||||||
|
if not found:
|
||||||
|
problems.append(f"unit file claimed by no service: {path}")
|
||||||
|
elif len(found) > 1:
|
||||||
|
problems.append(f"unit file claimed by {len(found)} services: {path}: {found}")
|
||||||
|
|
||||||
|
# Root units (scripts/*.service) are claimed by exactly one `files:` list.
|
||||||
|
for path in tracked_files():
|
||||||
|
if not (path.startswith("scripts/") and path.endswith(".service")) or is_trash(path):
|
||||||
|
continue
|
||||||
|
found = [s["name"] for s in inv.services if path in s.get("files", [])]
|
||||||
|
if not found:
|
||||||
|
problems.append(f"root unit claimed by no service: {path}")
|
||||||
|
elif len(found) > 1:
|
||||||
|
problems.append(f"root unit claimed by {found}: {path}")
|
||||||
|
|
||||||
|
# Every Network= reference in a unit file names a file on the same host.
|
||||||
|
for (host, unit), path in graph.units.items():
|
||||||
|
for value in _network_values(REPO_ROOT / path):
|
||||||
|
if value.endswith(".container"):
|
||||||
|
target = (host, value[: -len(".container")])
|
||||||
|
if target not in graph.units:
|
||||||
|
problems.append(f"{path}: Network={value} names no unit on {host}")
|
||||||
|
elif value.endswith(".network"):
|
||||||
|
target = (host, value[: -len(".network")])
|
||||||
|
if target not in graph.networks:
|
||||||
|
problems.append(f"{path}: Network={value} names no network on {host}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_config(inv: Inventory) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
for svc in inv.services:
|
||||||
|
label = f"{'+'.join(svc['hosts'])}/{svc['name']}"
|
||||||
|
for key in REQUIRED_KEYS:
|
||||||
|
if key not in svc:
|
||||||
|
problems.append(f"{label}: missing required key {key}")
|
||||||
|
if svc.get("kind") not in KINDS:
|
||||||
|
problems.append(f"{label}: kind {svc.get('kind')!r} not one of {sorted(KINDS)}")
|
||||||
|
unknown = set(svc.get("hosts", [])) - set(inv.hosts)
|
||||||
|
if unknown:
|
||||||
|
problems.append(f"{label}: undeclared hosts: {sorted(unknown)}")
|
||||||
|
if svc.get("units") and not svc.get("unit_dir"):
|
||||||
|
problems.append(f"{label}: units declared without unit_dir")
|
||||||
|
if svc.get("deployed_by") and not inv.any_named(svc["deployed_by"]):
|
||||||
|
problems.append(f"{label}: deployed_by names no service: {svc['deployed_by']}")
|
||||||
|
if (svc.get("health") and svc.get("wait") is None) or \
|
||||||
|
(svc.get("wait") is not None and not svc.get("health")):
|
||||||
|
problems.append(f"{label}: health and wait are a pair")
|
||||||
|
for seam in svc.get("tests", []):
|
||||||
|
if seam not in tracked_files():
|
||||||
|
problems.append(f"{label}: seam does not exist: {seam}")
|
||||||
|
for name in svc.get("dns", []):
|
||||||
|
if not str(name).replace(".", "").replace("-", "").isalnum():
|
||||||
|
problems.append(f"{label}: dns name is not a plain hostname: {name}")
|
||||||
|
if svc.get("kind") == "root-unit" and svc.get("unit_dir"):
|
||||||
|
problems.append(f"{label}: a root unit lives in scripts/, not unit_dir")
|
||||||
|
seen: set[str] = set()
|
||||||
|
for svc in inv.services:
|
||||||
|
key = (svc["name"], tuple(svc["hosts"]))
|
||||||
|
if key in seen:
|
||||||
|
problems.append(f"duplicate inventory entry: {svc['name']} on {svc['hosts']}")
|
||||||
|
seen.add(key)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_drift(inv: Inventory) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
rendered = render_pihole_hosts(inv)
|
||||||
|
hosts_file = REPO_ROOT / "dankclaw" / "pihole" / "addn-hosts"
|
||||||
|
if hosts_file.read_text() != rendered:
|
||||||
|
problems.append("dankclaw/pihole/addn-hosts is not the render of the inventory "
|
||||||
|
"(scripts/service-map.py render pihole-hosts > dankclaw/pihole/addn-hosts)")
|
||||||
|
|
||||||
|
want = sorted(json.dumps(f, sort_keys=True) for f in scrape_facts(inv))
|
||||||
|
have = sorted(json.dumps(f, sort_keys=True) for f in
|
||||||
|
prometheus_facts(REPO_ROOT / "shared" / "prometheus" / "prometheus.yml"))
|
||||||
|
for extra in [f for f in have if f not in want]:
|
||||||
|
problems.append(f"prometheus scrapes a target the inventory does not declare: {extra}")
|
||||||
|
for missing in [f for f in want if f not in have]:
|
||||||
|
problems.append(f"inventory declares a scrape prometheus.yml lacks: {missing}")
|
||||||
|
|
||||||
|
probes_want = sorted(set(probe_facts(inv)))
|
||||||
|
probes_have = homepage_probes(REPO_ROOT / "shared" / "homepage" / "services.json")
|
||||||
|
for extra in [p for p in probes_have if p not in probes_want]:
|
||||||
|
problems.append(f"homepage probes a target the inventory does not declare: {extra}")
|
||||||
|
for missing in [p for p in probes_want if p not in probes_have]:
|
||||||
|
problems.append(f"inventory declares a probe homepage does not list: {missing}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
CHECKS = {
|
||||||
|
"config": lambda inv, graph: check_config(inv),
|
||||||
|
"units": check_units,
|
||||||
|
"ownership": check_ownership,
|
||||||
|
"drift": lambda inv, graph: check_drift(inv),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check(inv: Inventory, names: list[str]) -> list[str]:
|
||||||
|
graph = graph_for(tuple(inv.hosts))
|
||||||
|
problems: list[str] = []
|
||||||
|
for name in names or list(CHECKS):
|
||||||
|
problems.extend(CHECKS[name](inv, graph))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--inventory", type=Path, default=DEFAULT_INVENTORY)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("hosts")
|
||||||
|
p = sub.add_parser("services")
|
||||||
|
p.add_argument("--host")
|
||||||
|
p = sub.add_parser("allows")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("--service", required=True)
|
||||||
|
p = sub.add_parser("plan")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("paths", nargs="+")
|
||||||
|
p = sub.add_parser("seams")
|
||||||
|
p.add_argument("--host")
|
||||||
|
p.add_argument("paths", nargs="+")
|
||||||
|
p = sub.add_parser("owner")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("path")
|
||||||
|
p = sub.add_parser("units")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("--service", required=True)
|
||||||
|
p = sub.add_parser("stack")
|
||||||
|
p.add_argument("--host", required=True)
|
||||||
|
p.add_argument("--service", required=True)
|
||||||
|
p = sub.add_parser("get")
|
||||||
|
p.add_argument("--host")
|
||||||
|
p.add_argument("--service", required=True)
|
||||||
|
p.add_argument("field")
|
||||||
|
p = sub.add_parser("render")
|
||||||
|
p.add_argument("artifact", choices=("pihole-hosts",))
|
||||||
|
p = sub.add_parser("check")
|
||||||
|
p.add_argument("names", nargs="*")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
inv = Inventory.load(args.inventory)
|
||||||
|
|
||||||
|
if args.command == "hosts":
|
||||||
|
print("\n".join(inv.hosts))
|
||||||
|
elif args.command == "services":
|
||||||
|
services = inv.on(args.host) if args.host else inv.services
|
||||||
|
seen: list[str] = []
|
||||||
|
for svc in services:
|
||||||
|
if svc["name"] not in seen:
|
||||||
|
seen.append(svc["name"])
|
||||||
|
print("\n".join(seen))
|
||||||
|
elif args.command == "allows":
|
||||||
|
return 0 if inv.named(args.host, args.service) else 1
|
||||||
|
elif args.command == "plan":
|
||||||
|
print("\n".join(select(inv, args.host, args.paths)))
|
||||||
|
elif args.command == "seams":
|
||||||
|
if args.host is not None:
|
||||||
|
seams = seams_for(inv, args.host, args.paths)
|
||||||
|
else:
|
||||||
|
# No host: the union across every host (the dev machine's
|
||||||
|
# pre-commit mapper has no deployment target).
|
||||||
|
seams = []
|
||||||
|
for host in inv.hosts:
|
||||||
|
for seam in seams_for(inv, host, args.paths):
|
||||||
|
if seam not in seams:
|
||||||
|
seams.append(seam)
|
||||||
|
print("\n".join(seams))
|
||||||
|
elif args.command == "owner":
|
||||||
|
found = select(inv, args.host, [args.path])
|
||||||
|
print(found[-1] if found else "")
|
||||||
|
elif args.command == "units":
|
||||||
|
matches = inv.named(args.host, args.service)
|
||||||
|
if not matches:
|
||||||
|
print(f"no service {args.service} on {args.host}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
for svc in matches:
|
||||||
|
for directory in inv.unit_dirs(svc, args.host):
|
||||||
|
for unit in svc.get("units", []):
|
||||||
|
print(f"{unit} {directory}")
|
||||||
|
elif args.command == "stack":
|
||||||
|
matches = inv.named(args.host, args.service)
|
||||||
|
if not matches:
|
||||||
|
print(f"no service {args.service} on {args.host}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
owner = matches[0].get("deployed_by") or args.service
|
||||||
|
print(owner)
|
||||||
|
for svc in inv.on(args.host):
|
||||||
|
if svc.get("deployed_by") == owner and svc["name"] != owner:
|
||||||
|
print(svc["name"])
|
||||||
|
elif args.command == "get":
|
||||||
|
matches = inv.on(args.host) if args.host else inv.services
|
||||||
|
matches = [s for s in matches if s["name"] == args.service]
|
||||||
|
if not matches:
|
||||||
|
print(f"no service {args.service}" + (f" on {args.host}" if args.host else ""),
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
values = [s.get(args.field) for s in matches if args.field in s]
|
||||||
|
print(json.dumps(values[0] if len(values) == 1 else values))
|
||||||
|
elif args.command == "render":
|
||||||
|
sys.stdout.write(render_pihole_hosts(inv))
|
||||||
|
elif args.command == "check":
|
||||||
|
problems = check(inv, args.names)
|
||||||
|
if problems:
|
||||||
|
for problem in problems:
|
||||||
|
print(f"FAIL: {problem}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("OK: the service inventory is sound (schema, units, ownership, drift)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+57
-190
@@ -62,234 +62,101 @@ declare -a TESTS=()
|
|||||||
add_test() {
|
add_test() {
|
||||||
local test="$1"
|
local test="$1"
|
||||||
local existing
|
local existing
|
||||||
|
MAPPED=1
|
||||||
for existing in "${TESTS[@]:-}"; do
|
for existing in "${TESTS[@]:-}"; do
|
||||||
[ "$existing" != "$test" ] || return 0
|
[ "$existing" != "$test" ] || return 0
|
||||||
done
|
done
|
||||||
TESTS+=("$test")
|
TESTS+=("$test")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The path->test mapping has two layers:
|
||||||
|
# 1. cross-cutting seams that no single service owns (compose validity,
|
||||||
|
# env consistency, the deployer itself, docs, syntax);
|
||||||
|
# 2. the service inventory (shared/services.yml via service-map.py
|
||||||
|
# "seams"): every service the path selects on ANY host contributes its
|
||||||
|
# declared tests. Service seams are the services' own — the former
|
||||||
|
# shotgun fallbacks (every dankclaw quadlet unit ran the open-webui,
|
||||||
|
# pihole, and searxng seams) are retired; the inventory names each
|
||||||
|
# service's real seams, and adding a service is a data edit.
|
||||||
for path in "${CHANGED[@]}"; do
|
for path in "${CHANGED[@]}"; do
|
||||||
|
MAPPED=0
|
||||||
case "$path" in
|
case "$path" in
|
||||||
tests/*.bats)
|
tests/*.bats)
|
||||||
add_test "$path"
|
add_test "$path"
|
||||||
|
continue
|
||||||
;;
|
;;
|
||||||
tests/helpers.bash|tests/run.sh)
|
tests/helpers.bash|tests/run.sh)
|
||||||
die "$path is shared test infrastructure; run ./tests/run.sh explicitly"
|
die "$path is shared test infrastructure; run ./tests/run.sh explicitly"
|
||||||
;;
|
;;
|
||||||
.githooks/*)
|
|
||||||
add_test tests/changed_tests.bats
|
|
||||||
;;
|
|
||||||
dankserver/compose.yaml|dankclaw/compose.yaml)
|
|
||||||
add_test tests/compose_validity.bats
|
|
||||||
add_test tests/env_consistency.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankserver/Caddyfile|dankclaw/Caddyfile)
|
|
||||||
add_test tests/caddyfile.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
tests/e2e/*)
|
tests/e2e/*)
|
||||||
add_test tests/e2e_scaffold.bats
|
add_test tests/e2e_scaffold.bats
|
||||||
|
continue
|
||||||
;;
|
;;
|
||||||
|
.githooks/*)
|
||||||
|
add_test tests/changed_tests.bats
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
# Archive material and host-level facts: a path under any
|
||||||
|
# .AGENTTRASH/ directory is retired-by-policy (the planner plans
|
||||||
|
# nothing from it), udev rules are applied by manual sudo install,
|
||||||
|
# reports are frozen artifacts, and ignore-lists change nothing at
|
||||||
|
# runtime. They select no focused test; without this case they would
|
||||||
|
# trip the mapping gate below.
|
||||||
|
.AGENTTRASH/*|*/.AGENTTRASH/*|dankclaw/udev/*|report/*|.gitignore|.env.example)
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Cross-cutting seams.
|
||||||
|
case "$path" in
|
||||||
dankserver/.env.example|dankclaw/.env.example)
|
dankserver/.env.example|dankclaw/.env.example)
|
||||||
add_test tests/env_consistency.bats
|
add_test tests/env_consistency.bats
|
||||||
;;
|
;;
|
||||||
shared/homepage/*)
|
dankserver/Caddyfile|dankclaw/Caddyfile)
|
||||||
add_test tests/homepage_caddy.bats
|
|
||||||
add_test tests/homepage_config.bats
|
|
||||||
add_test tests/homepage_deploy.bats
|
|
||||||
add_test tests/homepage_pages.bats
|
|
||||||
add_test tests/homepage_checker.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
shared/searxng/*)
|
|
||||||
add_test tests/searxng_limiter.bats
|
|
||||||
add_test tests/searxng_quadlet.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# The five observability containers left compose for Quadlet units on
|
|
||||||
# 2026-09-11. Their seam pins boot persistence and the loopback-only
|
|
||||||
# publish posture that keeps them off every host interface. This case
|
|
||||||
# precedes the dankclaw/*/quadlet/*.container fallback below, which
|
|
||||||
# would otherwise swallow it into unrelated seams.
|
|
||||||
dankclaw/observability/*)
|
|
||||||
add_test tests/observability_quadlet.bats
|
|
||||||
add_test tests/caddyfile.bats
|
add_test tests/caddyfile.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
;;
|
|
||||||
# Caddy left compose.yaml on 2026-08-26 for a quadlet; caddyfile.bats
|
|
||||||
# now asserts against the quadlet itself. Route every change under
|
|
||||||
# dankclaw/caddy/ to both seams (this block must precede the
|
|
||||||
# dankclaw/*.container glob, which would otherwise swallow the unit).
|
|
||||||
dankclaw/caddy/*)
|
|
||||||
add_test tests/caddy_quadlet.bats
|
|
||||||
add_test tests/caddyfile.bats
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankclaw/*.container|dankclaw/*.network)
|
|
||||||
add_test tests/open_webui_quadlet.bats
|
|
||||||
add_test tests/pihole.bats
|
|
||||||
add_test tests/searxng_quadlet.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# Anubis is a quadlet + Caddyfile pair: the chain (public listener ->
|
|
||||||
# anubis:8923 -> internal :8016 listener) spans both, so any change
|
|
||||||
# under the service dir must re-run the caddyfile seam too. This case
|
|
||||||
# precedes the dankclaw/*/quadlet/*.container fallback below, which
|
|
||||||
# would otherwise swallow it into unrelated seams.
|
|
||||||
dankclaw/anubis/*)
|
|
||||||
add_test tests/anubis.bats
|
|
||||||
add_test tests/caddyfile.bats
|
|
||||||
add_test tests/homepage_caddy.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# frank-dragoon is a rootless quadlet again since 2026-09-16 (the
|
|
||||||
# halogen-flash-server restore). Its unit carries the fleet's GPU and
|
|
||||||
# podman posture, so it gets its own seam rather than the generic
|
|
||||||
# fallback below.
|
|
||||||
dankclaw/frank-dragoon/*)
|
|
||||||
add_test tests/frank_dragoon_unit.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
;;
|
|
||||||
# Quadlets live under dankclaw/<svc>/quadlet/ (reorganized out of the
|
|
||||||
# top-level dankclaw/*.container globs above). Map the whole subtree so
|
|
||||||
# changes to the pihole, open-webui, searxng, and gitea quadlets hit
|
|
||||||
# their seam tests. The pihole-specific block must come first: shell
|
|
||||||
# case matches the first pattern, and dankclaw/pihole/quadlet/*.container
|
|
||||||
# also satisfies the dankclaw/*/quadlet/*.container fallback.
|
|
||||||
dankclaw/pihole/*)
|
|
||||||
add_test tests/pihole.bats
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankclaw/*/quadlet/*.container|dankclaw/*/quadlet/*.network)
|
|
||||||
add_test tests/open_webui_quadlet.bats
|
|
||||||
add_test tests/pihole.bats
|
|
||||||
add_test tests/searxng_quadlet.bats
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
;;
|
||||||
scripts/deploy.sh|scripts/deploy-remote.sh|scripts/deploy-plan.py|scripts/deploy-*-quadlet.sh)
|
scripts/deploy.sh|scripts/deploy-remote.sh|scripts/deploy-plan.py|scripts/deploy-*-quadlet.sh)
|
||||||
add_test tests/deployment_flow.bats
|
add_test tests/deployment_flow.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
|
scripts/service-map.py|shared/services.yml)
|
||||||
|
add_test tests/service_inventory.bats
|
||||||
|
add_test tests/deployment_scoping.bats
|
||||||
|
add_test tests/scripts_syntax.bats
|
||||||
|
;;
|
||||||
|
scripts/lib/*.sh)
|
||||||
|
add_test tests/llm_gates.bats
|
||||||
|
add_test tests/scripts_syntax.bats
|
||||||
|
;;
|
||||||
|
serverAppConfigs-docs/*|README.md|AGENTS.md)
|
||||||
|
add_test tests/docs_completeness.bats
|
||||||
|
add_test tests/deployment_scoping.bats
|
||||||
|
;;
|
||||||
scripts/mount-entropy.sh)
|
scripts/mount-entropy.sh)
|
||||||
add_test tests/mount_entropy.bats
|
add_test tests/mount_entropy.bats
|
||||||
;;
|
|
||||||
# Archive material: a path under any .AGENTTRASH/ directory is
|
|
||||||
# retired-by-policy (deploy-plan.py plans nothing from it), so it
|
|
||||||
# selects no focused test. It still needs its own case: without one
|
|
||||||
# the wrapper refuses retirement commits, whose trash moves show up
|
|
||||||
# as additions, with "no focused-test mapping".
|
|
||||||
.AGENTTRASH/*|*/.AGENTTRASH/*)
|
|
||||||
;;
|
|
||||||
# frank-archon is llama.cpp (the unsloth b11030-mix prebuilt) on the
|
|
||||||
# two R9700s as a ROOT unit — the 2026-09-26 swap that replaced the
|
|
||||||
# Lucebox DS4.1 quadlet on :7770. Its env, unit and serve script all
|
|
||||||
# live in scripts/, which is why this case has to precede the generic
|
|
||||||
# scripts/*.service and scripts/* fallbacks below.
|
|
||||||
scripts/frank-archon.*|scripts/serve-frank-archon.sh)
|
|
||||||
add_test tests/frank_archon_unit.bats
|
|
||||||
add_test tests/llm_units.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
# frank-dragoon's chat template lives in scripts/ (moved there on
|
old/scripts/*)
|
||||||
# 2026-09-26 when its old home was retired): a template edit changes
|
|
||||||
# what the live endpoint renders, so it selects the large unit seam.
|
|
||||||
scripts/qwen-sharp-v22.4.0.jinja)
|
|
||||||
add_test tests/frank_dragoon_unit.bats
|
|
||||||
;;
|
|
||||||
# frank-zealot is llama.cpp on the 3090 as a ROOT unit: a native CUDA
|
|
||||||
# binary, so no generated quadlet is involved. Its env, unit and serve
|
|
||||||
# script all live in scripts/, which is why this case has to precede
|
|
||||||
# the generic scripts/*.service and scripts/* fallbacks below.
|
|
||||||
scripts/frank-zealot.*|scripts/serve-frank-zealot.sh)
|
|
||||||
add_test tests/frank_zealot_unit.bats
|
|
||||||
add_test tests/llm_units.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
scripts/*.service)
|
|
||||||
add_test tests/llm_units.bats
|
|
||||||
add_test tests/scripts_syntax.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
scripts/*)
|
scripts/*)
|
||||||
add_test tests/scripts_syntax.bats
|
add_test tests/scripts_syntax.bats
|
||||||
;;
|
;;
|
||||||
serverAppConfigs-docs/*)
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankclaw/hermes/*)
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
;;
|
|
||||||
README.md|AGENTS.md)
|
|
||||||
# Project guidance changes can affect the documented deployment
|
|
||||||
# contract; validate the docs vault and scoped deployment seams.
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankclaw/comfyui/*)
|
|
||||||
add_test tests/docs_completeness.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# loki and alloy images are built from this repo's Containerfiles; the
|
|
||||||
# loki one carries the healthcheck command (see its comment), so both
|
|
||||||
# seams read it.
|
|
||||||
dankclaw/loki/*|dankclaw/alloy/*)
|
|
||||||
add_test tests/observability_quadlet.bats
|
|
||||||
add_test tests/caddyfile.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
dankclaw/udev/*)
|
|
||||||
# Host-level udev rules are applied by manual sudo install + udevadm
|
|
||||||
# trigger, not dispatched as a service; nothing here is bats-testable.
|
|
||||||
;;
|
|
||||||
.AGENTTRASH/*)
|
|
||||||
# Trash moves are not testable; skip.
|
|
||||||
;;
|
|
||||||
.gitignore|.env.example)
|
|
||||||
# Ignore/ignore-list changes change nothing at runtime; not testable.
|
|
||||||
;;
|
|
||||||
old/scripts/*|old/scripts/**)
|
|
||||||
add_test tests/scripts_syntax.bats
|
|
||||||
;;
|
|
||||||
# Cutover-era dankserver quadlet units (romm trio, jellyfin, calibre-web,
|
|
||||||
# copyparty, plex, tautulli): the seam checks boot-persistence +
|
|
||||||
# v1-generator contract; deploy-flow seams guard the deployer routing.
|
|
||||||
dankserver/romm/*|dankserver/jellyfin/*|dankserver/calibre-web/*|dankserver/copyparty/*|dankserver/plex/*|dankserver/tautulli/*)
|
|
||||||
add_test tests/dankserver_quadlet.bats
|
|
||||||
add_test tests/deployment_flow.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# FrankBook-Pro units live inside the podman machine VM; the seam
|
|
||||||
# pins boot persistence, the gluetun netns invariant, and the
|
|
||||||
# plan-locked volume layout. Deployer changes route here too.
|
|
||||||
frankbook-pro/*)
|
|
||||||
add_test tests/frankbook_quadlet.bats
|
|
||||||
add_test tests/deployment_flow.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
# Seerr unit on dankclaw: boot persistence, loopback-only port
|
|
||||||
# posture, and the deployer routing that installs it.
|
|
||||||
dankclaw/seerr/*)
|
|
||||||
add_test tests/seerr_quadlet.bats
|
|
||||||
add_test tests/deployment_flow.bats
|
|
||||||
add_test tests/deployment_scoping.bats
|
|
||||||
;;
|
|
||||||
report/*)
|
|
||||||
# Frozen audit reports are point-in-time artifacts; not testable.
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "no focused-test mapping for '$path'; add one before committing"
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# The inventory's declared seams for every service this path selects.
|
||||||
|
while IFS= read -r seam; do
|
||||||
|
[ -n "$seam" ] && add_test "$seam"
|
||||||
|
done < <(python3 "$REPO_ROOT/scripts/service-map.py" seams -- "$path" 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Mapping gate: a path that selected no test at all — no service seam,
|
||||||
|
# no cross-cutting rule — is an unmapped area of the repo; refuse to
|
||||||
|
# commit it silently (the old catch-all `die`).
|
||||||
|
if [ "$MAPPED" -eq 0 ]; then
|
||||||
|
die "no focused-test mapping for '$path'; map it in the inventory (shared/services.yml) before committing"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Mapped-but-untestable paths (udev rules, trash moves) legitimately produce an
|
# Mapped-but-untestable paths (udev rules, trash moves) legitimately produce an
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
title: Service Inventory
|
||||||
|
description: shared/services.yml is the one inventory of deployable services; scripts/service-map.py is its only reader. The planner, both deploy allow-lists, the changed-test seam mapper, the Pi-hole hosts render, the Prometheus scrape facts, and the homepage probes all derive from it.
|
||||||
|
tags: [deployment, inventory, planner, quadlet, prometheus, pihole, homepage]
|
||||||
|
timestamp: 2026-10-13T00:00:00-07:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# Service Inventory
|
||||||
|
|
||||||
|
Before 2026-10-13 the same facts lived in four hand-maintained lists — the
|
||||||
|
planner's path rules (`deploy-plan.py`), the `--service` allow-list in
|
||||||
|
`deploy.sh`, the near-copy in `deploy-remote.sh`, and the changed-test mapper
|
||||||
|
(`test-changed.sh`) — plus per-fact copies in `prometheus.yml`,
|
||||||
|
`addn-hosts`, and `services.json`. They drifted on every swap (the planner
|
||||||
|
still mapped the retired `qwen-sharp` template to frank-dragoon; the allow-list
|
||||||
|
still listed the *arr stack under Dankserver after it moved to FrankBook-Pro).
|
||||||
|
Now there is **one inventory and one reader**:
|
||||||
|
|
||||||
|
- `shared/services.yml` — the data: every deployable service, its hosts, unit
|
||||||
|
dir(s), unit files, path claims, tests, scrape/DNS/probe facts, aliases.
|
||||||
|
- `scripts/service-map.py` — the only reader. CLI subcommands:
|
||||||
|
`hosts`, `services`, `allows`, `plan`, `seams`, `owner`, `units`, `stack`,
|
||||||
|
`get`, `render`, `check`.
|
||||||
|
|
||||||
|
## 1. Consumers
|
||||||
|
|
||||||
|
|Consumer|Call|
|
||||||
|
|---|---|
|
||||||
|
|`scripts/deploy-plan.py`|thin wrapper → `plan --host TARGET [paths...]` (keeps the `--repo/--base/--head` and `--paths` CLIs)|
|
||||||
|
|`scripts/deploy.sh` (`--service` gate)|`allows --host T --service S`|
|
||||||
|
|`scripts/deploy-remote.sh` (`service_is_allowed`)|same|
|
||||||
|
|`scripts/test-changed.sh`|`seams [paths...]` — a service's declared `tests` are its seams|
|
||||||
|
|`dankclaw/pihole/addn-hosts`|`render pihole-hosts` — the tracked file is a rendered artifact|
|
||||||
|
|Prometheus scrape jobs, LAN DNS, homepage probes|`check` compares the tracked configs against the inventory's `scrape`/`dns`/`probe` fields (drift gate)|
|
||||||
|
|
||||||
|
Selection rules the engine encodes (see `tests/fixtures/service-plan-golden.tsv`,
|
||||||
|
the pinned characterization of every tracked path × every target):
|
||||||
|
|
||||||
|
- **Host scoping** — a host's top-level directory can only select that host's
|
||||||
|
services. `frankbook-pro/**` plans nothing on Dankserver (the old planner
|
||||||
|
had a stale Dankserver *arr list).
|
||||||
|
- **Quadlet coupling graph** — `Network=x.container` rides x's netns;
|
||||||
|
`Network=y.network` joins a shared network. Changing the parent (or the
|
||||||
|
network file) selects every rider transitively, in their own directories
|
||||||
|
(`dankclaw/searxng/quadlet/gluetun.container` → gluetun + searxng + marreta;
|
||||||
|
`frankbook-pro/gluetun/quadlet/gluetun.container` → the whole arr stack).
|
||||||
|
- **Stacks** — a service's `stack` members deploy and restart as one handler
|
||||||
|
(romm trio, hermes trio).
|
||||||
|
- **Retirement** — removing a service from `services.yml` removes it from the
|
||||||
|
planner, both allow-lists, and the seam mapper in one data edit; a pending
|
||||||
|
deploy-state entry for it is auto-triaged (see [[Deploy State Triage]]).
|
||||||
|
|
||||||
|
## 2. Integrity gates (`check`)
|
||||||
|
|
||||||
|
`python3 scripts/service-map.py check` runs, and
|
||||||
|
`tests/service_inventory.bats` pins:
|
||||||
|
|
||||||
|
- **schema** — required fields per entry, known keys only;
|
||||||
|
- **units** — every unit file named by an entry exists under its `unit_dir`
|
||||||
|
on the right host; each unit name is claimed by exactly one service per host;
|
||||||
|
- **ownership** — every tracked quadlet `.container`/`.network` file is claimed
|
||||||
|
by exactly one service (trash excluded);
|
||||||
|
- **paths** — every claimed `files`/`dirs`/`retires` glob matches ≥1 tracked file;
|
||||||
|
- **compose** — the container names in each host's `compose.yaml` have exactly
|
||||||
|
one quadlet owner (the [[Quadlet Lifecycle]] two-owners trap);
|
||||||
|
- **drift** — `addn-hosts` equals its render; the Prometheus job set, the DNS
|
||||||
|
names, and the homepage probe ports equal the inventory's fields.
|
||||||
|
|
||||||
|
## 3. Adding, moving, retiring a service
|
||||||
|
|
||||||
|
- **Add**: append an entry (name, hosts, kind, unit_dir, units, paths, tests,
|
||||||
|
…), add the quadlet file, run `check` — `plan`/`allows`/`seams` pick it up
|
||||||
|
with zero script edits.
|
||||||
|
- **Move between hosts**: edit `hosts` (and per-host `unit_dir`/`probe` maps).
|
||||||
|
- **Retire**: delete the entry, move files to `.AGENTTRASH/` (see
|
||||||
|
[[Testing Policy]] for the seam the mapper applies to trash paths).
|
||||||
|
|
||||||
|
Field notes: `alias` carries deployable names that differ from the service
|
||||||
|
name (`calibre-web` deploys the unit `calibre-server`); `probe` for multi-host
|
||||||
|
services is a `{host: port}` map; `dependents` names services whose config
|
||||||
|
embeds a reference to this one (config-only coupling — a `.env` edit selects
|
||||||
|
both, a unit edit selects the graph closure).
|
||||||
|
|
||||||
|
## 4. Known cutover deltas (2026-10-13)
|
||||||
|
|
||||||
|
The 17 deltas against the old planner are all in these classes: anubis paths
|
||||||
|
now select `anubis` (the old planner had no rule at all); cross-host paths
|
||||||
|
select nothing; `scripts/qwen-sharp-v22.4.0.jinja` (retired template) plans
|
||||||
|
nothing while `scripts/qwen38-froggeric-v22.5.jinja` (the live frank-archon
|
||||||
|
template, previously unmapped) plans frank-archon;
|
||||||
|
`dankserver/romm/romm.container` (top-level quadlet dir the old unit-file rule
|
||||||
|
missed) now plans the romm trio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Related: [[Deployment Flow]], [[Testing Policy]], [[Quadlet Lifecycle]],
|
||||||
|
[[Container Network Namespaces]], [[Deploy State Triage]]
|
||||||
@@ -28,6 +28,7 @@ covers both workloads).
|
|||||||
- [[Network Architecture]] — Caddy reverse proxy, hostname routing, Gluetun VPN path
|
- [[Network Architecture]] — Caddy reverse proxy, hostname routing, Gluetun VPN path
|
||||||
- [[Storage Policy]] — bind mounts, data locations, ownership
|
- [[Storage Policy]] — bind mounts, data locations, ownership
|
||||||
- [[Deployment Flow]] — service-scoped git deploy, target map, and health checks
|
- [[Deployment Flow]] — service-scoped git deploy, target map, and health checks
|
||||||
|
- [[Service Inventory]] — shared/services.yml, the one inventory of deployable services, and service-map.py
|
||||||
- [[Testing Policy]] — the test seams and how to run them
|
- [[Testing Policy]] — the test seams and how to run them
|
||||||
- [[E2E Suite]] — the Playwright suite that drives the live guard chain
|
- [[E2E Suite]] — the Playwright suite that drives the live guard chain
|
||||||
- [[Container Network Namespaces]] — which local services are addressable from each Podman network namespace on Dankclaw
|
- [[Container Network Namespaces]] — which local services are addressable from each Podman network namespace on Dankclaw
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# services.yml — THE service inventory.
|
||||||
|
#
|
||||||
|
# One entry per deployable service, in canonical deploy order (dependencies
|
||||||
|
# before consumers, the LLM fleet last). Four hand-maintained lists used to
|
||||||
|
# agree with each other by discipline alone; every one of them now READS this
|
||||||
|
# file:
|
||||||
|
#
|
||||||
|
# scripts/deploy-plan.py which services a changed path selects
|
||||||
|
# scripts/deploy-remote.sh which services a target accepts (allow-list)
|
||||||
|
# scripts/test-changed.sh which Bats seams a changed path must re-run
|
||||||
|
# scripts/service-map.py renderers + drift gates (`check`)
|
||||||
|
#
|
||||||
|
# `service-map.py check` is the referee: every quadlet unit file and every
|
||||||
|
# root unit in the tree is claimed by exactly one entry, every repo file is
|
||||||
|
# owned by a service or listed in `unowned:` with a reason, every seam exists,
|
||||||
|
# the rendered Pi-hole hosts file equals the render, and Prometheus' scrape
|
||||||
|
# targets plus the homepage probes equal the `scrape:` / `probe:` facts.
|
||||||
|
#
|
||||||
|
# --- fields ------------------------------------------------------------------
|
||||||
|
# name the deployer's service word == the systemd unit name for
|
||||||
|
# quadlet / root-unit / vm-quadlet kinds
|
||||||
|
# hosts targets that accept it (the deploy-remote.sh allow-list)
|
||||||
|
# kind quadlet rootless user unit from a .container file
|
||||||
|
# root-unit systemd system unit symlinked out of scripts/
|
||||||
|
# vm-quadlet quadlet inside the FrankBook-Pro podman machine
|
||||||
|
# host a host-level script/timer, no container
|
||||||
|
# deployed_by this service deploys as part of another deployable's handler
|
||||||
|
# (the trio/pair shape). It has no dirs/files of its own; the
|
||||||
|
# owner names the deployable whose handler installs it.
|
||||||
|
# unit_dir repo dir holding <unit>.container — a scalar, or a per-host map
|
||||||
|
# when one service carries its unit on two hosts (node-exporter)
|
||||||
|
# units container names this deployable owns (netns coupling is derived
|
||||||
|
# from the `Network=` lines in those files, never declared here)
|
||||||
|
# dirs repo dirs whose every file selects this service
|
||||||
|
# files exact repo files that select this service (root-unit trios,
|
||||||
|
# vendored templates, host scripts installed by this handler)
|
||||||
|
# retires files whose DELETION selects this service (the unit the
|
||||||
|
# handler must remove before it can claim the ports)
|
||||||
|
# only Dankserver's media stack still has one)
|
||||||
|
# health the deployer's readiness URL; `wait` its 2 s poll budget
|
||||||
|
# scrape Prometheus jobs: {job, target, metrics_path?, labels?}
|
||||||
|
# dns names this service answers at (rendered into the Pi-hole
|
||||||
|
# addn-hosts; every vhost resolves at the Dankclaw Caddy edge)
|
||||||
|
# probe host:port the homepage checker dials (per-host map if on 2 hosts)
|
||||||
|
# tests the Bats seams a change to this service must re-run
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
hosts:
|
||||||
|
dankclaw:
|
||||||
|
address: 192.168.0.143
|
||||||
|
# Caddy is consolidated on Dankclaw: it fronts every service on every
|
||||||
|
# host, so every DNS name in the fleet resolves here.
|
||||||
|
dns_edge: 192.168.0.143
|
||||||
|
dankserver:
|
||||||
|
address: 192.168.0.235
|
||||||
|
frankbook-pro:
|
||||||
|
address: 192.168.0.112
|
||||||
|
|
||||||
|
# Repo files no service owns. Each entry is a fact about the tree, not a
|
||||||
|
# fallback: the census gate fails on anything unowned that is NOT listed, so
|
||||||
|
# retirement work shrinks this list and a new stray file cannot slip in.
|
||||||
|
unowned:
|
||||||
|
shared/services.yml: the inventory this tool reads
|
||||||
|
scripts: repo tooling (build, fetch, bench, deploy, test); the deployers'
|
||||||
|
files are claimed by name in the service entries
|
||||||
|
autoresearch.sh: bench scratch at the repo root (retirement pending)
|
||||||
|
echo_prompt.txt: bench fixture at the repo root (retirement pending)
|
||||||
|
echo_request.json: bench fixture at the repo root (retirement pending)
|
||||||
|
prose_prompt.txt: bench fixture at the repo root (retirement pending)
|
||||||
|
prose_request.json: bench fixture at the repo root (retirement pending)
|
||||||
|
dankclaw/apply_qwen21_patch.py: retired patch script (its target runtime is gone)
|
||||||
|
dankclaw/comfyui-start.sh: superseded by the comfyui quadlet + Containerfile
|
||||||
|
dankclaw/udev: host udev rules, applied by manual sudo install + udevadm trigger
|
||||||
|
tests: the seams themselves
|
||||||
|
report: frozen point-in-time audit reports
|
||||||
|
old: retired scripts, reference only
|
||||||
|
.githooks: the commit hooks that run the seams
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- Search stack: one netns family, gluetun owns it ---------------------
|
||||||
|
- name: gluetun
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/searxng/quadlet
|
||||||
|
units: [gluetun]
|
||||||
|
tests: [tests/searxng_quadlet.bats]
|
||||||
|
# The tunnel's WireGuard bind — the homepage card for the tunnel itself.
|
||||||
|
probe: 127.0.0.1:51820
|
||||||
|
|
||||||
|
- name: searxng-valkey
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/searxng/quadlet
|
||||||
|
units: [searxng-valkey]
|
||||||
|
# The app-level law, not a network one: deploy_searxng_valkey restarts
|
||||||
|
# SearXNG after its cache, so the cache's files select both.
|
||||||
|
dependents: [searxng]
|
||||||
|
tests: [tests/searxng_quadlet.bats]
|
||||||
|
probe: 127.0.0.1:6379
|
||||||
|
|
||||||
|
- name: searxng
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/searxng/quadlet
|
||||||
|
units: [searxng]
|
||||||
|
dirs: [shared/searxng]
|
||||||
|
tests: [tests/searxng_limiter.bats, tests/searxng_quadlet.bats]
|
||||||
|
scrape:
|
||||||
|
- job: services
|
||||||
|
target: 192.168.0.143:8081
|
||||||
|
metrics_path: /
|
||||||
|
dns: [search.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:8081
|
||||||
|
|
||||||
|
- name: marreta
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/marreta/quadlet
|
||||||
|
units: [marreta, marreta-browser]
|
||||||
|
tests: [tests/marreta_quadlet.bats]
|
||||||
|
dns: [marreta.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:8082
|
||||||
|
|
||||||
|
# --- DNS ------------------------------------------------------------------
|
||||||
|
- name: pihole
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/pihole/quadlet
|
||||||
|
units: [pihole]
|
||||||
|
dirs: [dankclaw/pihole]
|
||||||
|
tests: [tests/pihole.bats, tests/docs_completeness.bats]
|
||||||
|
dns: [pihole.franksdank.cloud]
|
||||||
|
probe: 192.168.0.143:8083
|
||||||
|
|
||||||
|
# --- Chat front end -------------------------------------------------------
|
||||||
|
- name: open-webui
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/open-webui/quadlet
|
||||||
|
units: [open-webui]
|
||||||
|
tests: [tests/open_webui_quadlet.bats, tests/caddyfile.bats]
|
||||||
|
scrape:
|
||||||
|
- job: services
|
||||||
|
target: 192.168.0.143:3005
|
||||||
|
metrics_path: /
|
||||||
|
dns: [ai.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:3005
|
||||||
|
|
||||||
|
# --- Git hosting ----------------------------------------------------------
|
||||||
|
- name: gitea-postgres
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/gitea/quadlet
|
||||||
|
units: [gitea-postgres]
|
||||||
|
deployed_by: gitea-server
|
||||||
|
tests: [tests/caddyfile.bats, tests/docs_completeness.bats]
|
||||||
|
probe: 127.0.0.1:5432
|
||||||
|
|
||||||
|
- name: gitea-server
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/gitea/quadlet
|
||||||
|
units: [gitea-server]
|
||||||
|
tests: [tests/caddyfile.bats, tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: gitea
|
||||||
|
target: 192.168.0.143:3000
|
||||||
|
metrics_path: /metrics
|
||||||
|
dns: [gitea.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:3000
|
||||||
|
|
||||||
|
# --- Image generation -----------------------------------------------------
|
||||||
|
- name: comfyui
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/comfyui/quadlet
|
||||||
|
units: [comfyui]
|
||||||
|
dirs: [dankclaw/comfyui]
|
||||||
|
files: [dankclaw/Containerfile.comfyui]
|
||||||
|
health: http://127.0.0.1:8188/
|
||||||
|
wait: 300
|
||||||
|
tests: [tests/comfyui_rocm.bats, tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: comfyui
|
||||||
|
target: comfyui:8188
|
||||||
|
metrics_path: /
|
||||||
|
dns: [comfyui.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:8188
|
||||||
|
|
||||||
|
# --- Requests -------------------------------------------------------------
|
||||||
|
- name: seerr
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/seerr/quadlet
|
||||||
|
units: [seerr]
|
||||||
|
tests: [tests/seerr_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [seerr.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:5055
|
||||||
|
|
||||||
|
# --- Agent stack: one handler, four containers ----------------------------
|
||||||
|
- name: hermes
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/hermes/quadlet
|
||||||
|
units: [hermes, hermes-dashboard, hermes-webui, chrome-devtools-protocol]
|
||||||
|
dirs: [dankclaw/hermes]
|
||||||
|
tests: [tests/hermes_webui.bats, tests/docs_completeness.bats]
|
||||||
|
dns: [hermes.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:8787
|
||||||
|
|
||||||
|
- name: hermes-dashboard
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
deployed_by: hermes
|
||||||
|
tests: [tests/hermes_webui.bats, tests/docs_completeness.bats]
|
||||||
|
dns: [hermesadmin.franksdank.cloud]
|
||||||
|
probe: 192.168.0.143:9119
|
||||||
|
|
||||||
|
- name: hermes-webui
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
deployed_by: hermes
|
||||||
|
tests: [tests/hermes_webui.bats, tests/docs_completeness.bats]
|
||||||
|
|
||||||
|
# --- Observability --------------------------------------------------------
|
||||||
|
- name: prometheus
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/observability/quadlet
|
||||||
|
units: [prometheus]
|
||||||
|
dirs: [shared/prometheus]
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: prometheus
|
||||||
|
target: localhost:9090
|
||||||
|
dns: [prometheus.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:9090
|
||||||
|
|
||||||
|
- name: grafana
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/observability/quadlet
|
||||||
|
units: [grafana]
|
||||||
|
dirs: [shared/grafana]
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: grafana
|
||||||
|
target: grafana:3000
|
||||||
|
metrics_path: /metrics
|
||||||
|
dns: [grafana.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:3002
|
||||||
|
|
||||||
|
- name: loki
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/observability/quadlet
|
||||||
|
units: [loki]
|
||||||
|
dirs: [dankclaw/loki, shared/loki]
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: loki
|
||||||
|
target: loki:3100
|
||||||
|
metrics_path: /metrics
|
||||||
|
dns: [loki.franksdank.cloud]
|
||||||
|
probe: 127.0.0.1:3100
|
||||||
|
|
||||||
|
- name: alloy
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/observability/quadlet
|
||||||
|
units: [alloy]
|
||||||
|
dirs: [dankclaw/alloy, shared/alloy]
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
probe: 127.0.0.1:12345
|
||||||
|
|
||||||
|
- name: node-exporter
|
||||||
|
# One service on two hosts: the same exporter, its own unit per host.
|
||||||
|
hosts: [dankclaw, dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir:
|
||||||
|
dankclaw: dankclaw/observability/quadlet
|
||||||
|
dankserver: dankserver/observability/quadlet
|
||||||
|
units: [node-exporter]
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: node-dankclaw
|
||||||
|
target: host.containers.internal:9100
|
||||||
|
- job: node-dankserver
|
||||||
|
target: 192.168.0.235:9100
|
||||||
|
probe: 127.0.0.1:9100
|
||||||
|
|
||||||
|
- name: alertmanager
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/observability/quadlet
|
||||||
|
units: [alertmanager]
|
||||||
|
# The Alertmanager config ships from Prometheus' directory, and the Signal
|
||||||
|
# relay is a HOST unit this handler installs: a change to either reaches
|
||||||
|
# the machine only through an alertmanager deploy.
|
||||||
|
files:
|
||||||
|
- shared/prometheus/alertmanager.yml
|
||||||
|
- scripts/signal-relay.py
|
||||||
|
- scripts/signal-relay.service
|
||||||
|
tests: [tests/observability_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/docs_completeness.bats]
|
||||||
|
probe: 127.0.0.1:9093
|
||||||
|
|
||||||
|
# --- Identity -------------------------------------------------------------
|
||||||
|
- name: authentik-postgres
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/authentik/quadlet
|
||||||
|
units: [authentik-postgres]
|
||||||
|
deployed_by: authentik-server
|
||||||
|
tests: [tests/authentik.bats]
|
||||||
|
|
||||||
|
- name: authentik-server
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/authentik/quadlet
|
||||||
|
units: [authentik-server]
|
||||||
|
tests: [tests/authentik.bats]
|
||||||
|
dns: [auth.franksdank.cloud]
|
||||||
|
|
||||||
|
- name: authentik-worker
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/authentik/quadlet
|
||||||
|
units: [authentik-worker]
|
||||||
|
deployed_by: authentik-server
|
||||||
|
tests: [tests/authentik.bats]
|
||||||
|
|
||||||
|
- name: authentik-outpost
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/authentik/quadlet
|
||||||
|
units: [authentik-outpost]
|
||||||
|
tests: [tests/authentik.bats]
|
||||||
|
|
||||||
|
- name: authentik-outpost-ldap
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/authentik/quadlet
|
||||||
|
units: [authentik-outpost-ldap]
|
||||||
|
tests: [tests/authentik.bats]
|
||||||
|
|
||||||
|
# --- The auth chain's edge, then the edge itself --------------------------
|
||||||
|
- name: anubis
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/anubis/quadlet
|
||||||
|
units: [anubis]
|
||||||
|
dirs: [dankclaw/anubis]
|
||||||
|
tests: [tests/anubis.bats, tests/caddyfile.bats, tests/homepage_caddy.bats]
|
||||||
|
|
||||||
|
- name: caddy
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankclaw/caddy/quadlet
|
||||||
|
units: [caddy]
|
||||||
|
dirs: [dankclaw/caddy]
|
||||||
|
files: [dankclaw/Caddyfile]
|
||||||
|
# Retiring the old split proxy selects Caddy: it must remove the installed
|
||||||
|
# unit and container before it can claim 80/443.
|
||||||
|
retires: [dankclaw/gitea/quadlet/gitea-caddy.container]
|
||||||
|
tests: [tests/caddy_quadlet.bats, tests/caddyfile.bats,
|
||||||
|
tests/homepage_caddy.bats, tests/docs_completeness.bats]
|
||||||
|
dns:
|
||||||
|
- franksdank.cloud
|
||||||
|
- www.franksdank.cloud
|
||||||
|
- dankclaw.franksdank.cloud
|
||||||
|
- dankserver.franksdank.cloud
|
||||||
|
probe: 127.0.0.1:80
|
||||||
|
|
||||||
|
- name: homepage
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: host
|
||||||
|
dirs: [shared/homepage]
|
||||||
|
files:
|
||||||
|
- scripts/homepage-status.py
|
||||||
|
- scripts/homepage-status.service
|
||||||
|
- scripts/homepage-status.timer
|
||||||
|
tests: [tests/homepage_caddy.bats, tests/homepage_config.bats,
|
||||||
|
tests/homepage_deploy.bats, tests/homepage_pages.bats,
|
||||||
|
tests/homepage_checker.bats]
|
||||||
|
|
||||||
|
# --- Dankserver media stack ----------------------------------------------
|
||||||
|
# The romm trio is one handler and one directory: every file in it selects
|
||||||
|
# all three, exactly as the deployer restarts all three.
|
||||||
|
- name: romm-mariadb
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/romm/quadlet
|
||||||
|
units: [romm-mariadb]
|
||||||
|
deployed_by: romm
|
||||||
|
dirs: [dankserver/romm]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
probe: 192.168.0.235:3306
|
||||||
|
|
||||||
|
- name: romm-redis
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/romm/quadlet
|
||||||
|
units: [romm-redis]
|
||||||
|
deployed_by: romm
|
||||||
|
dirs: [dankserver/romm]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
|
||||||
|
- name: romm
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/romm/quadlet
|
||||||
|
units: [romm]
|
||||||
|
dirs: [dankserver/romm]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [romm.franksdank.cloud]
|
||||||
|
probe: 192.168.0.235:8085
|
||||||
|
|
||||||
|
- name: copyparty
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/copyparty/quadlet
|
||||||
|
units: [copyparty]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [files.franksdank.cloud]
|
||||||
|
probe: 192.168.0.235:3923
|
||||||
|
|
||||||
|
- name: jellyfin
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/jellyfin/quadlet
|
||||||
|
units: [jellyfin]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [jelly.franksdank.cloud]
|
||||||
|
probe: 192.168.0.235:8096
|
||||||
|
|
||||||
|
- name: calibre-web
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/calibre-web/quadlet
|
||||||
|
units: [calibre-web]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [books.franksdank.cloud]
|
||||||
|
probe: 192.168.0.235:8083
|
||||||
|
|
||||||
|
- name: calibre-server
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/calibre-server/quadlet
|
||||||
|
units: [calibre-server]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
|
||||||
|
- name: plex
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/plex/quadlet
|
||||||
|
units: [plex]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [plex.franksdank.cloud, plex.franksdank.casa]
|
||||||
|
probe: 192.168.0.235:32400
|
||||||
|
|
||||||
|
- name: tautulli
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: quadlet
|
||||||
|
unit_dir: dankserver/tautulli/quadlet
|
||||||
|
units: [tautulli]
|
||||||
|
tests: [tests/dankserver_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
probe: 192.168.0.235:8181
|
||||||
|
|
||||||
|
# --- FrankBook-Pro: the *arr stack inside the podman machine --------------
|
||||||
|
# Every container rides gluetun's netns, and that coupling is DERIVED: each
|
||||||
|
# arr's unit file says `Network=gluetun.container`, so a gluetun unit change
|
||||||
|
# selects the whole stack with no declaration here.
|
||||||
|
- name: gluetun
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/gluetun/quadlet
|
||||||
|
units: [gluetun]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
|
||||||
|
- name: prowlarr
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/prowlarr/quadlet
|
||||||
|
units: [prowlarr]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [prowlarr.franksdank.cloud]
|
||||||
|
probe: 192.168.0.112:9696
|
||||||
|
|
||||||
|
- name: qbittorrent
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/qbittorrent/quadlet
|
||||||
|
units: [qbittorrent]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [qbittorrent.franksdank.cloud]
|
||||||
|
probe: 192.168.0.112:8080
|
||||||
|
|
||||||
|
- name: radarr
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/radarr/quadlet
|
||||||
|
units: [radarr]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [radarr.franksdank.cloud]
|
||||||
|
probe: 192.168.0.112:7878
|
||||||
|
|
||||||
|
- name: sonarr
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/sonarr/quadlet
|
||||||
|
units: [sonarr]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
probe: 192.168.0.112:8989
|
||||||
|
|
||||||
|
- name: lidarr
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/lidarr/quadlet
|
||||||
|
units: [lidarr]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [lidarr.franksdank.cloud]
|
||||||
|
probe: 192.168.0.112:8686
|
||||||
|
|
||||||
|
- name: readarr
|
||||||
|
hosts: [frankbook-pro]
|
||||||
|
kind: vm-quadlet
|
||||||
|
unit_dir: frankbook-pro/readarr/quadlet
|
||||||
|
units: [readarr]
|
||||||
|
tests: [tests/frankbook_quadlet.bats, tests/deployment_flow.bats]
|
||||||
|
dns: [readarr.franksdank.cloud]
|
||||||
|
probe: 192.168.0.112:8787
|
||||||
|
|
||||||
|
# --- The LLM fleet (last: never implied by a generic change) --------------
|
||||||
|
- name: frank-dragoon
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: root-unit
|
||||||
|
files:
|
||||||
|
- scripts/frank-dragoon.env
|
||||||
|
- scripts/frank-dragoon.service
|
||||||
|
- scripts/serve-frank-dragoon.sh
|
||||||
|
health: http://127.0.0.1:7770/health
|
||||||
|
wait: 1200
|
||||||
|
tests: [tests/frank_dragoon_unit.bats, tests/llm_units.bats,
|
||||||
|
tests/llm_deploy_gates.bats, tests/llm_secrets.bats,
|
||||||
|
tests/scripts_syntax.bats, tests/docs_completeness.bats]
|
||||||
|
scrape:
|
||||||
|
- job: frank-dragoon
|
||||||
|
target: host.containers.internal:7770
|
||||||
|
metrics_path: /metrics
|
||||||
|
labels: {service: frank-dragoon}
|
||||||
|
probe: 127.0.0.1:7770
|
||||||
|
|
||||||
|
- name: frank-zealot
|
||||||
|
hosts: [dankclaw]
|
||||||
|
kind: root-unit
|
||||||
|
files:
|
||||||
|
- scripts/frank-zealot.env
|
||||||
|
- scripts/frank-zealot.service
|
||||||
|
- scripts/serve-frank-zealot.sh
|
||||||
|
health: http://127.0.0.1:7771/health
|
||||||
|
wait: 900
|
||||||
|
tests: [tests/frank_zealot_unit.bats, tests/llm_units.bats,
|
||||||
|
tests/llm_deploy_gates.bats, tests/llm_secrets.bats,
|
||||||
|
tests/scripts_syntax.bats]
|
||||||
|
scrape:
|
||||||
|
- job: frank-zealot
|
||||||
|
target: host.containers.internal:7771
|
||||||
|
metrics_path: /metrics
|
||||||
|
labels: {service: frank-zealot}
|
||||||
|
probe: 127.0.0.1:7771
|
||||||
|
|
||||||
|
- name: frank-archon
|
||||||
|
hosts: [dankserver]
|
||||||
|
kind: root-unit
|
||||||
|
files:
|
||||||
|
- scripts/frank-archon.env
|
||||||
|
- scripts/frank-archon.service
|
||||||
|
- scripts/serve-frank-archon.sh
|
||||||
|
# The vendored chat template the engine renders with.
|
||||||
|
- scripts/qwen38-froggeric-v22.5.jinja
|
||||||
|
health: http://127.0.0.1:7770/health
|
||||||
|
wait: 900
|
||||||
|
tests: [tests/frank_archon_unit.bats, tests/llm_units.bats,
|
||||||
|
tests/llm_deploy_gates.bats, tests/llm_secrets.bats,
|
||||||
|
tests/scripts_syntax.bats]
|
||||||
|
scrape:
|
||||||
|
- job: frank-archon
|
||||||
|
target: 192.168.0.235:7770
|
||||||
|
metrics_path: /metrics
|
||||||
|
labels: {service: frank-archon}
|
||||||
|
probe: 192.168.0.235:7770
|
||||||
File renamed without changes.
@@ -13,11 +13,15 @@ load helpers
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "changed-test wrapper maps a compose change to compose and environment tests" {
|
@test "changed-test wrapper maps a shared config change to its service seams" {
|
||||||
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankserver/compose.yaml
|
# The compose era is retired (rank 7): the former compose fixture here
|
||||||
|
# mapped dankserver/compose.yaml, a file that no longer exists. The
|
||||||
|
# equivalent contract now: a service config change maps to the
|
||||||
|
# service's seams and nothing unrelated.
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths shared/searxng/settings.yml
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ "$output" == *"tests/compose_validity.bats"* ]]
|
[[ "$output" == *"tests/searxng_limiter.bats"* ]]
|
||||||
[[ "$output" == *"tests/env_consistency.bats"* ]]
|
[[ "$output" == *"tests/searxng_quadlet.bats"* ]]
|
||||||
[[ "$output" != *"tests/homepage_pages.bats"* ]]
|
[[ "$output" != *"tests/homepage_pages.bats"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,3 +51,27 @@ load helpers
|
|||||||
[[ "$output" != *"tests/frank_medium_unit.bats"* ]]
|
[[ "$output" != *"tests/frank_medium_unit.bats"* ]]
|
||||||
[[ "$output" != *"tests/frank_archon_unit.bats"* ]]
|
[[ "$output" != *"tests/frank_archon_unit.bats"* ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "changed-test wrapper maps the frankbook unit to the frankbook and deploy-flow seams" {
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths frankbook-pro/sonarr/quadlet/sonarr.container
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"tests/frankbook_quadlet.bats"* ]]
|
||||||
|
[[ "$output" == *"tests/deployment_flow.bats"* ]]
|
||||||
|
[[ "$output" == *"tests/deployment_scoping.bats"* ]]
|
||||||
|
[[ "$output" != *"tests/frank_archon_unit.bats"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "changed-test wrapper maps the inventory itself to its seam" {
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths shared/services.yml
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"tests/service_inventory.bats"* ]]
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/service-map.py
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"tests/service_inventory.bats"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "changed-test wrapper refuses an unmapped path" {
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths newdir/file.txt
|
||||||
|
[ "$status" -eq 2 ]
|
||||||
|
[[ "$output" == *"no focused-test mapping"* ]]
|
||||||
|
}
|
||||||
+1773
File diff suppressed because it is too large.
Load diff
@@ -101,7 +101,10 @@ webui_service_yaml() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@test "remote deployer accepts and dispatches hermes-webui" {
|
@test "remote deployer accepts and dispatches hermes-webui" {
|
||||||
grep -q 'dankclaw:hermes-webui' "$REMOTE_DEPLOY"
|
# Acceptance is the inventory (rank 3): the old literal allow-list in
|
||||||
|
# the remote runner is retired.
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankclaw --service hermes-webui
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
# Dispatched to the unit handler, never to compose: deploy_hermes installs
|
# Dispatched to the unit handler, never to compose: deploy_hermes installs
|
||||||
# the quadlets and retires compose-era containers.
|
# the quadlets and retires compose-era containers.
|
||||||
grep -q 'hermes|hermes-dashboard|hermes-webui) deploy_hermes' "$REMOTE_DEPLOY"
|
grep -q 'hermes|hermes-dashboard|hermes-webui) deploy_hermes' "$REMOTE_DEPLOY"
|
||||||
@@ -111,5 +114,7 @@ webui_service_yaml() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@test "top-level deployer accepts hermes-webui as a Dankclaw service" {
|
@test "top-level deployer accepts hermes-webui as a Dankclaw service" {
|
||||||
grep -q 'dankclaw:hermes-webui' "$DEPLOY"
|
# deploy.sh validates --service through the inventory's allows.
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankclaw --service hermes-webui
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
}
|
}
|
||||||
@@ -433,11 +433,14 @@ assert any('ALERTS' in e for e in exprs), exprs
|
|||||||
grep -q -- '--web.listen-address=127.0.0.1:9100' "$unit"
|
grep -q -- '--web.listen-address=127.0.0.1:9100' "$unit"
|
||||||
grep -q -- '--web.listen-address=192.168.0.235:9100' "$unit"
|
grep -q -- '--web.listen-address=192.168.0.235:9100' "$unit"
|
||||||
! grep -qE -- '--web\.listen-address=(0\.0\.0\.0|:9100|\[::\])' "$unit"
|
! grep -qE -- '--web\.listen-address=(0\.0\.0\.0|:9100|\[::\])' "$unit"
|
||||||
# One owner per container name: the compose service must be gone, or two
|
# One owner per container name: Dankserver has no compose file at all
|
||||||
# definitions fight over the same container.
|
# since the 2026-10-13 retirement (it lived beside diverged quadlet
|
||||||
! grep -qE '^\s+node-exporter:' "$REPO_ROOT/dankserver/compose.yaml"
|
# mounts for weeks — the two-owners trap by construction).
|
||||||
# The deploy path has to know about it on this target.
|
[ ! -e "$REPO_ROOT/dankserver/compose.yaml" ]
|
||||||
grep -q 'dankserver:node-exporter' "$REPO_ROOT/scripts/deploy-remote.sh"
|
# The deploy path has to know about it on this target — acceptance
|
||||||
|
# is the inventory (rank 3), not a literal list in the runner.
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankserver --service node-exporter
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver \
|
run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver \
|
||||||
--paths dankserver/observability/quadlet/node-exporter.container
|
--paths dankserver/observability/quadlet/node-exporter.container
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
|
|||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: the service inventory (rank 3). shared/services.yml is the one
|
||||||
|
# inventory of deployable services; scripts/service-map.py is its only
|
||||||
|
# reader, and every consumer (deploy-plan.py, deploy-remote.sh's
|
||||||
|
# allow-list, deploy.sh's --service gate, test-changed.sh's seam mapper,
|
||||||
|
# the Pi-hole hosts render, the Prometheus scrape facts, the homepage
|
||||||
|
# probes) goes through it. These tests pin the inventory's integrity,
|
||||||
|
# the planner's selection against a captured fixture, and the rendered
|
||||||
|
# artifacts' drift against the tracked copies.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
@test "inventory: schema, unit claims, path claims, compose ownership, and render drift are sound" {
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" check
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: the deployers' allow-list is the inventory" {
|
||||||
|
# Every service the inventory places on a host must pass "allows" for
|
||||||
|
# that host, and a name the inventory never places must be refused.
|
||||||
|
for host in dankclaw dankserver frankbook-pro; do
|
||||||
|
while IFS= read -r svc; do
|
||||||
|
[ -n "$svc" ] || continue
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host "$host" --service "$svc"
|
||||||
|
[ "$status" -eq 0 ] || { echo "allows refused $host:$svc"; return 1; }
|
||||||
|
done < <(python3 "$REPO_ROOT/scripts/service-map.py" services --host "$host")
|
||||||
|
done
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankserver --service sonarr
|
||||||
|
[ "$status" -ne 0 ] # the *arr stack lives on frankbook-pro, not dankserver
|
||||||
|
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankclaw --service frank-archon
|
||||||
|
[ "$status" -ne 0 ] # frank-archon lives on dankserver
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: the planner reproduces the pinned selection fixture" {
|
||||||
|
# tests/fixtures/service-plan-golden.tsv is the characterization of
|
||||||
|
# path -> service selection across every tracked path, every target,
|
||||||
|
# and the multi-path combos the old hand-written planner was fed.
|
||||||
|
python3 - "$REPO_ROOT" <<'PYEOF'
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = sys.argv[1]
|
||||||
|
spec = importlib.util.spec_from_file_location("sm", root + "/scripts/service-map.py")
|
||||||
|
sm = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(sm)
|
||||||
|
inv = sm.Inventory.load(root + "/shared/services.yml")
|
||||||
|
bad = []
|
||||||
|
with open(root + "/tests/fixtures/service-plan-golden.tsv") as fh:
|
||||||
|
for line in fh:
|
||||||
|
host, paths, want = line.rstrip("\n").split("\t")
|
||||||
|
got = ",".join(sm.select(inv, host, paths.split("+")))
|
||||||
|
if got != want:
|
||||||
|
bad.append(f"{host} {paths}\n want: {want}\n got: {got}")
|
||||||
|
if bad:
|
||||||
|
print(f"{len(bad)} selection mismatches:", *bad[:20], sep="\n")
|
||||||
|
sys.exit(1)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: deploy-plan.py keeps its CLI contract on top of service-map" {
|
||||||
|
# stdin paths, positional paths, and the --repo/--base/--head diff mode
|
||||||
|
# all route to the same selection.
|
||||||
|
run bash -c "cd '$REPO_ROOT' && printf '%s\n' dankclaw/searxng/quadlet/gluetun.container | \
|
||||||
|
scripts/deploy-plan.py --target dankclaw"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "${lines[0]}" = "gluetun" ]
|
||||||
|
run "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver scripts/frank-archon.env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "${lines[0]}" = "frank-archon" ]
|
||||||
|
run "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw --repo "$REPO_ROOT" --base HEAD --head HEAD
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: the Pi-hole hosts file is a rendered artifact" {
|
||||||
|
# The tracked addn-hosts must be byte-identical to the render; editing
|
||||||
|
# it by hand is drift the inventory check also catches.
|
||||||
|
run bash -c "cd '$REPO_ROOT' && python3 scripts/service-map.py render pihole-hosts | \
|
||||||
|
diff -u dankclaw/pihole/addn-hosts -"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: changed-test seams for a service unit are the service's own" {
|
||||||
|
# The rank-3 contract: a unit change selects the OWNING service's
|
||||||
|
# declared seams, not the old shotgun of unrelated quadlet seams.
|
||||||
|
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/gitea/quadlet/gitea.network
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ "$output" == *"tests/caddyfile.bats"* ]]
|
||||||
|
[[ "$output" == *"tests/deployment_scoping.bats"* ]]
|
||||||
|
[[ "$output" != *"tests/open_webui_quadlet.bats"* ]]
|
||||||
|
[[ "$output" != *"tests/searxng_quadlet.bats"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "inventory: every tracked quadlet unit is claimed by exactly one service" {
|
||||||
|
# check units covers claim conflicts; this pins the census size so a
|
||||||
|
# unit added WITHOUT an inventory entry (unclaimed) fails the seam.
|
||||||
|
python3 - "$REPO_ROOT" <<'PYEOF'
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
root = sys.argv[1]
|
||||||
|
spec = importlib.util.spec_from_file_location("sm", root + "/scripts/service-map.py")
|
||||||
|
sm = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(sm)
|
||||||
|
inv = sm.Inventory.load(root + "/shared/services.yml")
|
||||||
|
graph = sm.graph_for(tuple(inv.hosts))
|
||||||
|
owned = {u for u in graph.units}
|
||||||
|
problems = []
|
||||||
|
tracked_units = [
|
||||||
|
p for p in sm.tracked_files()
|
||||||
|
if PurePosixPath(p).name.endswith((".container", ".network")) and not sm.is_trash(p)
|
||||||
|
]
|
||||||
|
for path in tracked_units:
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
name = parts[-1]
|
||||||
|
key = (parts[0], name.rsplit(".", 1)[0])
|
||||||
|
if key not in graph.units and key not in graph.networks:
|
||||||
|
problems.append(f"unclaimed unit-file path: {path}")
|
||||||
|
if problems:
|
||||||
|
print("\n".join(problems))
|
||||||
|
sys.exit(1)
|
||||||
|
PYEOF
|
||||||
|
}
|
||||||
Reference in new issue
Block a user