feat(rank6): node-exporter renders from a template; guard chain is one snippet
- service-map render node-exporter-unit --host H: the MIRROR pair comment
('keep this file in step') becomes a template. Per-host title, LAN
address from the hosts map, port from the node-<host> scrape job. Both
tracked quadlets are now rendered artifacts; check_drift gates them.
- dankclaw/Caddyfile: the nine verbatim Anubis pre-auth chains
(reverse_proxy anubis:8923 + X-Real-Ip) collapse into the (guarded)
named snippet; guarded vhosts are 'import guarded'. Validated with real
caddy 2.11.7 (import resolution clean; the stock image stops at the
porkbun module and the git basicauth placeholder, as designed).
- vhost-scoped guard greps (authentik, homepage_caddy, marreta_quadlet)
now pin 'import guarded' in the vhost block.
- Service Inventory + Authentik docs carry the new laws.
This commit is contained in:
+103
-2
@@ -100,6 +100,12 @@ class Inventory:
|
||||
return str(host["dns_edge"])
|
||||
raise SystemExit(f"{self.origin}: no host declares dns_edge")
|
||||
|
||||
def service(self, name: str) -> dict | None:
|
||||
for svc in self.services:
|
||||
if svc["name"] == name:
|
||||
return svc
|
||||
return None
|
||||
|
||||
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")
|
||||
@@ -492,6 +498,85 @@ def check_config(inv: Inventory) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
# --- node-exporter quadlet template (rank 6) --------------------------------
|
||||
# The two node-exporter units were a MIRROR pair kept in step by a comment.
|
||||
# The template is now the law; `check` drift-gates both tracked copies.
|
||||
NODE_EXPORTER_TEMPLATE = """\
|
||||
# Quadlet unit: node-exporter (rootless, boot-persistent) — RENDERED ARTIFACT.
|
||||
# Regenerate: python3 scripts/service-map.py render node-exporter-unit --host {host}
|
||||
# Source: shared/services.yml (entry node-exporter) + this template.
|
||||
#
|
||||
# Install path on {host}: ~/.config/containers/systemd/node-exporter.container
|
||||
# Unit name: node-exporter.service (container name: node-exporter)
|
||||
#
|
||||
# Host metrics source for Prometheus. Replaces the compose service of the same
|
||||
# name (the "Observability" block): Dankclaw moved to this quadlet on
|
||||
# 2026-09-11, Dankserver on 2026-09-16 — its compose-only copy died at a reboot
|
||||
# and stayed dead (rootless Podman does not restart bare containers after the
|
||||
# explicit stop shutdown performs), dead for a day and a half before anyone
|
||||
# noticed. One owner per container name; no compose file remains anywhere.
|
||||
#
|
||||
# HOST NAMESPACE. It must run in the host network namespace to report the
|
||||
# host's own interfaces/connections, and --pid=host for the processes and
|
||||
# cgroups collectors. "localhost" inside the container is therefore the host's
|
||||
# loopback, which is what the homepage status timer probes.
|
||||
#
|
||||
# LISTEN ADDRESSES. Exactly two: loopback for the homepage status probe, and
|
||||
# the LAN address, because Prometheus — which runs in a container on
|
||||
# dankclaw_default — scrapes http://{lan}:{port}/metrics
|
||||
# (shared/prometheus/prometheus.yml, job "node-{host}"). node_exporter has no
|
||||
# authentication, so the wildcard bind stays off the table; this is the
|
||||
# pairing that removes the unauthenticated observability surface on every
|
||||
# host, and both consumers are accounted for.
|
||||
#
|
||||
# MOUNTS. The container reads the host's /proc, /sys and / as /host/*
|
||||
# read-only and is pointed at them with --path.*; it writes nothing anywhere.
|
||||
|
||||
[Unit]
|
||||
Description=Prometheus node exporter ({title} host metrics)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
ContainerName=node-exporter
|
||||
Image=docker.io/prom/node-exporter:latest
|
||||
Network=host
|
||||
PodmanArgs=--pid=host
|
||||
Exec=--path.procfs=/host/proc --path.sysfs=/host/sys --path.rootfs=/rootfs --web.listen-address=127.0.0.1:{port} --web.listen-address={lan}:{port}
|
||||
Volume=/proc:/host/proc:ro
|
||||
Volume=/sys:/host/sys:ro
|
||||
Volume=/:/rootfs:ro
|
||||
HealthCmd=wget --spider -q http://localhost:{port}/metrics
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
HealthStartPeriod=30s
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
|
||||
def render_node_exporter_unit(inv: Inventory, host: str) -> str:
|
||||
"""The node-exporter quadlet for one host: shared template, per-host
|
||||
title, LAN address from the hosts map, port from the scrape job."""
|
||||
svc = inv.service("node-exporter")
|
||||
if svc is None:
|
||||
raise SystemExit("inventory has no node-exporter entry")
|
||||
job = f"node-{host}"
|
||||
port = next((str(e["target"]).rsplit(":", 1)[1] for e in svc.get("scrape", [])
|
||||
if e.get("job") == job), None)
|
||||
if port is None:
|
||||
raise SystemExit(f"node-exporter scrape declares no job {job}")
|
||||
lan = inv.hosts[host]["address"]
|
||||
return NODE_EXPORTER_TEMPLATE.format(host=host, title=host.capitalize(),
|
||||
lan=lan, port=port)
|
||||
|
||||
|
||||
def check_drift(inv: Inventory) -> list[str]:
|
||||
problems: list[str] = []
|
||||
rendered = render_pihole_hosts(inv)
|
||||
@@ -508,6 +593,15 @@ def check_drift(inv: Inventory) -> list[str]:
|
||||
for missing in [f for f in want if f not in have]:
|
||||
problems.append(f"inventory declares a scrape prometheus.yml lacks: {missing}")
|
||||
|
||||
svc = inv.service("node-exporter")
|
||||
for host in (svc or {}).get("hosts", []):
|
||||
for directory in inv.unit_dirs(svc, host):
|
||||
path = REPO_ROOT / directory / "node-exporter.container"
|
||||
if path.exists() and path.read_text() != render_node_exporter_unit(inv, host):
|
||||
problems.append(
|
||||
f"{directory}/node-exporter.container is not the render of the "
|
||||
f"template (scripts/service-map.py render node-exporter-unit --host {host})")
|
||||
|
||||
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]:
|
||||
@@ -568,7 +662,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
p.add_argument("--service", required=True)
|
||||
p.add_argument("field")
|
||||
p = sub.add_parser("render")
|
||||
p.add_argument("artifact", choices=("pihole-hosts",))
|
||||
p.add_argument("artifact", choices=("pihole-hosts", "node-exporter-unit"))
|
||||
p.add_argument("--host", choices=list(Hosts) if False else None)
|
||||
p = sub.add_parser("check")
|
||||
p.add_argument("names", nargs="*")
|
||||
|
||||
@@ -632,7 +727,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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))
|
||||
if args.artifact == "pihole-hosts":
|
||||
sys.stdout.write(render_pihole_hosts(inv))
|
||||
else:
|
||||
if not args.host:
|
||||
print("render node-exporter-unit requires --host", file=sys.stderr)
|
||||
return 2
|
||||
sys.stdout.write(render_node_exporter_unit(inv, args.host))
|
||||
elif args.command == "check":
|
||||
problems = check(inv, args.names)
|
||||
if problems:
|
||||
|
||||
Reference in New Issue
Block a user