feat(lab): build stacks/lab — Prometheus, Loki, Grafana, Alloy on a guest (#264) - #276
Merged
Conversation
) `STACK ?=` reached the lifecycle targets and stopped. Underneath, four scripts assumed the estate's stack was the only one, and each would have failed — or worse, quietly done the wrong thing — the first time a second one existed. render-config.sh demanded all ten of the estate's keys from any stack. Every render was already guarded by `[[ -f ... ]]`, so the script coped with a stack that has no snmp-exporter; the required-key list did not, and `make render STACK=lab` died naming four SNMP communities and four Alertmanager URLs the lab has no use for. The list is now derived: the Grafana half from the stack's own compose.yaml, because a ${VAR:?} guard IS the declaration that a service cannot start without it, and a second copy could only drift from it — the same derivation seed-validation-env.sh already runs against the same guards. The SNMP and Alertmanager halves stay conditional on their input files existing. For stacks/observability this produces the identical ten names, verified against main's array rather than reasoned about. reload-config.sh reloaded a SERVICES array that is the union across stacks, so it died on the first service a stack does not have — through the "is not running, so there is nothing to reload" path, which is the right message for a stopped service and the wrong one for an absent one. It now filters on what the compose file DECLARES, from `compose config --services`, so those two cases stay separate: a declared service that is down is still a failed deploy. bootstrap.sh filled in "the first placeholder found" in .sops.yaml. With a rule per stack that writes the lab guest's key into the estate's rule, or the reverse. It now looks for this stack's placeholder, and refuses outright when the key it would write is already a recipient of another rule — which is exactly what `make secrets-init STACK=lab` typed on the monitoring host would do, and it would have looked like a successful bootstrap. backup-firewall.sh read the age recipient as the first age1 key in .sops.yaml, which was the right key while there was only one rule. It now reads the key from the rule that actually covers backups/firewall/, because the moment the lab rule's placeholder becomes real, `head -1` would encrypt every firewall export to the lab guest — silently, and sops would do it happily. .sops.yaml gets that lab rule, above the catch-all, because the catch-all matches all of secrets/: a recipient added there can decrypt the estate's SNMP communities and Grafana admin password too. ADR-0020. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR-0020 settled the shape; this builds it. Four services, on a guest (`alexander`, 10.0.30.40) rather than on `Saruman` itself, because a compose stack is Docker and Docker rewrites the iptables of the box whose own firewall ADR-0014 relies on. Prometheus is here because ADR-0007 named three services while also saying config.alloy is reused with only the two *_URL variables changed, and that file has two sinks. Without it the second points at 10.0.99.20, which the Decision forbids. The agent config is MOUNTED from stacks/observability/alloy/, not copied — two files, not the directory, so syslog.alloy stays on the monitoring host where it belongs. deploy-agent.sh exists because `oracle` drifted four ways from a hand-copied agent, and its header states the rule: the fix is to not copy. Absent on purpose: no Alertmanager (nothing in the lab pages, and that is not an answer to #257), no snmp-exporter (the estate already polls `shiva`; two stacks polling one device is two answers to when it last responded), no blackbox-exporter, no renderer, no dashboards. The dashboards README says why a copy of the estate's seven would be four rows of empty panels, and an empty panel is indistinguishable from a broken collector. Prometheus and Loki are exposed, not published: ADR-0012 publishes a port only when something off-host uses it, and today only Alloy talks to them. compose.yaml marks the lines to uncomment when #265 gives them real clients. Retention is 15d/4GB and is a bound rather than a measurement — the stack has never run, so there is nothing to derive from. compose.yaml carries the queries to re-derive it, and PrometheusSizeRetentionActive is what says the ceiling started binding. Four rules, all four unit-tested firing and quiet, because a case that only expects silence passes against a rule that cannot fire (#63). check_docs.py grew the case it was never designed for: a stack committed before its host is racked. The marker inverts the check rather than switching it off — a normal row's host must appear in network.md, a row marked "not built yet" must be absent from it — so racking the host and adding its row fails, saying the marker is stale. It also drops such a row from the Alloy agent count, which makes removing the marker fail hardware.md's "three Alloy agents" on the same commit, which is when that sentence should have to change. All three new branches were verified to fire. Not covered, and written down rather than discovered: no validator runs against this stack (#263), and converge.sh runs a bare `make up`, so nothing deploys it either — it is applied by hand, on that guest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
It changes stack-critical operational scripts and SOPS key-selection behavior, which warrants a final human review of failure modes despite only minor requested adjustments.
Pull request overview
This PR introduces a new stacks/lab compose stack (Prometheus, Loki, Grafana, Alloy) intended to run on a VLAN 30 guest host, and refactors existing tooling/docs to support multiple stacks without leaking lab secrets or misapplying stack-specific operations.
Changes:
- Add
stacks/lab/with compose, configs, Prometheus rules + promtool unit tests, and stack-specific documentation. - Make key operational scripts stack-aware (
render-config.sh,reload-config.sh,bootstrap.sh,backup-firewall.sh) and update.sops.yamlto isolate lab secrets with a dedicated creation rule. - Update documentation to describe the new stack and support “planned/not built yet” host rows with additional assertions in
check_docs.py.
File summaries
| File | Description |
|---|---|
| stacks/lab/README.md | Documents the lab stack purpose, layout, and manual validation steps. |
| stacks/lab/prometheus/tests/lab.test.yaml | Adds promtool unit tests covering all lab alert rules (firing + quiet cases). |
| stacks/lab/prometheus/rules/lab.rules.yaml | Introduces lab self-monitoring alert rules (no paging / no Alertmanager). |
| stacks/lab/prometheus/prometheus.yaml | Lab Prometheus scrape config and external labels (segment identification). |
| stacks/lab/loki/loki-config.yaml | Lab Loki single-binary filesystem config with 15d retention and no ruler. |
| stacks/lab/grafana/provisioning/datasources/datasources.yaml | Provisions Prometheus + Loki datasources for the lab Grafana. |
| stacks/lab/grafana/provisioning/dashboards/dashboards.yaml | Provisions an intentionally empty dashboards directory (future dashboards). |
| stacks/lab/grafana/dashboards/README.md | Explains why dashboards are intentionally absent and how to add them later. |
| stacks/lab/compose.yaml | Defines the lab compose stack (4 services) with retention bounds and isolation. |
| stacks/lab/.env.example | Adds non-sensitive tunables for the lab stack (ports/retention). |
| secrets/lab.example.yaml | Adds lab secrets template (Grafana admin password) and key-isolation guidance. |
| scripts/render-config.sh | Derives required keys per-stack (compose guards + conditional SNMP/AM). |
| scripts/reload-config.sh | Filters reload targets to only services declared by the selected stack. |
| scripts/check_docs.py | Adds “Not built yet” handling/inverted checks and excludes planned agents from counts. |
| scripts/bootstrap.sh | Makes SOPS placeholder selection stack-specific; refuses collapsing stack recipients. |
| scripts/backup-firewall.sh | Reads SOPS age recipient from the rule covering backups/firewall/ (not first key). |
| README.md | Updates repo tree overview to include stacks/lab. |
| docs/roadmap.md | Updates roadmap status to reflect lab stack now built and tooling updated. |
| docs/observability.md | Clarifies this doc is estate-only; notes existence and isolation of lab stack. |
| docs/network.md | Adds planned guest narrative while keeping inventory table accurate. |
| docs/architecture.md | Adds host/stack mapping row for alexander marked “Not built yet”. |
| .sops.yaml | Adds dedicated creation rule for lab secrets above catch-all to prevent trust inversion. |
| .github/dependabot.yml | Adds Dependabot updates for /stacks/lab docker-compose pins. |
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
356
to
+360
| for entry in "${SERVICES[@]}"; do | ||
| reload_one "${entry%%:*}" "${entry##*:}" | ||
| svc="${entry%%:*}" | ||
| if [[ -n "${declared}" ]] && ! grep -qx "${svc}" <<<"${declared}"; then | ||
| continue | ||
| fi |
This was referenced Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds #264. Shape settled by
ADR-0020
(numbered 0019 when it merged; main renumbered it).
Two commits, each independently green:
refactor(scripts)— four scripts made stack-aware. Nothing in itdepends on the new stack existing.
feat(lab)— the stack, its docs rows, and the one checker change theyneed.
What the stack is
Four services on
alexander(10.0.30.40), a guest onSaruman— not thehypervisor, because a compose stack is Docker and Docker rewrites the iptables
of the box whose own firewall ADR-0014 relies on.
prometheusis the fourth service ADR-0007 did not name. That ADR listed threewhile also saying
config.alloyis reused with only the two*_URLvariableschanged, and that file has two sinks — so without a lab Prometheus the second
points at
10.0.99.20, which the Decision forbids.The agent config is mounted, not copied.
compose.yamlbind-mountsconfig.alloyanddocker.alloyout of../observability/alloy/. Two filesrather than the directory, so
syslog.alloy— the firewall's log listener —stays on the monitoring host.
deploy-agent.shexists becauseoracledriftedfour separate ways from a hand-copied agent setup, and its header states the
rule: "the fix is to not copy."
Absent on purpose, each with the reasoning in the file: no Alertmanager, no
snmp-exporter, no blackbox-exporter, no renderer, no dashboards. On the
last: copying the estate's seven would render four rows of empty panels and one
that works, and an empty panel is indistinguishable from a broken collector —
which is the failure this repo has been bitten by most (#62, #63, #71).
Prometheus and Loki are
exposed, not published. ADR-0012 publishes a portonly when something off-host uses it, and today only Alloy talks to them;
compose.yamlmarks the exact lines to uncomment when#265 gives them real clients.
Retention is 15d/4GB and is a bound, not a measurement — the stack has
never run, so there is nothing to derive from.
compose.yamlcarries thequeries to re-derive it against the spindles, and
PrometheusSizeRetentionActiveis what says the ceiling started binding.
The tooling had to move first
Four scripts assumed one stack. Each would have failed, or quietly done the
wrong thing, the first time a second existed:
render-config.shmake render STACK=labdied naming four SNMP communities and four Alertmanager URLs the lab has no use for. Now derived — the Grafana half from the stack's own${VAR:?}guards, SNMP and Alertmanager conditional on their input files.reload-config.shSERVICESarray that is the union across stacks, dying on the first service a stack lacks — through the "is not running" path, which is right for a stopped service and wrong for an absent one. Now filters oncompose config --services, keeping those two cases separate.bootstrap.sh.sops.yaml. Now finds this stack's, and refuses when the key it would write is already a recipient of another rule.backup-firewall.shage1key in.sops.yaml. Correct with one rule; with the lab rule above it,head -1would encrypt every firewall export to the lab guest the moment that placeholder became real. Now reads the key from the rule coveringbackups/firewall/..sops.yamlgets the lab rule ADR-0020 asked for, above the catch-all — thecatch-all matches all of
secrets/, so a recipient added there could decryptthe estate's SNMP communities and Grafana admin password.
The
render-config.shchange touches the live estate deploy, so it wasverified rather than reasoned about: the derived list for
stacks/observabilityis byte-identical to the ten-name array onmain,checked by running the real code path from the file against both stacks.
One checker change, and it adds assertions
check_host_stack_tablefails on anystacks/directory noarchitecture.mdrow names, and those rows must match a host in
network.md.alexanderis#262 and is not on the wire, so
writing it into
network.mdwould be exactly the false claimcheck_docs.pyexists to catch — and would mean inventing a MAC, a device and an OS for a
machine whose distribution is explicitly undecided.
So the row is marked
**Not built yet**, and the marker inverts the checkrather than switching it off:
network.md;network.mdhas a table for;That makes the marker self-clearing — rack the host, add its
network.mdrow,and the check fails saying the marker is stale. It also drops such a row from
count_alloy_agents, so removing the marker pushes that count to four andfails
hardware.md's "three Alloy agents" on the same commit, which is whenthat sentence should be forced to change.
All three new branches were verified to fire by temporarily introducing each
fault, not just by reading the code.
Verification
make validate— all checks passed, 2 skips (the usual "not the deploymentcheckout" pair).
docker compose -f stacks/lab/compose.yaml config -q— valid;config --servicesreturns exactly the four, which is what the newreload-config.shfilter reads.promtool check config,check rules,test rulesagainst the lab's filesin the pinned image — all SUCCESS. Four rules, each with a firing case and a
quiet case, because a case that only expects silence passes against a rule
that cannot fire (ContainerHighMemory cannot fire — no service sets a memory limit #63).
Not done, and written down rather than left to be discovered
#263 is untouched — this PR
deliberately did not widen its scope into it.
landed while this was in progress;
converge.shruns a baremake up, whichis
STACK=observabilityon the monitoring host. The lab is applied by hand.#257, unchanged. ADR-0020
decided only that no Alertmanager goes inside the stack.
and the guest does not exist yet (Build the guest on Saruman that stacks/lab/ runs in #262). Nothing on
Sarumanwas touched.🤖 Generated with Claude Code