Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ incident.
- **CI that validates the documentation too.** Six assertions cross-check
this prose against the configs it describes — rule and panel counts, the
SNMP inventory against `docs/network.md`, the host/stack and ports tables
against `compose.yaml`, and every image version quoted in Markdown. A
document that disagrees with the repository fails the build.
against `compose.yaml`, and a ban on image versions in prose — Dependabot
edits only `compose.yaml`, so a version written anywhere else is stale
from the next bump. A document that disagrees with the repository fails
the build.
- **Supply chain pinned by digest.** Every image carries both a tag and a
`sha256:` digest, so a moved tag cannot change what deploys. CI enforces it;
`make pin-digests` re-resolves them from the registry.
Expand Down
44 changes: 30 additions & 14 deletions scripts/check_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
3. Host and stack table docs/architecture.md <-> docs/network.md, stacks/
4. Ports table docs/architecture.md <-> compose.yaml
5. Compute table docs/hardware.md <-> docs/network.md
6. Image versions prose <-> compose.yaml
6. Image versions no version pins in prose; compose.yaml owns them

Only present-tense documents are checked. `docs/roadmap.md` and `docs/adr/`
record what was true when the work landed — `roadmap.md` still says "(34 rules)"
Expand Down Expand Up @@ -468,20 +468,35 @@ def check_compute_table() -> list[str]:


# ---------------------------------------------------------------------------
# 6. Image versions quoted in prose
# 6. Image versions must not appear in prose at all
# ---------------------------------------------------------------------------
def check_image_versions() -> list[str]:
pinned = {}
"""compose.yaml is the only place an image version may appear.

An earlier version of this check asserted that a version quoted in prose
*matched* compose.yaml. That keeps the documents honest but keeps the
duplication, and the duplication is the actual defect: Dependabot edits
only compose.yaml, so a version written anywhere else is stale from the
next bump onward. All six in the stack README went stale exactly that way
(#73), and matching them would have made every Dependabot PR red until
someone hand-edited a table.

ci.yml already refuses duplicated pins in shell, YAML and the Makefile —
"hardcoded copies went stale silently" — but its grep does not cover
Markdown, which is how the six survived. This is that rule, extended to
prose.
"""
pinned = set()
doc = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))
for svc in (doc.get("services") or {}).values():
image = str((svc or {}).get("image", ""))
if not image:
continue
repository, _, tag = image.partition("@")[0].rpartition(":")
pinned[repository] = tag
if image:
pinned.add(image.partition("@")[0].rpartition(":")[0])

# Only inline code spans. Prose mentioning an image without a version, and
# anything inside a URL, is not a pin and must not be treated as one.
# Only inline code spans, and only a repository compose.yaml actually
# pins. An OS version in a hardware table is not an image pin, and neither
# is a registry path mentioned without a tag — naming the image is fine,
# naming its version is what goes stale.
span = re.compile(r"`([a-z0-9][a-z0-9._-]*/[a-z0-9][a-z0-9._-]*):([^`\s]+)`")
problems = []
for rel in PROSE:
Expand All @@ -490,11 +505,12 @@ def check_image_versions() -> list[str]:
continue
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
for match in span.finditer(line):
repository, tag = match.group(1), match.group(2).split("@")[0]
if repository in pinned and tag != pinned[repository]:
repository = match.group(1)
if repository in pinned:
problems.append(
f"{rel}:{n} says {repository}:{tag}; compose.yaml pins "
f"{pinned[repository]}"
f"{rel}:{n} pins {repository}:{match.group(2)}; only "
f"compose.yaml may carry a version — drop the tag and "
f"write `{repository}`"
)
return problems

Expand All @@ -508,7 +524,7 @@ def main() -> int:
("host and stack mapping", check_host_stack_table),
("ports table against compose.yaml", check_ports),
("compute table against docs/network.md", check_compute_table),
("image versions quoted in prose", check_image_versions),
("image versions in prose (compose.yaml owns them)", check_image_versions),
)

total = 0
Expand Down
21 changes: 14 additions & 7 deletions stacks/observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ make up # from the repository root

| Service | Image | Port | Purpose |
| --- | --- | --- | --- |
| `prometheus` | `prom/prometheus:v3.14.0` | 9090 | Metrics store, remote-write receiver, rule evaluation |
| `alertmanager` | `prom/alertmanager:v0.34.0` | 9093 | Alert routing, grouping, inhibition |
| `loki` | `grafana/loki:3.7.6` | 3100 | Log store |
| `grafana` | `grafana/grafana-oss:13.0.2` | 3000 | Dashboards |
| `snmp-exporter` | `prom/snmp-exporter:v0.30.1` | *internal* | SNMP polling proxy |
| `alloy` | `grafana/alloy:v1.18.1` | 12345 (localhost) | Metric and log collection |
| `prometheus` | `prom/prometheus` | 9090 | Metrics store, remote-write receiver, rule evaluation |
| `alertmanager` | `prom/alertmanager` | 9093 | Alert routing, grouping, inhibition |
| `loki` | `grafana/loki` | 3100 | Log store |
| `grafana` | `grafana/grafana-oss` | 3000 | Dashboards |
| `snmp-exporter` | `prom/snmp-exporter` | *internal* | SNMP polling proxy |
| `alloy` | `grafana/alloy` | 12345 (localhost) | Metric and log collection |

## Layout

Expand Down Expand Up @@ -50,7 +50,14 @@ grafana/
block dropped.
- **Adding an SNMP target needs no restart** — file_sd re-reads every 5 minutes.
Adding a *module* does, because snmp-exporter reads its config once.
- **Image tags are pinned.** CI fails on `:latest`. Dependabot proposes bumps.
- **Image tags are pinned, and the versions are not repeated here.** Every image
in `compose.yaml` carries a tag *and* a `sha256:` digest; CI fails on
`:latest` and on any image missing a digest, and Dependabot proposes bumps.
The table above deliberately names images without versions — Dependabot only
edits `compose.yaml`, so a version written anywhere else goes stale the moment
it lands, which is what happened to all six of these (#73).
`scripts/check_docs.py` now fails the build if a version pin reappears in
prose. `docker compose images` prints what is actually running.

## Validate before deploying

Expand Down