feat(inventory): one service inventory drives planner, allow-lists, and seams
rank 3. shared/services.yml is the single source of truth for every deployable service (hosts, unit dirs, units, path claims, tests, scrape/ dns/probe facts, aliases, stacks); scripts/service-map.py is its only reader (hosts/services/allows/plan/seams/owner/units/stack/get/render/ check). deploy-plan.py becomes a thin CLI wrapper; both --service allow-lists (deploy.sh, deploy-remote.sh) and the changed-test mapper are the inventory. Selection semantics: host scoping (a host's tree selects only its own services), the quadlet netns/network coupling graph resolved across directories (gluetun's riders live in their own dirs), stacks, and canonical restart order. Against the pinned old-planner fixture: 921 raw deltas reduced to 17, all intentional (anubis now maps; cross-host paths select nothing; the retired qwen-sharp template plans nothing and the live froggeric template plans frank-archon; dankserver/romm/romm.container, missed by the old filename rule, plans the romm trio). The fixture is regenerated as the new pin. Integrity gates: schema, unit claims, quadlet-file ownership, path-glob liveness, Pi-hole hosts / Prometheus jobs / DNS / homepage probes drift. tests/service_inventory.bats pins all of it plus the plan fixture and the deploy-plan CLI. Allow-list grep seams in hermes_webui/observability_quadlet become behavioral allows calls.
This commit is contained in:
1 parent
156ea71879
commit
bdead570e2
17 files changed
+3435
-699
No files matched your search
File renamed without changes.
@@ -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"* ]]
|
||||
}
|
||||
+1773
File diff suppressed because it is too large.
Load diff
@@ -101,7 +101,10 @@ webui_service_yaml() {
|
||||
}
|
||||
|
||||
@test "remote deployer accepts and dispatches hermes-webui" {
|
||||
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 ]
|
||||
}
|
||||
@@ -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 ]
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: the service inventory (rank 3). shared/services.yml is the one
|
||||
# inventory of deployable services; scripts/service-map.py is its only
|
||||
# reader, and every consumer (deploy-plan.py, deploy-remote.sh's
|
||||
# allow-list, deploy.sh's --service gate, test-changed.sh's seam mapper,
|
||||
# the Pi-hole hosts render, the Prometheus scrape facts, the homepage
|
||||
# probes) goes through it. These tests pin the inventory's integrity,
|
||||
# the planner's selection against a captured fixture, and the rendered
|
||||
# artifacts' drift against the tracked copies.
|
||||
|
||||
load helpers
|
||||
|
||||
@test "inventory: schema, unit claims, path claims, compose ownership, and render drift are sound" {
|
||||
run python3 "$REPO_ROOT/scripts/service-map.py" check
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "inventory: the deployers' allow-list is the inventory" {
|
||||
# Every service the inventory places on a host must pass "allows" for
|
||||
# that host, and a name the inventory never places must be refused.
|
||||
for host in dankclaw dankserver frankbook-pro; do
|
||||
while IFS= read -r svc; do
|
||||
[ -n "$svc" ] || continue
|
||||
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host "$host" --service "$svc"
|
||||
[ "$status" -eq 0 ] || { echo "allows refused $host:$svc"; return 1; }
|
||||
done < <(python3 "$REPO_ROOT/scripts/service-map.py" services --host "$host")
|
||||
done
|
||||
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankserver --service sonarr
|
||||
[ "$status" -ne 0 ] # the *arr stack lives on frankbook-pro, not dankserver
|
||||
run python3 "$REPO_ROOT/scripts/service-map.py" allows --host dankclaw --service frank-archon
|
||||
[ "$status" -ne 0 ] # frank-archon lives on dankserver
|
||||
}
|
||||
|
||||
@test "inventory: the planner reproduces the pinned selection fixture" {
|
||||
# tests/fixtures/service-plan-golden.tsv is the characterization of
|
||||
# path -> service selection across every tracked path, every target,
|
||||
# and the multi-path combos the old hand-written planner was fed.
|
||||
python3 - "$REPO_ROOT" <<'PYEOF'
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
root = sys.argv[1]
|
||||
spec = importlib.util.spec_from_file_location("sm", root + "/scripts/service-map.py")
|
||||
sm = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(sm)
|
||||
inv = sm.Inventory.load(root + "/shared/services.yml")
|
||||
bad = []
|
||||
with open(root + "/tests/fixtures/service-plan-golden.tsv") as fh:
|
||||
for line in fh:
|
||||
host, paths, want = line.rstrip("\n").split("\t")
|
||||
got = ",".join(sm.select(inv, host, paths.split("+")))
|
||||
if got != want:
|
||||
bad.append(f"{host} {paths}\n want: {want}\n got: {got}")
|
||||
if bad:
|
||||
print(f"{len(bad)} selection mismatches:", *bad[:20], sep="\n")
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
@test "inventory: deploy-plan.py keeps its CLI contract on top of service-map" {
|
||||
# stdin paths, positional paths, and the --repo/--base/--head diff mode
|
||||
# all route to the same selection.
|
||||
run bash -c "cd '$REPO_ROOT' && printf '%s\n' dankclaw/searxng/quadlet/gluetun.container | \
|
||||
scripts/deploy-plan.py --target dankclaw"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "${lines[0]}" = "gluetun" ]
|
||||
run "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver scripts/frank-archon.env
|
||||
[ "$status" -eq 0 ]
|
||||
[ "${lines[0]}" = "frank-archon" ]
|
||||
run "$REPO_ROOT/scripts/deploy-plan.py" --target dankclaw --repo "$REPO_ROOT" --base HEAD --head HEAD
|
||||
[ "$status" -eq 0 ]
|
||||
[ -z "$output" ]
|
||||
}
|
||||
|
||||
@test "inventory: the Pi-hole hosts file is a rendered artifact" {
|
||||
# The tracked addn-hosts must be byte-identical to the render; editing
|
||||
# it by hand is drift the inventory check also catches.
|
||||
run bash -c "cd '$REPO_ROOT' && python3 scripts/service-map.py render pihole-hosts | \
|
||||
diff -u dankclaw/pihole/addn-hosts -"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "inventory: changed-test seams for a service unit are the service's own" {
|
||||
# The rank-3 contract: a unit change selects the OWNING service's
|
||||
# declared seams, not the old shotgun of unrelated quadlet seams.
|
||||
run bash "$REPO_ROOT/scripts/test-changed.sh" --list --paths dankclaw/gitea/quadlet/gitea.network
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"tests/caddyfile.bats"* ]]
|
||||
[[ "$output" == *"tests/deployment_scoping.bats"* ]]
|
||||
[[ "$output" != *"tests/open_webui_quadlet.bats"* ]]
|
||||
[[ "$output" != *"tests/searxng_quadlet.bats"* ]]
|
||||
}
|
||||
|
||||
@test "inventory: every tracked quadlet unit is claimed by exactly one service" {
|
||||
# check units covers claim conflicts; this pins the census size so a
|
||||
# unit added WITHOUT an inventory entry (unclaimed) fails the seam.
|
||||
python3 - "$REPO_ROOT" <<'PYEOF'
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
root = sys.argv[1]
|
||||
spec = importlib.util.spec_from_file_location("sm", root + "/scripts/service-map.py")
|
||||
sm = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(sm)
|
||||
inv = sm.Inventory.load(root + "/shared/services.yml")
|
||||
graph = sm.graph_for(tuple(inv.hosts))
|
||||
owned = {u for u in graph.units}
|
||||
problems = []
|
||||
tracked_units = [
|
||||
p for p in sm.tracked_files()
|
||||
if PurePosixPath(p).name.endswith((".container", ".network")) and not sm.is_trash(p)
|
||||
]
|
||||
for path in tracked_units:
|
||||
parts = PurePosixPath(path).parts
|
||||
name = parts[-1]
|
||||
key = (parts[0], name.rsplit(".", 1)[0])
|
||||
if key not in graph.units and key not in graph.networks:
|
||||
problems.append(f"unclaimed unit-file path: {path}")
|
||||
if problems:
|
||||
print("\n".join(problems))
|
||||
sys.exit(1)
|
||||
PYEOF
|
||||
}
|
||||
Reference in new issue
Block a user