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:
Frank
2026-10-13 22:35:07 -07:00
parent 156ea71879
commit bdead570e2
17 changed files with 3435 additions and 699 deletions
+28 -12
View File
@@ -35,11 +35,16 @@ the [runbook index](#runbook-index). The full swap history lives in
## Repository Layout
- `dankserver/` — compose.yaml + Caddyfile + .env.example for the media stack
- `dankclaw/` — compose.yaml + Caddyfile + .env.example for the AI stack
- `shared/` — homepage, Prometheus, Loki, Alloy, SearXNG configs
- `scripts/` — `deploy.sh` / `deploy-plan.py` / `deploy-remote.sh`, LLM serve
scripts, systemd units, env tunables, bench harnesses
- `dankserver/` — quadlet units + Caddyfile for the media stack (the
compose.yaml inventory retired 2026-10-13; its mounts had diverged from
the quadlets)
- `dankclaw/` — quadlet units + Caddyfile for the AI stack (compose retired 2026-09-16)
- `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)
- `serverAppConfigs-docs/` — knowledge base (Obsidian vault, OKF format).
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
```
The planner maps changed paths to services; the remote runner restarts only
the selected service and records its revision outside the checkout.
The inventory (`shared/services.yml`, read only through
`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
their own files — every endpoint (`frank-dragoon`/`frank-zealot`/`frank-archon`)
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
```
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**,
script syntax (shell + python), LLM unit integrity, LLM secret hygiene,
deployment flow, service-scoped planning, 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.
LLM artifact gates (`llm_gates`/`llm_deploy_gates` — the shared
`scripts/lib/llm-gates.sh` every endpoint's serve and deploy gates run on),
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`.
## 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-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` |
| Deployment flow + incidents | `serverAppConfigs-docs/LLM/deployment.md` |
@@ -1050,6 +1065,7 @@ Retired (rollback targets and history):
| 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` |
| 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` |
| 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` |
+54 -461
View File
@@ -1,475 +1,68 @@
#!/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
path list or two Git revisions. The latter lets compose-file changes resolve
to only the service definitions that actually changed instead of restarting an
entire compose project.
Thin wrapper over scripts/service-map.py, which owns the path->service
semantics in the shared inventory (shared/services.yml): per-service path
rules, the quadlet netns/network coupling graph, host scoping (a host's
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 argparse
import os
import subprocess
import sys
from pathlib import PurePosixPath
from typing import Any
import yaml
# Dependency-sensitive services come before their consumers. Independent
# compose services follow. LLM services are deliberately last and are never
# implied by a generic host, stack, documentation, or config change.
SERVICE_ORDER = (
"gluetun",
"searxng-valkey",
"searxng",
"marreta",
"pihole",
"open-webui",
"gitea-postgres",
"gitea-server",
"comfyui",
"seerr",
"hermes",
"hermes-dashboard",
"hermes-webui",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"alertmanager",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"authentik-outpost",
"authentik-outpost-ldap",
"caddy",
"homepage",
"romm-mariadb",
"romm-redis",
"romm",
"copyparty",
"jellyfin",
"calibre-web",
"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"}
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SERVICE_MAP = os.path.join(SCRIPT_DIR, "service-map.py")
def _add(services: set[str], *names: str) -> None:
services.update(names)
def target_services(target: str) -> set[str]:
if target == "dankclaw":
return DANKCLAW_SERVICES
if target == "dankserver":
return DANKSERVER_SERVICES
if target == "frankbook-pro":
return FRANKBOOK_SERVICES
raise ValueError(f"unknown target: {target}")
def _compose_services(target: str) -> set[str]:
return DANKCLAW_COMPOSE_SERVICES if target == "dankclaw" else DANKSERVER_COMPOSE_SERVICES
def _git_show(repo: str, revision: str, path: str) -> str | None:
result = subprocess.run(
["git", "-C", repo, "show", f"{revision}:{path}"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
)
return result.stdout if result.returncode == 0 else None
def _yaml_document(contents: str | None) -> dict[str, Any]:
if contents is None:
return {}
try:
data = yaml.safe_load(contents) or {}
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def _yaml_services(contents: str | None) -> dict[str, Any]:
services = _yaml_document(contents).get("services", {})
return services if isinstance(services, dict) else {}
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
"""Return only compose services whose YAML definitions or dependencies changed.
A target with no compose file (Dankclaw since 2026-09-16) plans nothing:
its services are Quadlet units, selected by their own paths.
"""
path = f"{target}/compose.yaml"
if not _git_show(repo, head, path) and not _git_show(repo, base, path):
return set()
old_doc = _yaml_document(_git_show(repo, base, path))
new_doc = _yaml_document(_git_show(repo, head, path))
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
new = new_doc.get("services", {}) if isinstance(new_doc.get("services", {}), dict) else {}
names = set(old) | set(new)
allowed = _compose_services(target)
# An unavailable or malformed revision is safer as a broad compose plan
# than as a silent no-op. It still cannot include LLM units.
if (old_doc and not new_doc) or (new_doc and not old_doc and names):
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
# Top-level networks/configuration can affect every compose service even
# when no individual service mapping changed. Ignore the services key while
# comparing the rest of the Compose document.
old_globals = {key: value for key, value in old_doc.items() if key != "services"}
new_globals = {key: value for key, value in new_doc.items() if key != "services"}
if old_globals != new_globals:
return {COMPOSE_NAME_MAP.get(n, n) for n in allowed}
changed = {name for name in names if name in allowed and old.get(name) != new.get(name)}
return {COMPOSE_NAME_MAP.get(n, n) for n in changed}
def services_for_path(path: str, target: str) -> set[str]:
"""Return services affected by one repository-relative path."""
path = path.replace("\\", "/")
p = PurePosixPath(path)
parts = p.parts
services: set[str] = set()
if not parts:
return services
# Non-runtime material is deliberately a no-op.
if parts[0] in {"docs", "tests", "old", ".githooks"}:
return services
if path in {"README.md", "AGENTS.md"} or parts[0] == ".obsidian":
return services
if parts[0] == "shared":
if len(parts) < 2:
return services
group = parts[1]
if group == "searxng":
_add(services, "searxng")
elif group == "prometheus":
_add(services, "prometheus")
if len(parts) >= 3 and parts[2] == "alertmanager.yml":
# The Alertmanager config ships from this directory: a change
# to it must redeploy the service that installs it.
_add(services, "alertmanager")
elif group == "loki":
_add(services, "loki")
elif group == "alloy":
_add(services, "alloy")
elif group == "grafana":
# Provisioned datasources and dashboards: git is the source of
# truth, so a change here must restart the service that reads them.
_add(services, "grafana")
elif group == "homepage":
# Homepage is a Dankclaw-owned deployment. Shared homepage files
# are intentionally not copied during a Dankserver-only run.
if target == "dankclaw":
_add(services, "homepage")
return services
if parts[0] == "dankclaw":
if len(parts) >= 2 and parts[1] == "compose.yaml":
# Dankclaw has no compose file since 2026-09-16: every service is a
# Quadlet unit, each selected by its own path. A change (or a
# deletion) of this path therefore restarts nothing.
return services
if len(parts) >= 2 and parts[1] == "Caddyfile":
_add(services, "caddy")
return services
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
_add(services, "comfyui")
return services
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis", "seerr"}:
_add(services, parts[1])
return services
if len(parts) >= 4 and parts[1] == "authentik" and parts[2] == "quadlet":
name = parts[3].removesuffix(".container")
if name in {"authentik-postgres", "authentik-server", "authentik-worker", "authentik-outpost", "authentik-outpost-ldap"}:
_add(services, name)
return services
if len(parts) >= 2 and parts[1] == "searxng":
if len(parts) >= 4 and parts[2] == "quadlet":
name = parts[3]
if name == "gluetun.container":
# SearXNG and Marreta (+ its browser companion) share
# Gluetun's network namespace. A Gluetun recreation
# tears the netns down, so every sharer must restart.
_add(services, "gluetun", "searxng", "marreta")
elif name == "searxng.container":
_add(services, "searxng")
elif name == "searxng-valkey.container":
_add(services, "searxng-valkey", "searxng")
elif name == "searxng.network":
_add(services, "gluetun", "searxng-valkey", "searxng", "marreta")
return services
if len(parts) >= 2 and parts[1] == "marreta":
# One deployable owns both the app and its browser companion;
# both live in gluetun's netns. A change to either unit selects
# the marreta service, which restarts browser-then-app.
_add(services, "marreta")
return services
if len(parts) >= 2 and parts[1] == "pihole":
_add(services, "pihole")
return services
if len(parts) >= 2 and parts[1] == "open-webui":
_add(services, "open-webui")
return services
# frank-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 git_paths(repo: str, base: str, head: str) -> list[str]:
proc = subprocess.run(
["git", "-C", repo, "diff", "--name-only", base, head],
text=True, capture_output=True)
if proc.returncode != 0:
sys.stderr.write(f"deploy-plan: git diff failed: {proc.stderr}")
raise SystemExit(1)
return [line for line in proc.stdout.splitlines() if line]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=("dankclaw", "dankserver", "frankbook-pro"), required=True)
parser.add_argument("--paths", nargs="+")
parser.add_argument("--repo", help="repository path for revision-aware planning")
parser.add_argument("--base", help="base Git revision")
parser.add_argument("--head", help="head Git revision")
args = parser.parse_args()
if not args.paths and not (args.repo and args.base and args.head):
parser.error("provide --paths or all of --repo, --base, and --head")
paths = args.paths or changed_paths(args.repo, args.base, args.head)
print("\n".join(plan(paths, args.target, args.repo, args.base, args.head)))
args = sys.argv[1:]
if not args or args[0] != "--target":
print(__doc__, file=sys.stderr)
return 2
if len(args) < 2:
print("missing target name after --target", file=sys.stderr)
return 2
target = args[1]
# `--paths` is the historic seam keyword: a flag, not an option — the
# paths are the positional arguments that follow it.
rest = [a for a in args[2:] if a != "--paths"]
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
+7 -25
View File
@@ -131,31 +131,13 @@ else
fi
valid_service() {
local service="$1"
case "$TARGET:$service" in
dankclaw:caddy|dankclaw:gluetun|dankclaw:searxng-network|dankclaw:searxng-valkey|\
dankclaw:searxng|dankclaw:marreta|dankclaw:pihole|dankclaw:open-webui|dankclaw:gitea-postgres|\
dankclaw:gitea-server|dankclaw:homepage|\
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
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
# The inventory is the only allow-list (see scripts/service-map.py);
# service-map's "allows" exit code is the answer. The former literal
# case list here drifted from reality (it still listed the *arr stack
# under dankserver, which moved to frankbook-pro, and a
# "searxng-network" name that is a generated systemd unit, not a
# deployable service).
python3 "$REPO_ROOT/scripts/service-map.py" allows --host "$TARGET" --service "$1" >/dev/null
}
for service in "${EXPLICIT_SERVICES[@]}"; do
+647
View File
@@ -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
View File
@@ -62,234 +62,101 @@ declare -a TESTS=()
add_test() {
local test="$1"
local existing
MAPPED=1
for existing in "${TESTS[@]:-}"; do
[ "$existing" != "$test" ] || return 0
done
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
MAPPED=0
case "$path" in
tests/*.bats)
add_test "$path"
continue
;;
tests/helpers.bash|tests/run.sh)
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/*)
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)
add_test tests/env_consistency.bats
;;
shared/homepage/*)
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
dankserver/Caddyfile|dankclaw/Caddyfile)
add_test tests/caddyfile.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)
add_test tests/deployment_flow.bats
add_test tests/deployment_scoping.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)
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
;;
# frank-dragoon's chat template lives in scripts/ (moved there on
# 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
old/scripts/*)
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/*)
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
# 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
# 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]]
+1
View File
@@ -28,6 +28,7 @@ covers both workloads).
- [[Network Architecture]] — Caddy reverse proxy, hostname routing, Gluetun VPN path
- [[Storage Policy]] — bind mounts, data locations, ownership
- [[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
- [[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
+598
View File
@@ -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
+32 -4
View File
@@ -13,11 +13,15 @@ load helpers
[ "$status" -eq 0 ]
}
@test "changed-test wrapper maps a compose change to compose and environment tests" {
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankserver/compose.yaml
@test "changed-test wrapper maps a shared config change to its service seams" {
# 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 ]
[[ "$output" == *"tests/compose_validity.bats"* ]]
[[ "$output" == *"tests/env_consistency.bats"* ]]
[[ "$output" == *"tests/searxng_limiter.bats"* ]]
[[ "$output" == *"tests/searxng_quadlet.bats"* ]]
[[ "$output" != *"tests/homepage_pages.bats"* ]]
}
@@ -47,3 +51,27 @@ load helpers
[[ "$output" != *"tests/frank_medium_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"* ]]
}
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -101,7 +101,10 @@ webui_service_yaml() {
}
@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
# the quadlets and retires compose-era containers.
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" {
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 ]
}
+8 -5
View File
@@ -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=192.168.0.235: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
# definitions fight over the same container.
! grep -qE '^\s+node-exporter:' "$REPO_ROOT/dankserver/compose.yaml"
# The deploy path has to know about it on this target.
grep -q 'dankserver:node-exporter' "$REPO_ROOT/scripts/deploy-remote.sh"
# One owner per container name: Dankserver has no compose file at all
# since the 2026-10-13 retirement (it lived beside diverged quadlet
# mounts for weeks — the two-owners trap by construction).
[ ! -e "$REPO_ROOT/dankserver/compose.yaml" ]
# 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 \
--paths dankserver/observability/quadlet/node-exporter.container
[ "$status" -eq 0 ]
+125
View File
@@ -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
}