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.
71 lines
2.5 KiB
Python
Executable File
71 lines
2.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""deploy-plan.py — map changed paths to deployable services.
|
|
|
|
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.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
SERVICE_MAP = os.path.join(SCRIPT_DIR, "service-map.py")
|
|
|
|
|
|
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:
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|