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:
+54
-461
@@ -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
@@ -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
|
||||
|
||||
Executable
+647
@@ -0,0 +1,647 @@
|
||||
#!/usr/bin/env python3
|
||||
"""service-map.py — the only reader of the service inventory.
|
||||
|
||||
Four lists used to agree by discipline (the planner's path rules, the
|
||||
deployer's allow-list, the changed-test mapper, and the scrape/probe/DNS
|
||||
facts written by hand in three config files). They now all read
|
||||
`shared/services.yml` through this tool:
|
||||
|
||||
hosts the deploy targets
|
||||
services [--host H] deployables a target accepts, canonical order
|
||||
allows --host H --service S exit 0 when H accepts S (silent)
|
||||
plan --host H PATH... services selected by changed paths, order-safe
|
||||
seams [--host H] PATH... the Bats seams a changed path must re-run
|
||||
owner --host H PATH the service that owns a file (empty: none)
|
||||
units --host H --service S "<unit> <unit_dir>" lines the handler installs
|
||||
stack --host H --service S the deployable + members one handler deploys
|
||||
get --service S [--host H] F one field as JSON
|
||||
render pihole-hosts the Pi-hole addn-hosts file
|
||||
check [NAME...] integrity + drift gates (exit 1 on drift)
|
||||
|
||||
Two laws the engine enforces structurally:
|
||||
|
||||
* A path selects only services that RUN on the planned target. The old planner
|
||||
matched paths host-blind and filtered at the end, so a frankbook-pro file
|
||||
could plan a dankserver service (the stale *arr entries made that reachable).
|
||||
* The netns family is DERIVED, never declared: a unit file that says
|
||||
`Network=gluetun.container` rides that tunnel, so a gluetun change selects
|
||||
every sharer. The unit files are the source of that coupling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_INVENTORY = REPO_ROOT / "shared" / "services.yml"
|
||||
|
||||
REQUIRED_KEYS = ("name", "hosts", "kind", "tests")
|
||||
KINDS = {"quadlet", "root-unit", "vm-quadlet", "host"}
|
||||
UNIT_SUFFIXES = (".container", ".network")
|
||||
|
||||
# Repo material that is never a deployed runtime's state.
|
||||
NON_RUNTIME_ROOTS = {"docs", "report", "old", ".githooks", ".obsidian", ".github",
|
||||
"tests", "serverAppConfigs-docs"}
|
||||
NON_RUNTIME_NAMES = {".env.example"}
|
||||
NON_RUNTIME_PATHS = {"README.md", "AGENTS.md", ".gitignore"}
|
||||
|
||||
|
||||
def is_trash(path: str) -> bool:
|
||||
return ".AGENTTRASH" in PurePosixPath(path).parts
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# inventory
|
||||
# --------------------------------------------------------------------------
|
||||
class Inventory:
|
||||
def __init__(self, doc: dict[str, Any], origin: str) -> None:
|
||||
self.origin = origin
|
||||
self.hosts: dict[str, dict[str, Any]] = doc["hosts"]
|
||||
self.services: list[dict[str, Any]] = doc["services"]
|
||||
self.unowned: dict[str, str] = doc.get("unowned", {})
|
||||
for index, svc in enumerate(self.services):
|
||||
svc["_index"] = index
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path = DEFAULT_INVENTORY) -> "Inventory":
|
||||
doc = yaml.safe_load(Path(path).read_text())
|
||||
if not isinstance(doc, dict) or "services" not in doc:
|
||||
raise SystemExit(f"{path}: not a service inventory document")
|
||||
return cls(doc, str(path))
|
||||
|
||||
def on(self, host: str) -> list[dict[str, Any]]:
|
||||
return [s for s in self.services if host in s["hosts"]]
|
||||
|
||||
def named(self, host: str, name: str) -> list[dict[str, Any]]:
|
||||
return [s for s in self.services if s["name"] == name and host in s["hosts"]]
|
||||
|
||||
def any_named(self, name: str) -> list[dict[str, Any]]:
|
||||
return [s for s in self.services if s["name"] == name]
|
||||
|
||||
@property
|
||||
def order(self) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for svc in self.services:
|
||||
if svc["name"] not in seen:
|
||||
seen.append(svc["name"])
|
||||
return seen
|
||||
|
||||
def edge_ip(self) -> str:
|
||||
"""The single address every DNS name resolves at (Caddy's edge)."""
|
||||
for host in self.hosts.values():
|
||||
if "dns_edge" in host:
|
||||
return str(host["dns_edge"])
|
||||
raise SystemExit(f"{self.origin}: no host declares dns_edge")
|
||||
|
||||
def unit_dirs(self, svc: dict[str, Any], host: str | None = None) -> list[str]:
|
||||
"""The repo dirs holding this service's unit files, for one host."""
|
||||
dirs = svc.get("unit_dir")
|
||||
if not dirs:
|
||||
return []
|
||||
if isinstance(dirs, dict):
|
||||
if host is not None:
|
||||
return [dirs[host]] if host in dirs else []
|
||||
return [dirs[h] for h in svc["hosts"] if h in dirs]
|
||||
return [dirs]
|
||||
|
||||
def owns_unit(self, svc: dict[str, Any], host: str, unit: str, directory: str) -> bool:
|
||||
return unit in svc.get("units", []) and directory in self.unit_dirs(svc, host)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# repo facts
|
||||
# --------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def tracked_files() -> tuple[str, ...]:
|
||||
# Tracked + untracked (a check that only saw HEAD would ignore the very
|
||||
# files a work-in-progress commit is adding).
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(REPO_ROOT), "ls-files", "--cached", "--others",
|
||||
"--exclude-standard"],
|
||||
text=True, stdout=subprocess.PIPE, check=True)
|
||||
return tuple(line for line in out.stdout.splitlines() if line)
|
||||
|
||||
|
||||
def _network_values(container: Path) -> list[str]:
|
||||
"""Every `Network=` value a quadlet unit declares (comma lists included)."""
|
||||
values: list[str] = []
|
||||
try:
|
||||
text = container.read_text()
|
||||
except OSError:
|
||||
return values
|
||||
for line in text.splitlines():
|
||||
if line.startswith("Network="):
|
||||
values.extend(v.strip() for v in line[len("Network="):].split(",") if v.strip())
|
||||
return values
|
||||
|
||||
|
||||
class Graph:
|
||||
"""The unit-file coupling, read out of the tree: netns + netns-net users.
|
||||
|
||||
Quadlet installs every user unit flat, so `Network=gluetun.container`
|
||||
names a unit by name, not by path; the same host is the scope, which is
|
||||
also the deployment scope.
|
||||
"""
|
||||
|
||||
def __init__(self, hosts: list[str]) -> None:
|
||||
self.units: dict[tuple[str, str], str] = {} # (host, unit) -> path
|
||||
self.networks: dict[tuple[str, str], str] = {} # (host, net) -> path
|
||||
self.netns_children: dict[tuple[str, str], set[tuple[str, str]]] = {}
|
||||
self.network_users: dict[tuple[str, str], set[tuple[str, str]]] = {}
|
||||
for path in tracked_files():
|
||||
parts = PurePosixPath(path).parts
|
||||
if not parts or parts[0] not in hosts or is_trash(path):
|
||||
continue
|
||||
host, directory, name = parts[0], str(PurePosixPath(*parts[1:-1])), parts[-1]
|
||||
if name.endswith(".container"):
|
||||
self.units[(host, name[: -len(".container")])] = path
|
||||
elif name.endswith(".network"):
|
||||
self.networks[(host, name[: -len(".network")])] = path
|
||||
for (host, unit), path in self.units.items():
|
||||
for value in _network_values(REPO_ROOT / path):
|
||||
if value.endswith(".container"):
|
||||
parent = (host, value[: -len(".container")])
|
||||
self.netns_children.setdefault(parent, set()).add((host, unit))
|
||||
elif value.endswith(".network"):
|
||||
net = (host, value[: -len(".network")])
|
||||
self.network_users.setdefault(net, set()).add((host, unit))
|
||||
|
||||
def rides(self, start: list[tuple[str, str]]) -> set[tuple[str, str]]:
|
||||
"""The transitive netns closure: a unit, everything riding it, and
|
||||
everything riding those (a netns inside a netns)."""
|
||||
found: set[tuple[str, str]] = set()
|
||||
frontier = list(start)
|
||||
while frontier:
|
||||
node = frontier.pop()
|
||||
if node in found:
|
||||
continue
|
||||
found.add(node)
|
||||
frontier.extend(self.netns_children.get(node, ()))
|
||||
return found
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def graph_for(hosts: tuple[str, ...]) -> Graph:
|
||||
return Graph(list(hosts))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# selection
|
||||
# --------------------------------------------------------------------------
|
||||
def _owner_index(inv: Inventory) -> dict[tuple[str, str, int]]:
|
||||
"""(host, unit) -> service index, for unit-file ownership.
|
||||
|
||||
A quadlet unit name is unique per host (``check units`` enforces one
|
||||
claim), so the coupling closure can resolve a riding unit that lives in
|
||||
a DIFFERENT directory than the changed file — gluetun's netns children
|
||||
are the point.
|
||||
"""
|
||||
index: dict[tuple[str, str], int] = {}
|
||||
for svc in inv.services:
|
||||
for host in svc["hosts"]:
|
||||
for unit in svc.get("units", []):
|
||||
key = (host, unit)
|
||||
if key in index and index[key] != svc["_index"]:
|
||||
raise SystemExit(
|
||||
f"{inv.origin}: unit {unit!r} on {host} claimed by "
|
||||
f"{inv.services[index[key]]['name']} and {svc['name']}"
|
||||
)
|
||||
index[key] = svc["_index"]
|
||||
return index
|
||||
|
||||
|
||||
def select(inv: Inventory, host: str, paths: list[str]) -> list[str]:
|
||||
"""Canonical-order service names the changed paths select for one host."""
|
||||
if host not in inv.hosts:
|
||||
raise SystemExit(f"{inv.origin}: no host {host!r}")
|
||||
graph = graph_for(tuple(inv.hosts))
|
||||
owners = _owner_index(inv)
|
||||
hits: set[int] = set()
|
||||
|
||||
def add(index: int) -> None:
|
||||
hits.add(index)
|
||||
|
||||
for raw in paths:
|
||||
path = raw.replace("\\", "/")
|
||||
parts = PurePosixPath(path).parts
|
||||
if not parts or is_trash(path) or path in NON_RUNTIME_PATHS:
|
||||
continue
|
||||
if PurePosixPath(path).name in NON_RUNTIME_NAMES:
|
||||
continue
|
||||
head = parts[0]
|
||||
if head in NON_RUNTIME_ROOTS:
|
||||
continue
|
||||
# A host's directory is the scope: a frankbook-pro file can never
|
||||
# select a dankserver service.
|
||||
scoped_host = head if head in inv.hosts else None
|
||||
if scoped_host is not None and scoped_host != host:
|
||||
continue
|
||||
|
||||
name = parts[-1]
|
||||
directory = str(PurePosixPath(*parts[1:-1])) if len(parts) > 1 else ""
|
||||
|
||||
if scoped_host and name.endswith(UNIT_SUFFIXES):
|
||||
stem = name.rsplit(".", 1)[0]
|
||||
key = (scoped_host, stem)
|
||||
if name.endswith(".network") and key in graph.networks:
|
||||
users = graph.network_users.get(key, set())
|
||||
for unit in graph.rides(sorted(users)):
|
||||
index = owners.get(unit)
|
||||
if index is not None:
|
||||
add(index)
|
||||
elif key in graph.units:
|
||||
for unit in graph.rides([key]):
|
||||
index = owners.get(unit)
|
||||
if index is not None:
|
||||
add(index)
|
||||
|
||||
for index in list(hits):
|
||||
for dependent in inv.services[index].get("dependents", []):
|
||||
for svc in inv.named(host, dependent):
|
||||
add(svc["_index"])
|
||||
|
||||
for svc in inv.on(host):
|
||||
if path in svc.get("files", []):
|
||||
add(svc["_index"])
|
||||
if path in svc.get("retires", []):
|
||||
add(svc["_index"])
|
||||
for directory_rule in svc.get("dirs", []):
|
||||
if path == directory_rule or path.startswith(directory_rule + "/"):
|
||||
add(svc["_index"])
|
||||
|
||||
return [svc["name"] for svc in sorted((inv.services[i] for i in hits),
|
||||
key=lambda s: s["_index"])]
|
||||
|
||||
|
||||
def seams_for(inv: Inventory, host: str, paths: list[str]) -> list[str]:
|
||||
"""The Bats seams the selected services require, deduplicated, in order."""
|
||||
seams: list[str] = []
|
||||
for name in select(inv, host, paths):
|
||||
for svc in inv.named(host, name):
|
||||
for seam in svc.get("tests", []):
|
||||
if seam not in seams:
|
||||
seams.append(seam)
|
||||
if seams and "tests/deployment_scoping.bats" not in seams:
|
||||
seams.append("tests/deployment_scoping.bats")
|
||||
return seams
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# renderers + facts
|
||||
# --------------------------------------------------------------------------
|
||||
def render_pihole_hosts(inv: Inventory) -> str:
|
||||
edge = inv.edge_ip()
|
||||
lines = [
|
||||
"# A records for the flat franksdank.cloud scheme (dnsmasq addn-hosts",
|
||||
"# format: <IP> <name>). RENDERED — do not edit by hand:",
|
||||
"#",
|
||||
"# scripts/service-map.py render pihole-hosts > dankclaw/pihole/addn-hosts",
|
||||
"#",
|
||||
"# The source of truth is the `dns:` field of shared/services.yml; the",
|
||||
"# drift gate in tests/service_inventory.bats fails when this file and",
|
||||
"# the inventory disagree. Caddy is consolidated on Dankclaw, so every",
|
||||
"# name — including the services running on dankserver and",
|
||||
"# frankbook-pro — resolves at the Dankclaw edge.",
|
||||
"#",
|
||||
"# dnsmasq strips ONLY whole-line comments in addn-hosts: a trailing",
|
||||
"# comment on a data line becomes a HOSTNAME token. No inline notes",
|
||||
"# here; the service attribution lives in the inventory.",
|
||||
"",
|
||||
]
|
||||
for svc in inv.services:
|
||||
for name in svc.get("dns", []):
|
||||
lines.append(f"{edge} {name}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def scrape_facts(inv: Inventory) -> list[dict[str, str]]:
|
||||
facts = []
|
||||
for svc in inv.services:
|
||||
for job in svc.get("scrape", []):
|
||||
facts.append({"job": job["job"], "target": job["target"],
|
||||
"metrics_path": job.get("metrics_path", "/")})
|
||||
return facts
|
||||
|
||||
|
||||
def prometheus_facts(path: Path) -> list[dict[str, str]]:
|
||||
doc = yaml.safe_load(path.read_text())
|
||||
facts = []
|
||||
for job in doc.get("scrape_configs", []):
|
||||
for sc in job.get("static_configs", []):
|
||||
for target in sc.get("targets", []):
|
||||
facts.append({"job": job["job_name"], "target": target,
|
||||
"metrics_path": job.get("metrics_path", "/")})
|
||||
return facts
|
||||
|
||||
|
||||
def probe_facts(inv: Inventory) -> list[str]:
|
||||
facts: list[str] = []
|
||||
for svc in inv.services:
|
||||
probe = svc.get("probe")
|
||||
if isinstance(probe, dict):
|
||||
facts.extend(str(v) for v in probe.values())
|
||||
elif probe:
|
||||
facts.append(str(probe))
|
||||
return facts
|
||||
|
||||
|
||||
def homepage_probes(path: Path) -> list[str]:
|
||||
return sorted({s["probe"] for s in json.loads(path.read_text()) if s.get("probe")})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# checks
|
||||
# --------------------------------------------------------------------------
|
||||
def check_ownership(inv: Inventory, graph: Graph) -> list[str]:
|
||||
"""Every repo file belongs to a service, a rule, or the unowned list."""
|
||||
problems: list[str] = []
|
||||
exact: set[str] = set()
|
||||
directories: set[str] = set()
|
||||
for svc in inv.services:
|
||||
for host in svc["hosts"]:
|
||||
for directory in inv.unit_dirs(svc, host):
|
||||
for unit in svc.get("units", []):
|
||||
exact.add(f"{directory}/{unit}.container")
|
||||
for field in ("files", "retires"):
|
||||
exact.update(svc.get(field, []))
|
||||
directories.update(svc.get("dirs", []))
|
||||
# A shared network file is claimed by the units that ride it; the unit
|
||||
# graph is the referrer, and check_units proves every reference resolves.
|
||||
exact.update(graph.networks.values())
|
||||
|
||||
def is_claimed(path: str) -> bool:
|
||||
if path in exact:
|
||||
return True
|
||||
first = PurePosixPath(path).parts[0]
|
||||
if first in NON_RUNTIME_ROOTS or path in NON_RUNTIME_PATHS or is_trash(path):
|
||||
return True
|
||||
if PurePosixPath(path).name in NON_RUNTIME_NAMES:
|
||||
return True
|
||||
for owned in directories | set(inv.unowned):
|
||||
if path == owned or path.startswith(owned + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
for path in tracked_files():
|
||||
if not is_claimed(path):
|
||||
problems.append(f"file belongs to no service and is not in unowned: {path}")
|
||||
|
||||
# The reverse direction: an unowned entry that names nothing is stale.
|
||||
for directory in inv.unowned:
|
||||
if not any(p == directory or p.startswith(directory + "/") for p in tracked_files()):
|
||||
problems.append(f"unowned entry names no tracked file: {directory}")
|
||||
|
||||
# Every inventory file/dir claim names a tracked file.
|
||||
for svc in inv.services:
|
||||
# `retires` names a file whose DELETION selects the service, so by
|
||||
# definition it is not in the tree.
|
||||
for field in ("files", "dirs"):
|
||||
for entry in svc.get(field, []):
|
||||
if entry not in tracked_files() and not any(
|
||||
p.startswith(entry + "/") for p in tracked_files()):
|
||||
problems.append(f"{svc['name']}: {field} entry names no tracked file: {entry}")
|
||||
del graph
|
||||
return problems
|
||||
|
||||
|
||||
def check_units(inv: Inventory, graph: Graph) -> list[str]:
|
||||
"""Every unit file has exactly one owner; every unit reference resolves."""
|
||||
problems: list[str] = []
|
||||
owners: dict[str, list[str]] = {}
|
||||
for svc in inv.services:
|
||||
for host in svc["hosts"]:
|
||||
for directory in inv.unit_dirs(svc, host):
|
||||
for unit in svc.get("units", []):
|
||||
path = f"{directory}/{unit}.container"
|
||||
owners.setdefault(path, []).append(f"{svc['name']}@{host}")
|
||||
if path not in tracked_files():
|
||||
problems.append(f"{svc['name']}: unit file is not tracked: {path}")
|
||||
for path in tracked_files():
|
||||
if not path.endswith(".container") or is_trash(path):
|
||||
continue
|
||||
found = owners.get(path, [])
|
||||
if not found:
|
||||
problems.append(f"unit file claimed by no service: {path}")
|
||||
elif len(found) > 1:
|
||||
problems.append(f"unit file claimed by {len(found)} services: {path}: {found}")
|
||||
|
||||
# Root units (scripts/*.service) are claimed by exactly one `files:` list.
|
||||
for path in tracked_files():
|
||||
if not (path.startswith("scripts/") and path.endswith(".service")) or is_trash(path):
|
||||
continue
|
||||
found = [s["name"] for s in inv.services if path in s.get("files", [])]
|
||||
if not found:
|
||||
problems.append(f"root unit claimed by no service: {path}")
|
||||
elif len(found) > 1:
|
||||
problems.append(f"root unit claimed by {found}: {path}")
|
||||
|
||||
# Every Network= reference in a unit file names a file on the same host.
|
||||
for (host, unit), path in graph.units.items():
|
||||
for value in _network_values(REPO_ROOT / path):
|
||||
if value.endswith(".container"):
|
||||
target = (host, value[: -len(".container")])
|
||||
if target not in graph.units:
|
||||
problems.append(f"{path}: Network={value} names no unit on {host}")
|
||||
elif value.endswith(".network"):
|
||||
target = (host, value[: -len(".network")])
|
||||
if target not in graph.networks:
|
||||
problems.append(f"{path}: Network={value} names no network on {host}")
|
||||
return problems
|
||||
|
||||
|
||||
def check_config(inv: Inventory) -> list[str]:
|
||||
problems: list[str] = []
|
||||
for svc in inv.services:
|
||||
label = f"{'+'.join(svc['hosts'])}/{svc['name']}"
|
||||
for key in REQUIRED_KEYS:
|
||||
if key not in svc:
|
||||
problems.append(f"{label}: missing required key {key}")
|
||||
if svc.get("kind") not in KINDS:
|
||||
problems.append(f"{label}: kind {svc.get('kind')!r} not one of {sorted(KINDS)}")
|
||||
unknown = set(svc.get("hosts", [])) - set(inv.hosts)
|
||||
if unknown:
|
||||
problems.append(f"{label}: undeclared hosts: {sorted(unknown)}")
|
||||
if svc.get("units") and not svc.get("unit_dir"):
|
||||
problems.append(f"{label}: units declared without unit_dir")
|
||||
if svc.get("deployed_by") and not inv.any_named(svc["deployed_by"]):
|
||||
problems.append(f"{label}: deployed_by names no service: {svc['deployed_by']}")
|
||||
if (svc.get("health") and svc.get("wait") is None) or \
|
||||
(svc.get("wait") is not None and not svc.get("health")):
|
||||
problems.append(f"{label}: health and wait are a pair")
|
||||
for seam in svc.get("tests", []):
|
||||
if seam not in tracked_files():
|
||||
problems.append(f"{label}: seam does not exist: {seam}")
|
||||
for name in svc.get("dns", []):
|
||||
if not str(name).replace(".", "").replace("-", "").isalnum():
|
||||
problems.append(f"{label}: dns name is not a plain hostname: {name}")
|
||||
if svc.get("kind") == "root-unit" and svc.get("unit_dir"):
|
||||
problems.append(f"{label}: a root unit lives in scripts/, not unit_dir")
|
||||
seen: set[str] = set()
|
||||
for svc in inv.services:
|
||||
key = (svc["name"], tuple(svc["hosts"]))
|
||||
if key in seen:
|
||||
problems.append(f"duplicate inventory entry: {svc['name']} on {svc['hosts']}")
|
||||
seen.add(key)
|
||||
return problems
|
||||
|
||||
|
||||
def check_drift(inv: Inventory) -> list[str]:
|
||||
problems: list[str] = []
|
||||
rendered = render_pihole_hosts(inv)
|
||||
hosts_file = REPO_ROOT / "dankclaw" / "pihole" / "addn-hosts"
|
||||
if hosts_file.read_text() != rendered:
|
||||
problems.append("dankclaw/pihole/addn-hosts is not the render of the inventory "
|
||||
"(scripts/service-map.py render pihole-hosts > dankclaw/pihole/addn-hosts)")
|
||||
|
||||
want = sorted(json.dumps(f, sort_keys=True) for f in scrape_facts(inv))
|
||||
have = sorted(json.dumps(f, sort_keys=True) for f in
|
||||
prometheus_facts(REPO_ROOT / "shared" / "prometheus" / "prometheus.yml"))
|
||||
for extra in [f for f in have if f not in want]:
|
||||
problems.append(f"prometheus scrapes a target the inventory does not declare: {extra}")
|
||||
for missing in [f for f in want if f not in have]:
|
||||
problems.append(f"inventory declares a scrape prometheus.yml lacks: {missing}")
|
||||
|
||||
probes_want = sorted(set(probe_facts(inv)))
|
||||
probes_have = homepage_probes(REPO_ROOT / "shared" / "homepage" / "services.json")
|
||||
for extra in [p for p in probes_have if p not in probes_want]:
|
||||
problems.append(f"homepage probes a target the inventory does not declare: {extra}")
|
||||
for missing in [p for p in probes_want if p not in probes_have]:
|
||||
problems.append(f"inventory declares a probe homepage does not list: {missing}")
|
||||
return problems
|
||||
|
||||
|
||||
CHECKS = {
|
||||
"config": lambda inv, graph: check_config(inv),
|
||||
"units": check_units,
|
||||
"ownership": check_ownership,
|
||||
"drift": lambda inv, graph: check_drift(inv),
|
||||
}
|
||||
|
||||
|
||||
def check(inv: Inventory, names: list[str]) -> list[str]:
|
||||
graph = graph_for(tuple(inv.hosts))
|
||||
problems: list[str] = []
|
||||
for name in names or list(CHECKS):
|
||||
problems.extend(CHECKS[name](inv, graph))
|
||||
return problems
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--inventory", type=Path, default=DEFAULT_INVENTORY)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("hosts")
|
||||
p = sub.add_parser("services")
|
||||
p.add_argument("--host")
|
||||
p = sub.add_parser("allows")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("--service", required=True)
|
||||
p = sub.add_parser("plan")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("paths", nargs="+")
|
||||
p = sub.add_parser("seams")
|
||||
p.add_argument("--host")
|
||||
p.add_argument("paths", nargs="+")
|
||||
p = sub.add_parser("owner")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("path")
|
||||
p = sub.add_parser("units")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("--service", required=True)
|
||||
p = sub.add_parser("stack")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("--service", required=True)
|
||||
p = sub.add_parser("get")
|
||||
p.add_argument("--host")
|
||||
p.add_argument("--service", required=True)
|
||||
p.add_argument("field")
|
||||
p = sub.add_parser("render")
|
||||
p.add_argument("artifact", choices=("pihole-hosts",))
|
||||
p = sub.add_parser("check")
|
||||
p.add_argument("names", nargs="*")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
inv = Inventory.load(args.inventory)
|
||||
|
||||
if args.command == "hosts":
|
||||
print("\n".join(inv.hosts))
|
||||
elif args.command == "services":
|
||||
services = inv.on(args.host) if args.host else inv.services
|
||||
seen: list[str] = []
|
||||
for svc in services:
|
||||
if svc["name"] not in seen:
|
||||
seen.append(svc["name"])
|
||||
print("\n".join(seen))
|
||||
elif args.command == "allows":
|
||||
return 0 if inv.named(args.host, args.service) else 1
|
||||
elif args.command == "plan":
|
||||
print("\n".join(select(inv, args.host, args.paths)))
|
||||
elif args.command == "seams":
|
||||
if args.host is not None:
|
||||
seams = seams_for(inv, args.host, args.paths)
|
||||
else:
|
||||
# No host: the union across every host (the dev machine's
|
||||
# pre-commit mapper has no deployment target).
|
||||
seams = []
|
||||
for host in inv.hosts:
|
||||
for seam in seams_for(inv, host, args.paths):
|
||||
if seam not in seams:
|
||||
seams.append(seam)
|
||||
print("\n".join(seams))
|
||||
elif args.command == "owner":
|
||||
found = select(inv, args.host, [args.path])
|
||||
print(found[-1] if found else "")
|
||||
elif args.command == "units":
|
||||
matches = inv.named(args.host, args.service)
|
||||
if not matches:
|
||||
print(f"no service {args.service} on {args.host}", file=sys.stderr)
|
||||
return 2
|
||||
for svc in matches:
|
||||
for directory in inv.unit_dirs(svc, args.host):
|
||||
for unit in svc.get("units", []):
|
||||
print(f"{unit} {directory}")
|
||||
elif args.command == "stack":
|
||||
matches = inv.named(args.host, args.service)
|
||||
if not matches:
|
||||
print(f"no service {args.service} on {args.host}", file=sys.stderr)
|
||||
return 2
|
||||
owner = matches[0].get("deployed_by") or args.service
|
||||
print(owner)
|
||||
for svc in inv.on(args.host):
|
||||
if svc.get("deployed_by") == owner and svc["name"] != owner:
|
||||
print(svc["name"])
|
||||
elif args.command == "get":
|
||||
matches = inv.on(args.host) if args.host else inv.services
|
||||
matches = [s for s in matches if s["name"] == args.service]
|
||||
if not matches:
|
||||
print(f"no service {args.service}" + (f" on {args.host}" if args.host else ""),
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
values = [s.get(args.field) for s in matches if args.field in s]
|
||||
print(json.dumps(values[0] if len(values) == 1 else values))
|
||||
elif args.command == "render":
|
||||
sys.stdout.write(render_pihole_hosts(inv))
|
||||
elif args.command == "check":
|
||||
problems = check(inv, args.names)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"FAIL: {problem}", file=sys.stderr)
|
||||
return 1
|
||||
print("OK: the service inventory is sound (schema, units, ownership, drift)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+57
-190
@@ -62,234 +62,101 @@ declare -a TESTS=()
|
||||
add_test() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user