diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d96aa4e..fde80aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,18 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - STACK: stacks/observability - # Image versions are NOT duplicated here. They are resolved from compose.yaml - # at run time by scripts/image-for.sh, because Dependabot only updates - # compose.yaml — hardcoded copies went stale silently and CI ended up - # validating v3.1.0 configs against a stack running v3.13.2. gitleaks was the - # last exception to that and is now a profile-gated service in compose.yaml - # like every other image (#65). +# There is no `env: STACK:` here any more. It pinned every step below to +# stacks/observability, which is why stacks/lab landed as a stack CI had never +# seen (#263, #264). Each step loops over ./scripts/stacks.sh instead — the one +# place that defines what a stack is, and the thing that fails when a directory +# under stacks/ has no compose.yaml. +# +# Image versions are NOT duplicated here either. They are resolved from +# compose.yaml at run time by scripts/image-for.sh, because Dependabot only +# updates compose.yaml — hardcoded copies went stale silently and CI ended up +# validating v3.1.0 configs against a stack running v3.13.2. gitleaks was the +# last exception to that and is now a profile-gated service in compose.yaml +# like every other image (#65). jobs: # --------------------------------------------------------------------------- @@ -64,8 +68,14 @@ jobs: # needs a decryption key. The list of variables lives in the script, which # scripts/validate.sh also calls — inlining it here is what let CI and the # local run drift apart. The .env written here stays gitignored. + # Per stack, because the guard list is derived from each stack's own + # compose.yaml — seeding one from another's guards proves nothing about + # the file being validated. - name: Seed a validation-only .env - run: ./scripts/seed-validation-env.sh "$STACK/.env" + run: | + for stack in $(./scripts/stacks.sh); do + ./scripts/seed-validation-env.sh "stacks/$stack/.env" "$stack" + done # Single source of truth: whatever compose.yaml pins is what gets tested. - name: Resolve pinned images from compose.yaml @@ -80,7 +90,11 @@ jobs: ./scripts/image-for.sh alloy - name: docker compose config - run: docker compose -f "$STACK/compose.yaml" config -q + run: | + for stack in $(./scripts/stacks.sh); do + echo "== $stack" + docker compose -f "stacks/$stack/compose.yaml" config -q + done # Guard against the duplication coming back. Any image: pin outside # compose.yaml is drift waiting to happen, since Dependabot cannot see it. @@ -125,7 +139,9 @@ jobs: *@sha256:*) ;; *) echo "::error::$ref is not pinned by digest — run make pin-digests"; missing=1 ;; esac - done < <(awk '$1 == "image:" { print $2 }' "$STACK/compose.yaml") + done < <(./scripts/stacks.sh --paths | while read -r sd; do + awk '$1 == "image:" { print $2 }' "$sd/compose.yaml" + done) exit "$missing" # The three checks above are all pattern matches, and #65 walked past all @@ -141,33 +157,57 @@ jobs: - name: Verify every docker image comes from compose.yaml run: python3 scripts/check_image_pins.py - - name: promtool check config + # Every stack that runs a Prometheus. A stack with rules and no unit + # tests fails, which is stricter than the single-stack version was: rules + # that cannot fire pass `check rules` (#63), so the moment to notice a + # stack has none is when its rules land. + - name: promtool check config, check rules, test rules run: | - docker run --rm --entrypoint promtool \ - -v "$PWD:/repo" -w /repo "$PROM_IMAGE" \ - check config "$STACK/prometheus/prometheus.yaml" - - - name: promtool check rules - run: | - docker run --rm --entrypoint promtool \ - -v "$PWD:/repo" -w /repo "$PROM_IMAGE" \ - check rules "$STACK"/prometheus/rules/*.rules.yaml - - # check rules only parses PromQL. It passed for months against a rule that - # could not fire for any input (#63); these are the tests that catch that. - - name: promtool test rules - run: | - docker run --rm --entrypoint promtool \ - -v "$PWD:/repo" -w /repo "$PROM_IMAGE" \ - test rules "$STACK"/prometheus/tests/*.test.yaml + fail=0 + for stack in $(./scripts/stacks.sh); do + sd="stacks/$stack" + [ -f "$sd/prometheus/prometheus.yaml" ] || { echo "== $stack: no prometheus.yaml"; continue; } + echo "== $stack" + docker run --rm --entrypoint promtool \ + -v "$PWD:/repo" -w /repo "$PROM_IMAGE" \ + check config "$sd/prometheus/prometheus.yaml" || fail=1 + + rules=$(find "$sd/prometheus/rules" -name '*.rules.yaml' 2>/dev/null | sort) + tests=$(find "$sd/prometheus/tests" -name '*.test.yaml' 2>/dev/null | sort) + if [ -z "$rules" ]; then + echo " no alert rules" + continue + fi + # shellcheck disable=SC2086 + docker run --rm --entrypoint promtool \ + -v "$PWD:/repo" -w /repo "$PROM_IMAGE" check rules $rules || fail=1 + if [ -z "$tests" ]; then + echo "::error::$stack has alert rules and no promtool tests —" + echo "::error::a rule that cannot fire still passes check rules (#63)" + fail=1 + continue + fi + # shellcheck disable=SC2086 + docker run --rm --entrypoint promtool \ + -v "$PWD:/repo" -w /repo "$PROM_IMAGE" test rules $tests || fail=1 + done + exit $fail # No secret needed: the receiver URL comes from url_file, which # Alertmanager reads at notify time rather than at config load time. + # Only stacks that run one. stacks/lab has no Alertmanager by decision + # (ADR-0020), and the ROUTES table below describes the estate's tree + # specifically. - name: amtool check-config run: | - docker run --rm --entrypoint amtool \ - -v "$PWD:/repo" -w /repo "$AM_IMAGE" \ - check-config "$STACK/alertmanager/alertmanager.yaml" + fail=0 + for f in $(./scripts/stacks.sh --paths | sed 's|$|/alertmanager/alertmanager.yaml|'); do + [ -f "$f" ] || continue + echo "== $f" + docker run --rm --entrypoint amtool \ + -v "$PWD:/repo" -w /repo "$AM_IMAGE" check-config "$f" || fail=1 + done + exit $fail # check-config proves the tree parses and that every route names a # receiver that exists. It does not say WHICH receiver an alert reaches, @@ -181,13 +221,17 @@ jobs: - name: amtool config routes test run: | fail=0 + # The estate's tree. Asserted against the one stack that has an + # Alertmanager rather than against every stack, because these eight + # rows are that tree's routing and not a property all stacks share. + cfg=stacks/observability/alertmanager/alertmanager.yaml while read -r expected labels; do [ -n "$expected" ] || continue # shellcheck disable=SC2086 docker run --rm --entrypoint amtool \ -v "$PWD:/repo" -w /repo "$AM_IMAGE" \ config routes test \ - --config.file="$STACK/alertmanager/alertmanager.yaml" \ + --config.file="$cfg" \ --verify.receivers="$expected" $labels \ || { echo "::error::expected $expected for $labels"; fail=1; } done <<'ROUTES' @@ -209,8 +253,14 @@ jobs: - name: alloy fmt --test # Every file in the directory: the agent loads the directory, and the # deploy script ships a subset of it, so each file must stand alone. + # Every *.alloy under stacks/, found rather than assumed to live under + # one of them. stacks/lab has no alloy/ directory — it mounts the + # estate's two files rather than copying them (ADR-0007) — so a `find` + # here covers both today and any stack that grows its own tomorrow. run: | - for f in "$STACK"/alloy/*.alloy; do + found=$(find stacks -path '*/alloy/*.alloy' | sort) + [ -n "$found" ] || { echo "::error::no *.alloy under stacks/"; exit 1; } + for f in $found; do docker run --rm --entrypoint alloy \ -v "$PWD:/repo" -w /repo "$ALLOY_IMAGE" \ fmt --test "$f" @@ -233,7 +283,16 @@ jobs: # the static check. scripts/validate.sh may honestly skip it on a host # without docker; CI may not. - name: Verify health dependencies are satisfiable and probe the images - run: python3 scripts/check_compose_health.py --probe + run: | + fail=0 + for f in $(./scripts/stacks.sh --paths | sed 's|$|/compose.yaml|'); do + echo "== $f" + python3 scripts/check_compose_health.py --probe "$f" || fail=1 + done + # The half no single file can answer: a reloaded or claimed service + # that exists in no stack at all. + python3 scripts/check_compose_health.py --cross-stack || fail=1 + exit $fail # promtool cannot check these — it parses PromQL and rejects every LogQL # stream selector. Loki itself is the only thing that understands them, so @@ -245,7 +304,12 @@ jobs: # so a typo in one rendered an empty panel and looked like quiet traffic # rather than a broken query (#82). - name: Validate Loki rules and dashboard LogQL - run: ./scripts/check_loki_rules.sh + run: | + fail=0 + for stack in $(./scripts/stacks.sh); do + ./scripts/check_loki_rules.sh --stack "$stack" || fail=1 + done + exit $fail # The device list is spread across snmp.yaml, generator.yaml, # render-config.sh's REQUIRED array and the example secrets file. Drift @@ -283,7 +347,12 @@ jobs: run: ./scripts/install-timers.sh --check --require-all - name: Validate Grafana dashboards - run: python3 scripts/check_dashboards.py + run: | + fail=0 + for stack in $(./scripts/stacks.sh); do + python3 scripts/check_dashboards.py --stack "$stack" || fail=1 + done + exit $fail # Dashboard queries are as easy to typo as alert rules, and a broken one # shows up as an empty panel rather than an error. Parse them all — the @@ -291,9 +360,22 @@ jobs: # can parse it. - name: Parse every dashboard PromQL expression run: | - python3 scripts/check_dashboards.py --emit-promql > /tmp/dashboard-exprs.yaml - docker run --rm --entrypoint promtool \ - -v /tmp:/tmp "$PROM_IMAGE" check rules /tmp/dashboard-exprs.yaml + fail=0 + for stack in $(./scripts/stacks.sh); do + # --emit-promql fails on a stack with no dashboards, deliberately: + # its output is what proves the panel queries parse, so emitting an + # empty file would pass promtool over nothing (#68). The guard is + # here, where "this stack ships none" is knowable. + if [ -z "$(find "stacks/$stack/grafana/dashboards" -name '*.json' 2>/dev/null)" ]; then + echo "== $stack: no dashboards" + continue + fi + python3 scripts/check_dashboards.py --stack "$stack" --emit-promql \ + > "/tmp/dashboard-exprs-$stack.yaml" + docker run --rm --entrypoint promtool \ + -v /tmp:/tmp "$PROM_IMAGE" check rules "/tmp/dashboard-exprs-$stack.yaml" || fail=1 + done + exit $fail # The step above reads the dashboard JSON; this one makes Grafana serve it # back. Grafana does not return what it was given — it sorts keys, HTML- diff --git a/Makefile b/Makefile index c1bbc2b..a6ca77b 100644 --- a/Makefile +++ b/Makefile @@ -179,12 +179,25 @@ check-timers: ## Verify the schedule and its staleness thresholds agree ./scripts/install-timers.sh --check .PHONY: pin-digests -pin-digests: ## Re-resolve image digests in compose.yaml (--write applies) - ./scripts/pin-digests.sh --write +pin-digests: ## Re-resolve image digests in every stack's compose.yaml (--write applies) + @# Every stack, not just the estate's. pin-digests.sh takes one compose file + @# and rewrites it in place, which is the right shape for the work it does — + @# so the loop lives here rather than inside it, driven by the same + @# scripts/stacks.sh that validate.sh and ci.yml read. Left single-stack, + @# `stacks/lab`'s digests would be re-resolved by nothing and verified by + @# nothing, which is the #263 defect in the one place it costs a supply-chain + @# guarantee rather than a test. + @set -e; for sd in $$(./scripts/stacks.sh --paths); do \ + printf '\033[0;34m--\033[0m %s\n' "$$sd"; \ + COMPOSE_FILE="$$sd/compose.yaml" ./scripts/pin-digests.sh --write; \ + done .PHONY: check-digests check-digests: ## Verify pinned digests still match the registry - ./scripts/pin-digests.sh + @set -e; for sd in $$(./scripts/stacks.sh --paths); do \ + printf '\033[0;34m--\033[0m %s\n' "$$sd"; \ + COMPOSE_FILE="$$sd/compose.yaml" ./scripts/pin-digests.sh; \ + done .PHONY: scan scan: ## Scan the working tree and history for secrets diff --git a/docs/roadmap.md b/docs/roadmap.md index 93c03ad..af72ade 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -306,11 +306,17 @@ what left this one unfireable for months. (`render-config.sh` derives its required keys per stack rather than demanding the estate's ten, `reload-config.sh` skips services a stack does not declare, `bootstrap.sh` refuses to give one age key both stacks) and gave `.sops.yaml` - the lab rule ADR-0020 asked for. It did **not** touch - [#263](https://github.com/Gerrrt/HomeLab/issues/263): `STACK ?=` reaches the - lifecycle targets and stops there, every checker is still pinned to - `stacks/observability`, and so the new stack is one CI has never seen — - validated only by hand and by the checks that already follow `STACK`. + the lab rule ADR-0020 asked for. [#263](https://github.com/Gerrrt/HomeLab/issues/263) + followed it: `scripts/stacks.sh` is now the single definition of what a stack + is, and `validate.sh`, `ci.yml`, `pin-digests.sh` and the Python checkers all + read it instead of carrying `stacks/observability`. Both stacks are checked, + each line says which, and a directory under `stacks/` with no compose.yaml + fails rather than being skipped — a stack nothing checks being the defect the + list exists to prevent. Two guards got stronger on the way: rules without + `promtool` unit tests are now a failure rather than an absence nobody + measured (#63), and the reload/ABSENT_BINARIES cross-checks gained a + cross-stack mode, because "not in this compose file" stopped meaning "in no + stack at all" the moment there were two. [#265](https://github.com/Gerrrt/HomeLab/issues/265) the domain is what everything else is pointed at, and blocks both [#266](https://github.com/Gerrrt/HomeLab/issues/266) Wazuh — the heaviest diff --git a/scripts/check_compose_health.py b/scripts/check_compose_health.py index e671c38..b198cdb 100755 --- a/scripts/check_compose_health.py +++ b/scripts/check_compose_health.py @@ -32,7 +32,25 @@ reads the array back out and requires each entry to match the healthcheck it is standing on (#80). +That cross-check changed shape when a second stack arrived (#263, #264). +SERVICES is the union across every stack, and reload-config.sh now skips +entries the stack it is reloading does not declare — so "not defined in this +compose file" stopped being a defect on its own. It is split in two instead, +and the pair is strictly stronger than the single check it replaces: + + * per file, an entry that stack DOES declare must carry the healthcheck the + probe stands on — unchanged, and now applied to every stack rather than + only to the estate's; + * across the complete set of stacks, every entry must be declared SOMEWHERE. + That is what still catches an array naming a service nothing has, which is + the case the old "not defined" message existed for. + +The completeness half is only claimed when this script discovered the stacks +itself, which it does when given no paths. Explicit paths mean the caller chose +the scope, and no claim about the whole repository can follow from a subset. + Usage: scripts/check_compose_health.py [--probe] [compose.yaml] + scripts/check_compose_health.py --cross-stack """ from __future__ import annotations @@ -162,20 +180,21 @@ def healthcheck_binary(test: object) -> tuple[str | None, str | None]: ) -def reload_probe_problems(services: dict, compose_name: str) -> list[str]: - """Where reload-config.sh's SERVICES array disagrees with the healthchecks. +def reload_entries() -> tuple[list[tuple[str, str]], list[str]]: + """The SERVICES array from reload-config.sh, as (name, port) pairs. - Returns the problems, or a single problem if the array itself could not be - read — a scraper that has silently stopped matching must fail rather than - report nothing to check, which is the shape of bug this whole file is about. + Split out of reload_probe_problems() so the completeness check across + stacks and the per-stack healthcheck check read the identical parse. Two + readers of one hand-rolled scraper is how they come to disagree about what + the array says. """ if not RELOAD_SCRIPT.exists(): - return [f"{RELOAD_SCRIPT.name} is missing; nothing reloads these services"] + return [], [f"{RELOAD_SCRIPT.name} is missing; nothing reloads these services"] source = RELOAD_SCRIPT.read_text(encoding="utf-8") block = RELOAD_SERVICES.search(source) if not block: - return [ + return [], [ f"could not find the SERVICES=( ... ) array in {RELOAD_SCRIPT.name} — " f"it was reshaped, and this check has been reading nothing ever since" ] @@ -195,15 +214,27 @@ def reload_probe_problems(services: dict, compose_name: str) -> list[str]: entries.append((entry.group(1), entry.group(2))) if not entries and not problems: - return [f"{RELOAD_SCRIPT.name} SERVICES is empty"] + problems.append(f"{RELOAD_SCRIPT.name} SERVICES is empty") + return entries, problems + + +def reload_probe_problems(services: dict, compose_name: str) -> list[str]: + """Where reload-config.sh's SERVICES array disagrees with the healthchecks. + + Scoped to the entries THIS compose file declares. An entry it does not + declare is not a defect here: SERVICES is the union across stacks, and + reload-config.sh skips what a stack does not define, so `stacks/lab` having + no alertmanager is the arrangement rather than a fault. That an entry + exists in no stack at all is still caught — by the completeness check in + main(), which is the only place that knows it is looking at every stack. + """ + entries, problems = reload_entries() + if not entries: + return problems for name, port in entries: svc = services.get(name) if svc is None: - problems.append( - f"{RELOAD_SCRIPT.name} reloads {name}, which is not defined in " - f"{compose_name}" - ) continue expected = f"http://localhost:{port}{RELOAD_PROBE_PATH}" @@ -342,10 +373,69 @@ def probe_binary( return "present", "" +def cross_stack_problems() -> list[str]: + """Names this file asserts about, checked against every stack at once. + + Both per-file checks had to stop treating "not in this compose file" as a + defect when a second stack arrived: reload-config.sh skips services a stack + does not declare, and a stack need not run everything ABSENT_BINARIES + describes. What must still be true is that each name exists SOMEWHERE — an + entry naming a service no stack has is a scraper that has silently stopped + matching, which is the failure this whole file was written for (#80). + + Only answerable with the complete set of stacks, which is why it is its own + mode rather than something the per-file path could do. + """ + try: + listed = subprocess.run( + [str(REPO / "scripts/stacks.sh"), "--paths"], + capture_output=True, text=True, check=True, + ).stdout.split() + except (OSError, subprocess.CalledProcessError) as exc: + err = (getattr(exc, "stderr", "") or str(exc)).strip() + return [f"could not list stacks: {err}"] + + declared: set[str] = set() + for entry in listed: + compose_path = REPO / entry / "compose.yaml" + compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) + declared |= set(compose.get("services") or {}) + + problems: list[str] = [] + entries, problems_from_parse = reload_entries() + problems += problems_from_parse + for name, _port in entries: + if name not in declared: + problems.append( + f"{RELOAD_SCRIPT.name} reloads {name}, which no stack defines — " + f"it was renamed or removed, and nothing has reloaded it since" + ) + for name in ABSENT_BINARIES: + if name not in declared: + problems.append( + f"ABSENT_BINARIES names {name}, which no stack defines — the " + f"claim it stands for has gone with the service" + ) + return problems + + def main() -> int: argv = sys.argv[1:] probe = "--probe" in argv argv = [arg for arg in argv if arg != "--probe"] + + if "--cross-stack" in argv: + argv = [arg for arg in argv if arg != "--cross-stack"] + if argv: + print("--cross-stack takes no compose file", file=sys.stderr) + return 1 + problems = cross_stack_problems() + for problem in problems: + print(f" {problem}", file=sys.stderr) + if problems: + return 1 + print("cross-stack OK — every reloaded and claimed service exists in some stack") + return 0 if argv and argv[0].startswith("-"): print(f"unknown option {argv[0]}", file=sys.stderr) print(__doc__.strip().splitlines()[-1], file=sys.stderr) @@ -384,11 +474,11 @@ def main() -> int: ) # The reload script's copy of these ports, checked against the originals. - # Only meaningful against the file it actually reloads, so it is skipped for - # an explicitly-passed compose file that is not the default one. - reload_checked = path.resolve() == DEFAULT.resolve() - if reload_checked: - problems += reload_probe_problems(services, path.name) + # Runs for EVERY stack now, not just the default one: it is scoped to the + # entries this file declares, so it is meaningful against any of them. The + # matching "no stack declares this entry at all" half is --reload-completeness + # below, which is the only mode that looks at every stack at once. + problems += reload_probe_problems(services, path.name) # Every healthcheck the compose file declares, decoded to the one binary the # image has to contain for it to run at all. Profiles are deliberately not @@ -440,11 +530,13 @@ def main() -> int: for name, binaries in ABSENT_BINARIES.items(): svc = services.get(name) if svc is None: - problems.append( - f"ABSENT_BINARIES names {name}, which is not defined in " - f"{path.name} — it was renamed or removed, and the claim it " - f"stands for has gone with it" - ) + # Not a defect for THIS file. ABSENT_BINARIES is a claim about + # images across the repository, and a stack is allowed not to run + # the service it names — `loki` happens to be in both stacks today, + # but nothing says the next one must be. The rename-or-removal + # catch this message existed for now lives in --cross-stack, which + # is the only mode that can tell "absent from this stack" from + # "absent from every stack". continue svc = svc or {} if svc.get("healthcheck"): @@ -559,8 +651,7 @@ def main() -> int: f"{path.name} OK — {checks} healthcheck(s), " f"{healthy_deps} service_healthy dependency/dependencies, all satisfiable" ) - if reload_checked: - summary += f"; {RELOAD_SCRIPT.name} probes agree with them" + summary += f"; {RELOAD_SCRIPT.name} probes agree with them" if probe: # The count is the point. A probe loop that silently stopped matching # anything would otherwise print this same green line having done diff --git a/scripts/check_dashboards.py b/scripts/check_dashboards.py index f34cc76..7416b88 100755 --- a/scripts/check_dashboards.py +++ b/scripts/check_dashboards.py @@ -34,6 +34,11 @@ import sys REPO = pathlib.Path(__file__).resolve().parent.parent + +# Rebound by main() from --stack. They stay module-level because +# declared_datasources() and the emit paths read them, and threading a stack +# through six call sites to avoid two `global` statements would be the worse +# trade. DASHBOARDS = REPO / "stacks/observability/grafana/dashboards" DATASOURCES = REPO / "stacks/observability/grafana/provisioning/datasources/datasources.yaml" @@ -276,8 +281,26 @@ def main() -> int: help="print dashboard PromQL as a rules file for promtool") emit.add_argument("--emit-logql", action="store_true", help="print dashboard LogQL as a rules file for Loki's ruler") + parser.add_argument("--stack", default="observability", + help="stack under stacks/ to check (default: observability)") args = parser.parse_args() + global DASHBOARDS, DATASOURCES + stack_dir = REPO / "stacks" / args.stack + if not (stack_dir / "compose.yaml").is_file(): + print(f"no such stack: {stack_dir}", file=sys.stderr) + return 1 + DASHBOARDS = stack_dir / "grafana/dashboards" + DATASOURCES = stack_dir / "grafana/provisioning/datasources/datasources.yaml" + + # ALLOY_DIR is deliberately NOT stack-relative. The agent config is one + # directory shared by every stack — stacks/lab mounts config.alloy and + # docker.alloy straight out of stacks/observability/alloy/ rather than + # copying them (ADR-0007's "reused unchanged") — so the level vocabulary it + # defines is a property of the repository, not of whichever stack is being + # checked. Making it stack-relative would look tidier and would check the + # lab's dashboards against an alloy directory that does not exist. + declared = declared_datasources() known = set(declared) | BUILTIN_UIDS problems: list[str] = [] @@ -289,8 +312,27 @@ def main() -> int: files = sorted(DASHBOARDS.glob("*.json")) if not files: - print(f"no dashboards found in {DASHBOARDS}", file=sys.stderr) - return 1 + # Not an error. `stacks/lab` ships no dashboards on purpose — copying + # the estate's seven would render four rows of empty panels, and an + # empty panel is indistinguishable from a broken collector. A stack is + # allowed to have none. + # + # This does not weaken the guard against the estate's dashboards + # disappearing: check_docs.py counts them and asserts the count against + # the prose that claims seven, so observability reaching zero fails + # there, in the check that owns that claim. + # The emit paths stay STRICT, and that is the half that matters. Their + # callers hand the output to promtool or to Loki's ruler as the file + # that proves the panel queries parse, so emitting an empty file over a + # vanished dashboard directory would pass those checks over nothing — + # which is the #68 shape. check_loki_rules.sh therefore only asks for an + # emit when the stack actually has dashboards, and a request that + # arrives anyway is a bug worth failing on. + if args.emit_promql or args.emit_logql: + print(f"no dashboards found in {DASHBOARDS}", file=sys.stderr) + return 1 + print(f"no dashboards in {DASHBOARDS.relative_to(REPO)} — nothing to check") + return 0 for path in files: name = path.name diff --git a/scripts/check_docs.py b/scripts/check_docs.py index ae18080..2978e59 100755 --- a/scripts/check_docs.py +++ b/scripts/check_docs.py @@ -86,7 +86,14 @@ "docs/network.md", "docs/observability.md", "docs/security.md", - "stacks/observability/README.md", + # Every stack's README, globbed rather than listed. This was the single + # literal "stacks/observability/README.md", so stacks/lab's README was + # prose nothing checked — its image table could have carried a version pin + # and gone stale silently, which is the #73 defect the whole PROSE list + # exists to prevent (#263). + *sorted( + str(p.relative_to(REPO)) for p in REPO.glob("stacks/*/README.md") + ), *sorted( str(p.relative_to(REPO)) for p in (REPO / "docs/runbooks").glob("*.md") ), diff --git a/scripts/check_loki_rules.sh b/scripts/check_loki_rules.sh index 94b18e8..bc5da05 100755 --- a/scripts/check_loki_rules.sh +++ b/scripts/check_loki_rules.sh @@ -18,14 +18,14 @@ # of the panels to promtool for exactly that reason; LogQL had no equivalent, # so a typo in a Loki panel reached production unchallenged. # -# Usage: scripts/check_loki_rules.sh +# Usage: scripts/check_loki_rules.sh [--stack NAME] [--skips-file PATH] # -e is on: a failed cp or config rewrite must not produce a cheerful PASS. # The one command allowed to fail is the timeout below, which is guarded. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -STACK="${REPO_ROOT}/stacks/observability" +STACK_NAME="observability" # Resolved from compose.yaml — see scripts/image-for.sh. LOKI_IMAGE="$("${REPO_ROOT}/scripts/image-for.sh" loki)" BOOT_SECONDS="${BOOT_SECONDS:-45}" @@ -40,13 +40,42 @@ SKIPS_FILE="" while (($#)); do case "$1" in --skips-file) SKIPS_FILE="${2:?--skips-file needs a path}"; shift ;; + --stack) STACK_NAME="${2:?--stack needs a name}"; shift ;; *) die "unknown argument: $1" ;; esac shift done +STACK="${REPO_ROOT}/stacks/${STACK_NAME}" +[[ -f "${STACK}/compose.yaml" ]] || die "no such stack: ${STACK}" RULES_DIR="${STACK}/loki/rules" -[[ -d "${RULES_DIR}" ]] || die "no rules directory at ${RULES_DIR}" + +# A stack may legitimately have neither Loki rules nor dashboards. `stacks/lab` +# has both absences on purpose: its Loki carries no ruler, because a ruler needs +# an Alertmanager to deliver to and that stack has none (ADR-0020), and it ships +# no dashboards yet. With nothing to parse there is nothing this check can say, +# and it exits 0 saying so. +# +# NOT a skip. A skip means "this could not run and therefore proved nothing", +# and validate.sh counts skips precisely so a run cannot claim to have checked +# what it did not (#68). This ran, and there was nothing to check — a different +# statement, and mislabelling it would inflate the skip count on every run +# forever until it stopped being read. +n_committed=0 +if [[ -d "${RULES_DIR}" ]]; then + shopt -s nullglob + rule_files=("${RULES_DIR}"/*.yaml) + shopt -u nullglob + n_committed=${#rule_files[@]} +fi +shopt -s nullglob +dash_files=("${STACK}/grafana/dashboards"/*.json) +shopt -u nullglob +if ((n_committed == 0 && ${#dash_files[@]} == 0)); then + printf '\033[0;32m PASS\033[0m %s\n' \ + "${STACK_NAME}: no Loki rules and no dashboards — nothing to parse" + exit 0 +fi # Whether this run can happen at all is decided FIRST, before anything with a # side effect or a failure mode of its own. @@ -81,20 +110,34 @@ WORK="$(mktemp -d)" trap 'rm -rf "${WORK}" 2>/dev/null || true' EXIT # auth_enabled is false, so Loki's local ruler looks under