ADR-0059: Service catalog — auto-generated per-service home pages from the Alloy probe inventory
A docs service catalog: one auto-generated home page per homelab service, published under
docs.pitbun.com/services/<domain>/<service>/. Each page is a full service card (identity,
endpoints, observability model, dependencies, live dashboard + rollup links) generated from the
same Alloy blackbox probe inventory that already defines the Grafana per-service dashboards
and the dynamic service-health rollup. One declarative inventory, three renderers (dashboards,
rollup, catalog). This is the homelab’s lightweight take on the service-catalog / Backstage
pattern — a single source of truth fanned out to every consumer.
| Status | Accepted |
| Date | 2026-06-27 |
| Epic | ADO #1252 |
| Extended by | ADR-0063 (umbrella/parent pages over duplicated or grouped components) |
Context
Section titled “Context”Per-service dashboards (ADR-0045) and the dynamic rollup gave us a single observability plane
keyed on the probe_success{service,domain} blackbox inventory in config.alloy. But there was
no knowledge plane: no canonical page per service stating what it is, where it lives, how it’s
reached, and how it’s observed. Pillar 5 (docs-as-code) and Pillar 2 (a conforming page per
service) were unmet for the fleet as a whole.
The constraints that shaped the design:
- The service set must not be re-declared. A second hand-maintained list of services would drift from the Alloy inventory immediately. Whatever drives the catalog must read the same inventory the dashboards and rollup read.
- New services must self-onboard. A page must appear when a service comes online (i.e. when
its probe target lands in
config.alloy) with no manual step — “ensure they’re created as new services come online” was the explicit requirement. claude -pcannot run in the docs pipeline. The narrative blurb (Prime Directive 9) needs an LLM, but the Anthropic API key lives on control01, not on the ADO build agent. The six existingdoc_gengenerators that useclaude -p(e.g.generate_ansible_docs.py) all run on control01 and commit their output; the docs pipeline only ever publishes committed files.
Decision
Section titled “Decision”-
Source of truth = the Alloy probe inventory.
generate_service_homepages.py(a seventhdoc_gengenerator, house shape:argparse --src/--out, md5 content-hash cache,claude -pnarrative,--no-llmfallback) parsesconfig.alloy, groups probe targets byservice, and emits one card per service plus a generated landing index and.pagesnav. -
Fully automatic, no hand-authored layer. Every field — including the Prime-Directive-9 blurb — is generated. The blurb is produced by
claude -pfrom the service’s facts and content-hash cached, so it regenerates only when the facts change. There is no per-service override file: if a blurb is wrong, fix the facts or the generator prompt. -
control01 cron + commit mechanism. The generator runs on control01 (where the key lives), commits the rendered pages into
pitlab-docswith[skip ci], and pushes. The docs pipeline then publishes them like any committed section (a wholesale per-section copy indocs.yml+ aroot.pagesentry). Pages are git-visible and diffable. Regeneration is triggered by the docker-stacks observability deploy (the flow that ships a new probe target) plus a nightly cron safety net. -
Full bidirectional linking. Pages link to
/d/<service>and the rollup; every catalog dashboard links back to its doc page (see ADR-0045, amended). The bulk-inject and the enforcing conformance gate live in docker-stacks. -
Domain-grouped, vocab-canonicalised tags. Pages live at
services/<domain>/<service>.md(the 8 rollup domains). Taggedkind: generated+ the domain; the short Alloy codesinfraandnetworkmap to the existing vocab tagsinfrastructure/networkingso the tag index stays cohesive (onlyappwas added to the vocabulary).
Alternatives considered
Section titled “Alternatives considered”- A new dedicated
services.ymlregistry. Richer per-service fields, but a second source to keep in sync with Alloy — exactly the drift we refused. Rejected. - Scan docker-compose stacks for the service set. Captures Docker workloads but misses host-based services (plex, pve, zabbix, samba) that have probe targets but no compose file. Rejected.
- Query Prometheus
probe_successlabels (like the rollup) instead of parsing the file. Truest mirror of the rollup, but the labels carry no endpoints/module, so the full card can’t be built from them, and it adds a live Prometheus dependency in the generator. Rejected in favour of parsing the committed desired-state. - Hand-authored blurb, layered over generated card. Was the initial choice; reversed mid-design — the blurb had to be automatic so new services need zero human step. With an auto blurb the override layer lost its only justification and was dropped.
- Build-time generation in the docs pipeline (ephemeral pages). Was the initial choice;
reversed once it was confirmed
claude -pcan’t run on the build agent (no key). The control01 cron + commit model is the established house pattern for LLM-using generators. - Put an Anthropic key on the build agent. Would allow build-time generation but adds an LLM secret to the build agent’s blast radius and diverges from the house pattern. Rejected on DevSecOps grounds.
Consequences
Section titled “Consequences”- A complete service catalog ships for all 44 Alloy services with real
claude -pblurbs; new services self-onboard with no manual step. The docs site is Cloudflare-Access-gated, so the full internal detail (endpoints, deps) is an internal surface and safe to publish. - The generator’s
.doc-cache.jsonis committed (persists blurbs across cron runs); the docs build strips it from the published tree. - The build is governed by an explicit per-section copy; a future top-level section still needs its
copy block +
root.pagesentry (the standing docs-pipeline gotcha). - Pillar-2 gaps surfaced by the work (4 services with no dashboard, 6 dashboards with no probe) are tracked as ADO debt (#1258, #1260), not silently ignored.