ci: validate compose manifests against gateway-reachable profile sets (BLO-34239) - #20
Conversation
… (BLO-34239) main is the fleet's live fetch path -- magma's compose_manager pulls both manifests from refs/heads/main and reconciles hourly -- and the branch has no CI and no protection, so a typo reaches every enrolled gateway unvalidated. Adds a parse-only lane running 'docker compose config' over the profile sets the gateway can actually compute, per computeProfilesWithBackend() in magma orc8r/gateway/go/services/magmad/compose_manager/manager.go. That function always seeds [managed] and appends a backend/multicast, so testing profiles standalone asserts a configuration that is never deployed -- and misses the ones that are. Surfaces two live, gateway-reachable breaks, pinned as expected failures so the lane is green on main and can be made a required check: [managed ats multicast] -> container name 'relay' already in use [managed varnish multicast] -> container name 'relay' already in use ats is the default backend. Both are pinned bidirectionally: the lane fails if a pinned set starts passing, so the list cannot rot into a silent allowlist. No manifest is modified; this commit is additive.
|
🔗 Paperclip issue: BLO-34239 |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI not launched in the k8s runner; prompts applied directly over the diff).
Reviewed head: cf5b1ed
The design reasoning here is better than most CI lanes get: the matrix is derived from the real caller rather than guessed, and the bidirectional EXPECTED_FAIL assertion is the right instinct against permanent allowlists. The findings below are all in the coverage guard, which does not hold the line it claims to — and compose has a native one-call answer for exactly what it hand-rolls.
Critical Issues (0)
Important Issues (3)
-
[code]
scripts/validate-compose.sh:51—blockcastdis not a declared profile, so itsOPERATOR_ONLYstandalone check asserts nothing.
Ground truth at this head:docker compose -f docker-compose.yml -f docker-compose.relay.yml config --profilesreturnsats caddy luks managed multicast relay varnish— seven profiles, noblockcastd. All three occurrences ofblockcastdas a list item (docker-compose.relay.yml:86,181,430) aredepends_on:entries. Compose silently accepts unknown profile names, so the check resolves the baseline and can never fail for a profile-specific reason:--profile blockcastdyields[blockcastd], byte-identical to--profile zzz-does-not-exist, while real profiles differ (luks→[blockcastd cache-init],relay→[blockcastd cache-init maxmind-sync relay]). The comment at :50 — "Checked standalone so they cannot rot unnoticed" — is not true for this entry.- Drop it, or declare the profile if an operator-only
blockcastdwas intended. Root cause is that the guard at :80-86 is one-directional: it fails on declared-but-unknown and never on known-but-undeclared, so a bogus list entry is invisible by construction.
- Drop it, or declare the profile if an operator-only
-
[gstack/review]
scripts/validate-compose.sh:67— the-A6context scan pulls non-profile list items intoDECLARED, so edits that touch no profile can fail this lane red.
grep -hA6 '^\s*profiles:'takes six lines past everyprofiles:key, and^\s*-\s+[a-z0-9_-]+then matches list items from whatever block follows. Live on this tree:docker-compose.relay.yml:84-86isprofiles: [managed]followed bydepends_on: - blockcastd, which is the only reasonblockcastdreachesDECLARED. Reproduced minimally — a service withprofiles: [managed]anddepends_on: - some-new-serviceyieldsDECLARED=(some-new-service)and the guard emitsFAIL profile 'some-new-service' is declared in a manifest but is not in REACHABLE or OPERATOR_ONLY. It passes today only because both contaminants (blockcastd,relay) happen to sit inOPERATOR_ONLY.docker compose "${FILES[@]}" config --profilesreturns exactly this set, YAML-aware, in one call — it replaces :66-76 (bothmapfileblocks, the inline-form special case, and the re-sort) and would have made theblockcastdentry above fail loudly on day one.
-
[native-codex]
scripts/validate-compose.sh:101— the "started passing" branch hard-codes one explanation, so an environment difference fails the lane with advice that is wrong in that case.
The message asserts "The fix landed -- remove it from EXPECTED_FAIL in this script." A pinned set also resolves cleanly when compose itself behaves differently, which matters here because :19-20 advertises "Runs anywhere with the docker CLI + compose v2 plugin" and :67 documents a standaloneUsage:— local execution is explicitly invited, on whatever compose the developer has.- Observed divergence, offered as evidence and not as a claim the pins are stale: running this exact matrix against these manifests at this head, all eight reachable sets return
rc=0, including both pinned entries — which by this branch exits 1. CI is green, so onarc-lightthose two genuinely fail. I could not attribute the cause; my compose reportsv5.5.0, which is not an upstream version, so my run is not authoritative about which behaviour is correct. Pinning the compose plugin version in the workflow would make the pins reproducible, and the message should name environment drift as the other explanation.
- Observed divergence, offered as evidence and not as a claim the pins are stale: running this exact matrix against these manifests at this head, all eight reachable sets return
Suggestions (3)
- [gstack/review]
.github/workflows/compose-validate.yml:19—cancel-in-progress: truealso covers thepush: branches: [main]trigger, so back-to-back merges leave the superseded main commit with no recorded verdict on the path this lane exists to protect.cancel-in-progress: ${{ github.event_name == 'pull_request' }}keeps the PR-side saving without the gap. - [code]
.github/workflows/compose-validate.yml:21— nopermissions:block, so the job inherits the repo default.permissions: {contents: read}is what it actually needs and pairs with the existingpersist-credentials: false. - [errors]
scripts/validate-compose.sh:24—cd "$(dirname "$0")/.."is unguarded and-eis deliberately off, so a failedcdruns the whole matrix against the wrong directory.cd "$(dirname "$0")/.." || exit 1.
Strengths
- The matrix is derived from
computeProfilesWithBackend()rather than invented, with the reasoning recorded inline and the unreachable-but-cheapmanaged caddy multicastcase called out explicitly. That is the part this class of lane usually gets wrong. local err; err=$(...)is split fromlocal got=$?. The natural one-line formlocal err=$(...)pins$?to thelocalbuiltin's status, which would have made every check report success — a silent, total failure of the lane. Getting this right is not an accident.2>&1 >/dev/nullis in the correct order to capture stderr only, and strippinglevel=warningkeeps the reported first line meaningful.EXPECTED_FAILasserting in both directions is the right idea; the note above is about its blast radius, not the design.persist-credentials: false, a 5-minute timeout, and thesynchronizerationale recorded in a comment are all good defaults on a new lane.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
…ways Addresses Ally's three Important findings on #20. 1. `blockcastd` was in OPERATOR_ONLY but is not a profile -- all three occurrences are `depends_on:` entries. compose ignores an unknown `--profile` silently and resolves the no-profile baseline, so `--profile blockcastd` was byte-identical to `--profile zzz-does-not-exist` and that check asserted nothing. Dropped. 2. The `grep -hA6 '^\s*profiles:'` scan read six lines past every `profiles:` key and swept up list items from whatever block followed -- `depends_on: - blockcastd` at docker-compose.relay.yml:85-86 was the only reason `blockcastd` reached DECLARED. Any `depends_on` next to a `profiles:` key could fail the lane red without touching a profile. Replaced both mapfile blocks and the inline-form special case with `docker compose config --profiles`, which is YAML-aware and one call. The coverage guard is now bidirectional. It already failed on declared-but-untested; it now also fails on tested-but-undeclared, which is the direction that made (1) invisible by construction. 3. The "started passing" branch asserted a single cause. It now names the mechanism to re-verify -- two cache backends colliding on `container_name: relay`, since `multicast` pulls in relay-caddy on top of the ats/varnish backend and all three inherit x-relay-cache -- and the alternative, that the set silently resolved to the baseline. `check()` now splits the label internally with `read -ra` instead of relying on the caller passing `$set` unquoted, so a set that fails to split cannot degrade into one bogus profile that parses clean and reports `ok`. Each profile is also asserted declared before use. Suggestions applied: `cancel-in-progress` scoped to pull_request so a superseded push to main still records a verdict; `permissions: {contents: read}`; `cd ... || exit 1`. Compose version is logged for diagnosis. Negative controls (all exit 1): a new undeclared profile in a manifest; a matrix entry no manifest declares (re-adding `blockcastd` trips both the reverse guard and the per-check assertion); a broken `x-relay-cache` anchor. Baseline still passes with both pins reported `known`. Co-Authored-By: Claude <noreply@anthropic.com>
Response to Ally review at
|
| control | result |
|---|---|
new brandnew profile added to a manifest |
FAIL profile 'brandnew' ... in neither REACHABLE nor OPERATOR_ONLY |
re-add blockcastd to OPERATOR_ONLY |
FAIL 'blockcastd' is in the matrix but no manifest declares it + per-check assertion — the exact entry that was previously silent |
x-relay-cache anchor broken |
FAIL 'docker compose config --profiles' failed |
Baseline still passes with both pins reported known. Manifests were restored after each control (git diff clean).
Finding disposition — head
|
| # | finding | disposition |
|---|---|---|
| 1 | validate-compose.sh:51 — blockcastd is not a declared profile |
accepted, fixed — dropped from OPERATOR_ONLY; coverage guard made bidirectional so the class cannot recur |
| 2 | validate-compose.sh:67 — -A6 scan pulls in non-profile list items |
accepted, fixed — replaced with docker compose config --profiles |
| 3 | validate-compose.sh:101 — "started passing" branch hard-codes one explanation |
accepted, fixed — message now names the mechanism plus the baseline-resolution alternative; check() splits with read -ra and asserts each profile is declared. Version pinning not adopted: the divergence in the review was a zsh word-splitting artifact in the reproduction, not compose behaviour, so there is no measured version dependence to pin against. |
All three Suggestions applied (cancel-in-progress scoped to pull_request, permissions: {contents: read}, cd ... || exit 1).
This is the author's disposition, not a review. No review exists at this head and I am not self-approving. compose-validate is green on arc-light at this head (run 35218057731), which also confirms the two EXPECTED_FAIL pins still fire there — the step would fail if either resolved rc=0. Awaiting a fresh review at c3efc875.
The pins cited BLO-34239 -- this CI issue -- which is self-referential: the comment says "pinned to the issue tracking the fix" and BLO-34239 adds the lane rather than fixing the manifest. The collision is tracked by BLO-34364, which has a fix in flight (#21) and whose AC is that these two entries get removed from EXPECTED_FAIL when it lands. Also corrects the mechanism note. The `multicast` profile activates relay-caddy; it does not activate the multicast service, which already runs under `managed` via x-managed. So on an ats/varnish backend the profile adds a second cache backend rather than enabling multicast -- redundant when the backend is caddy, destructive otherwise. Co-Authored-By: Claude <noreply@anthropic.com>
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI not launched in the k8s runner; prompts applied directly over the diff and the tree at head).
Reviewed head: 9f52b86
All three prior Important findings are genuinely fixed, and the fixes are the right ones rather than the narrow ones — config --profiles replaced the hand-rolled scan wholesale, and the coverage guard now asserts in both directions. I ran the lane at this head (docker compose v5.5.0, clean clone of 9f52b865): it exits 0, both pinned sets fail with the documented container_name "relay" collision, and config --profiles returns exactly the seven profiles the matrix names.
One correction I owe you first: my prior finding 3 offered an "observed divergence" — that both pinned sets returned rc=0 here. That was wrong. I re-ran the old script at the old head (cf5b1ed2c) on this same host and it also passes, with both pinned sets correctly failing. There was never a divergence to explain. Please disregard that evidence; the pins are reproducible and the compose-version-pinning rationale I built on it was unfounded. The wording half of that finding was still legitimate and you have addressed it.
Prior Findings Dispositioned (3)
- prior:cf5b1ed important 1 — fixed —
scripts/validate-compose.sh:53—OPERATOR_ONLY=(luks relay); theblockcastdentry is gone. More than a deletion: the new tested-but-undeclared loop at:108-115is the guard that makes the class unrepresentable, andcheck()at:129-133now rejects an undeclared profile per-label before compose is invoked. The comment at:51-52now states the invariant the guard enforces. - prior:cf5b1ed important 2 — fixed —
scripts/validate-compose.sh:82—docker compose "${FILES[@]}" config --profilesreplaces bothmapfileblocks, the inline-form special case and the re-sort. Verified at this head: it returnsats caddy luks managed multicast relay varnish— seven entries, noblockcastd, so thedepends_oncontamination path is gone rather than worked around. The failure branch at:83-86correctly treats an unparseable manifest as fatal rather than as an empty declared set. - prior:cf5b1ed important 3 — fixed —
scripts/validate-compose.sh:144-150— the branch no longer asserts "The fix landed -- remove it from EXPECTED_FAIL." It now leads with "Confirm the mechanism is actually gone before editing the pin" and names the concrete mechanism, so a developer who trips it verifies rather than edits. That is the substantive protection regardless of which cause fired. (See the retraction above: the environment-drift evidence I attached to this finding does not hold.)
Critical Issues (0)
Important Issues (1)
- [code]
scripts/validate-compose.sh:88— the^[a-z0-9_-]+$filter drops legal compose profile names, so a profile containing.or an uppercase letter is silently absent fromDECLAREDand never tested. This re-opens the "invisible by construction" hole the PR just closed forblockcastd, one layer down.
Compose accepts[a-zA-Z0-9][a-zA-Z0-9_.-]*. Verified directly: a manifest declaringprofiles: ["Dev.Local_1", "lower"]yieldsconfig --profiles→Dev.Local_1andlower, and piping that through this exact filter returns onlylower. So a manifest that declaresDev.Local_1producesDECLAREDwithout it, the declared-but-untested loop at:95-101never fires, and the profile ships untested — which is precisely what the comment at:73-74("assert the matrix names exactly that set, in BOTH directions") promises cannot happen.:91filtersKNOWNthrough the same regex, so adding such a profile to the matrix does not rescue it either: it is dropped fromKNOWNtoo, and the run instead fails at:130with "is not a profile any manifest declares" — loud, but pointing at the wrong cause.
No profile in the tree triggers this today (all seven are lowercase alphanumeric), so this is a hole in the guard's future coverage rather than a live miss — but future coverage is the entire reason the guard exists.- Widen the class at both
:88and:91to^[A-Za-z0-9][A-Za-z0-9_.-]*$; they must stay in lockstep or the two directions disagree. Root cause is that the filter is only there to striplevel=warninglines merged in by the2>&1at:82— capturing stderr into a separate variable instead would let the filter go away entirely, which is the smaller long-term surface.
- Widen the class at both
Suggestions (2)
- [native-codex]
scripts/validate-compose.sh:148-150— the second explanation in the "started passing" message is unreachable.check()validates every profile againstDECLAREDat:129-133and returns 1 before compose is invoked, so by the time control reaches:143the profile names provably did reach compose and "this set resolved to the no-profile baseline" cannot be the cause. Dead advice in an error message costs a reader time at exactly the moment they are already confused. Replacing it with the cause that can still fire — a compose version that resolves these manifests differently fromarc-light— would keep the branch honest; the lane is explicitly invited to run locally (:18-21) on whatever compose the developer has. - [comments]
scripts/validate-compose.sh:65— the comment pins the collision message asservices.relay: container name "relay" is already in use, but the service named in that error is not stable. Same host, same compose binary, same manifests: the old head reportedservices.relayandservices.relay-varnishfor the two pinned sets, this head reportedservices.relay-caddyfor both. Three services inheritx-relay-cacheand collide, and compose names whichever it reaches first. Nothing asserts on the string —:153only prints it — so this is cosmetic, but someone verifying the mechanism against the comment will not match it. Naming the collision without pinning one service would age better.
Strengths
- The two fixes are root-cause, not symptom. Deleting
blockcastdwould have satisfied finding 1 literally; adding the reverse-direction loop at:108-115is what makes the whole class unrepresentable, and it is the direction that would have caught the original bug on day one. config --profilesis the right rung: it is compose's own YAML-aware answer, it deleted three shell blocks including a special case, and the replaced-scan rationale is recorded at:76-81so nobody reintroduces the grep.- Moving the label split inside
check()(:127) and dropping the unquoted$setat the call sites removes a footgun where a mis-split set would have parsed cleanly and reportedokfor a combination never tested. The comment at:121-126explains why, not what. local err; err=$(...)split fromlocal got=$?is still correct, and2>&1 >/dev/nullis still in the right order. Both are easy to "tidy" into silent total failure.EXPECTED_FAILnow carries the mechanism inline (:60-66) and points at BLO-34364 rather than the tracking issue for this lane, so the pin is actionable and cannot be mistaken for self-reference.- All three prior suggestions landed: conditional
cancel-in-progress(.github/workflows/compose-validate.yml:21),permissions: contents: read(:23-24), and the|| exit 1cd guard (scripts/validate-compose.sh:24). The script is committed100755, so the workflow's bare./scripts/validate-compose.shwill execute.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
Two independent false-green paths in the lane, both found in review. 1. `^[a-z0-9_-]+$` dropped legal compose profile names. Compose accepts `[a-zA-Z0-9][a-zA-Z0-9_.-]*`, so a profile named `Dev.Local_1` never reached DECLARED and neither direction of the coverage guard could fire on it -- the same invisible-by-construction hole the guard exists to close, one layer down. Filtering KNOWN through the same regex meant adding such a profile to the matrix did not rescue it either: it was dropped there too and the run failed at the per-check assertion instead, loud but pointing at the wrong cause. 2. `docker compose config` resolves cleanly over two restart-policy hazards in getRestartPolicy (docker/compose pkg/compose/create.go), so rc alone sees neither. `attempts, _ = strconv.Atoi(num)` discards the parse error, so `on-failure:l3` -- or a bare `on-failure` -- reaches the daemon as MaximumRetryCount 0, which Docker reads as unlimited; and a `deploy.restart_policy` block is a plain assignment over `restart:`, not a merge, so the resolved output still prints the `restart:` value while the daemon applies the deploy block. Asserting on `.restart` alone stays green through the second. The audit runs over every service in each resolving set rather than pinning one, so a service added later inherits it. jq is required and its absence fails the run closed. Negative controls, all exit 1 with the intended message, manifests restored after each (tree clean): on-failure:l3 -> no bounded retry count bare on-failure -> no bounded retry count + deploy.restart_policy -> declares both profile Dev.Local_1 -> declared but untested (was silently dropped) jq removed from PATH -> jq is required BLO-34239
The audit piped `config --format json` straight into jq with stderr discarded, so a failed resolve arrived as empty input and reported "nothing to flag" -- silent green, not red. Same invisible-by-construction hole the jq guard one call up already closes, and the YAML rc check above does not cover it: the two formatters do not always agree on rc for one project. Control (stub failing only on --format json): pre-fix PASS, post-fix FAIL on every set. Audit fixtures still discriminate: on-failure:13 and restart: always silent; on-failure:l3, bare on-failure, and a deploy.restart_policy override all fire. Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally please re-review at head The reviewer run for the previous head ( Since your last review at
Controls run at this head (pairing each guard with a failing mutation, rather than only green runs):
Baseline is green and both |
Finding disposition — head
|
| finding | disposition |
|---|---|
Important validate-compose.sh:88 — ^[a-z0-9_-]+$ drops legal profile names (Dev.Local_1), reopening the invisible-by-construction hole one layer down |
accepted, fixed in 17c97ad |
Suggestion :148-150 — unreachable second explanation in the "started passing" message |
not adopted this cycle — the branch still carries it (:188-190) |
Suggestion :65 — comment pins services.relay but the colliding service is not stable |
not adopted this cycle — comment unchanged |
Verified at this head, not at 17c97ad:
:97—PROFILE_RE='^[a-zA-Z0-9][a-zA-Z0-9_.-]*$', compose's own accepted shape. Your recommendation was to widen both:88and:91and keep them in lockstep; they are now one shared constant applied at:98(DECLARED) and:100-101(KNOWN), so they cannot drift apart at all rather than being kept aligned by hand.- The root-cause half of your recommendation — capture stderr separately so the filter can go away — is not taken.
:86still merges stderr (2>&1) because the failure branch at:87-90prints the first line of it as the diagnostic when the manifests do not parse. Splitting the streams to delete the filter is the smaller long-term surface and I agree with it; it is a follow-up, not something I want to fold into this PR's last head.
One thing since your review that is not a response to a finding: 78e0ac4b closes a fail-open I found in the restart-policy audit 17c97ad added. It piped config --format json into jq with stderr discarded, so a failed resolve arrived as empty input and restart_audit reported "nothing to flag" — silent green. :209-213 now captures the rc and fails closed. The YAML rc check above it does not cover this; the two formatters do not always agree on rc for one project. Controls at this head, each guard paired with a failing mutation: on-failure:13 and always silent; typo'd on-failure:l3, bare on-failure, and restart + deploy.restart_policy each fire; json-resolve-fails-while-YAML-fine goes red, and the pre-fix script passed that same control.
config is green at this head (run 35492936935), which also confirms both EXPECTED_FAIL pins still fire — the step fails if either resolves rc=0.
This is the author's disposition, not a review, and I am not self-approving. A re-review was requested at this head in the comment above.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI not launched in the k8s runner; prompts applied directly over the diff and a clean clone at head).
Reviewed head: 78e0ac4
The prior finding is properly fixed, and fixed better than I asked: hoisting the class into a single PROFILE_RE makes the two directions lockstep structurally, where the two-literal fix I suggested would only have been lockstep by convention. I ran the lane on a clean clone at this head (docker compose v5.5.0): baseline green, both pins firing, and I paired each new guard with a failing mutation rather than only green runs.
The one finding below is in the fail-closed fix that 78e0ac4b added. It closes the case it names and leaves the adjacent one open, in the same call, one line over.
Prior Findings Dispositioned (1)
- prior:9f52b86 important 1 — fixed —
scripts/validate-compose.sh:97—PROFILE_RE='^[a-zA-Z0-9][a-zA-Z0-9_.-]*$', compose's own accepted shape, defined once and applied at:98(DECLARED) and:101(KNOWN). Verified at this head: the filter now passesDev.Local_1,UPPERanda.b-c_d, still rejects-badand still strips alevel=warning msg="…"line (which is what the filter is for); the old^[a-z0-9_-]+$passes onlylowerof that set. Because both call sites read one variable, the two directions of the coverage guard cannot drift apart in a later edit — which is the part that makes the class unrepresentable rather than merely corrected.
Critical Issues (0)
Important Issues (1)
-
[code]
scripts/validate-compose.sh:209— the new fail-closed check captures2>&1, so a compose run that succeeds while emitting a warning feeds non-JSON tojq; the audit then returns empty and reads as "nothing to flag". Same silent-green class the commit closed, one line over.
:209guards on the exit code, which catches "compose failed". It does not catch "compose succeeded and its stdout is not JSON" — and2>&1is what creates that second case, by merging stderr into the captured value.:177already gets this right for the YAML call (2>&1 >/dev/null, stderr only);:209merges both streams into the value that is parsed.
Measured on a clean clone at this head, planting the exact hazard the audit exists to catch (restart: on-failure:l3, the unlimited-retry case tied to BLO-29773) and changing nothing else but whetherHOSTNAMEis exported:condition audit lane exit violation, no compose warning FAIL … has no bounded retry count1 violation + one level=warningon stderrsilent 0, PASS all profile sets resolve as expectedIn the second run
jqfailed 8 times (once per resolving set) withparse error: Invalid literal at line 1, column 6; every one was discarded, becauseaudit=$(…)at:214keeps only stdout and nothing tests the exit status. So the whole restart-policy audit goes dark across every profile set while the lane reports PASS — and the two outcomes are indistinguishable from the result, which is the property this PR has now twice iterated to remove.
Not firing today, and I want to be exact about that: I pulled the realarc-lightjob log for this head (run35492936935, composev5.3.1) — 0 warnings, 0 jq errors, audit live. This is a latent fail-open, not a live miss. It is one environment difference away, not a hypothetical one:${HOME}and${HOSTNAME}are the two interpolations in these manifests carrying no:-default (46 of the 48 refs have one), and droppingHOSTNAMEalone producesrc=0with a 116-byte warning on stderr. The header at:18-21explicitly invites local runs on whatever compose the developer has, where that env is not controlled.- One line, at
:214, fails closed on every reason the audit did not run rather than only on the two known ones:if ! audit=$(restart_audit "$label" <<<"$resolved"); then echo "FAIL [$label] restart-policy audit did not run (unparseable resolved output)"; return 1; fi. Verified: with that in place the warning case goes red (exit=1) instead of green. Worth pairing with2>/dev/nullon:209so routine warnings stop reachingjqat all — but the rc check is the load-bearing half, since it also covers a malformed-but-parseable future case that dropping stderr would not.
- One line, at
Suggestions (3)
- [native-codex]
scripts/validate-compose.sh:188-190— the second explanation in the "now resolves" message is still unreachable, and this head did not change that.check()validates every profile againstDECLAREDat:169-173and returns 1 before compose is invoked, so by the time control reaches:182the names provably did reach compose and "this set resolved to the no-profile baseline" cannot be the cause. The cause that can still fire is a compose version resolving these manifests differently — which is no longer speculative:arc-lightrunsv5.3.1and reportsservices.relay-caddyfor both pinned sets, whilev5.5.0here reportsrelay-caddyandrelay-varnish. Naming version drift instead would make the branch honest. (Carried from my9f52b86review; re-raised with firmer evidence, not re-counted as a finding.) - [comments]
scripts/validate-compose.sh:69— the comment still pins the collision asservices.relay: container name "relay" is already in use, and no run I have seen reportsservices.relay. The realarc-lightlog for this head reportsservices.relay-caddyfor both pinned sets; my local run reportedrelay-caddyandrelay-varnish. Three services inheritx-relay-cacheand compose names whichever it reaches first. Nothing asserts on the string, so this is cosmetic — but:185-187now instructs a developer to "confirm the mechanism is actually gone" against a comment whose example will not match what they see. Naming the collision without pinning one service would age better. - [errors]
scripts/validate-compose.sh:151—elifmeans a service declaring both hazards reports only the retry-count one; thedeploy.restart_policyoverwrite surfaces on the next run, after the first is fixed. Two round trips for one service. Emitting both (collect into an array rather than branching) costs a line and is a strictly better error report.
Strengths
- The fix for the prior finding is the structural one, not the literal one. I asked for both regexes widened "in lockstep"; hoisting to
PROFILE_REremoves the possibility of them diverging rather than relying on the next editor remembering. The comment at:92-96records the hole it closes, so it cannot be "tidied" back to a tighter class. - The restart-policy audit is real and I confirmed both branches fire rather than taking the control table on trust:
on-failure:l3→has no bounded retry count; bareon-failure→ same;restart:+deploy.restart_policy→declares both; and the legal forms (on-failure:13,always) stay silent. Auditing every service in each resolving set, rather than a pinned list, is what makes it survive a service being added later. :128-144documents whycompose configis blind to both hazards, with the upstream mechanism (attempts, _ = strconv.Atoi(num)discarding the error, anddeploy.restart_policyassigning rather than merging) and the fleet consequence. That is the reasoning a future reader needs to avoid "simplifying" the audit away as redundant with the rc check.- The
jqpresence check at:28fails closed with its rationale inline — a missingjqwould otherwise skip the audit silently, which is the same class as the finding above and was pre-empted here. local err; err=$(…)split fromlocal got=$?survives another edit, and2>&1 >/dev/nullat:177is still in the correct order. Both are trivially "tidied" into silent total failure.- The bidirectional coverage guard and the per-label
DECLAREDcheck insidecheck()continue to hold:config --profilesreturns exactly the seven declared profiles, and the matrix names exactly those.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
… project 78e0ac4 guarded the json call on exit code, which catches "compose failed" and not "compose succeeded and its stdout is not JSON" -- a case the `2>&1` capture created itself, by merging stderr into the value fed to jq. One compose warning then prefixes the JSON, jq's parse error goes to ITS stderr and is discarded, and `restart_audit` returns empty, which the caller reads as "nothing to flag". The whole restart-policy audit goes dark across every profile set while the lane prints PASS. Reproduced at 78e0ac4 with `restart: on-failure:l3` planted on x-service and nothing else changed but whether HOSTNAME is exported (${HOSTNAME} at docker-compose.relay.yml:212 is one of only two interpolations in these manifests carrying no `:-` default): HOSTNAME set -> 20 "has no bounded retry count" lines, exit 1 HOSTNAME unset -> audit silent, exit 0, PASS Keep stderr off the parsed value, as the YAML call at :177 already does, and check jq's rc as well. Both are needed: jq exits 0 on EMPTY input, so the jq check alone would still pass a compose that failed outright. Mutation-tested per guard rather than only run green: - revert 2>/dev/null -> 2>&1 : clean run with HOSTNAME unset goes red - drop the compose-rc arm : restart_audit on empty input is rc=0 and silent, so a failed compose passes After: all four conditions agree -- baseline green with HOSTNAME set and unset, violation red with HOSTNAME set and unset. Reported-by: allyblockcast[bot] on PR #20 at 78e0ac4 Refs BLO-34239
Finding addressed —
|
| condition | audit | lane exit |
|---|---|---|
violation, HOSTNAME present |
20 × has no bounded retry count |
1 |
violation, HOSTNAME absent |
silent | 0, PASS all profile sets resolve as expected |
Same table. The 2>&1 at :209 merges the warning into the value jq parses; jq exits 5, its parse error goes to its stderr and is discarded, audit comes back empty, and [[ -n "$audit" ]] reads that as a clean audit.
The fix is two arms, because neither subsumes the other
Your one-line suggestion at :214 is the right shape and I took it — but it is not sufficient on its own, and that is worth stating because it is the same class one layer down:
$ echo -n "" | jq -r '.services // {} | to_entries[] | .key'; echo $?
0
jq exits 0 on empty input. So with stderr separated, a compose run that fails outright yields empty stdout → jq rc 0 → silent PASS. The rc check on compose is the only thing that catches that. Conversely the jq rc check is the only thing that catches rc=0-with-non-JSON. Both stay:
if ! resolved=$(docker compose … config --format json 2>/dev/null) \
|| ! audit=$(restart_audit "$label" <<<"$resolved"); thenStderr separation matches :177's 2>&1 >/dev/null — stderr belongs in the diagnostic, never in the parsed value. I dropped the old head -1 <<<"$resolved" message with it (it was printing the merged stream) and replaced it with the reproduce command line, which is more useful than a truncated compose error for a case that only fires when the YAML call at :197 already passed.
Mutation-tested per guard, not only run green
| mutation | result |
|---|---|
revert 2>/dev/null → 2>&1 (keep jq arm) |
baseline, no violation, HOSTNAME absent → exit 1, the restart-policy audit did not run — a false red |
drop the compose-rc arm (keep jq arm) |
restart_audit on empty input → rc=0, empty output — a failed compose passes silently |
Each arm has a mutation that turns the check red (or, for arm 2, demonstrably blind), so neither is decoration.
After
All four conditions now agree — that one variable no longer changes the verdict:
HOSTNAME present |
HOSTNAME absent |
|
|---|---|---|
| baseline | PASS, exit 0 |
PASS, exit 0 |
on-failure:l3 planted |
20 FAIL, exit 1 | 20 FAIL, exit 1 |
Thanks for the precision on "latent, not live" — pulling run 35492936935 to confirm 0 warnings on the real arc-light job is what made this triageable as a correctness fix rather than a suspected CI miss. The header at :18-21 does invite local runs on an uncontrolled shell, so the latent case is the one that matters.
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI not launched in the k8s runner; prompts applied directly over the diff and the file at head).
Reviewed head: d793325
The prior finding is fixed, and the fix does better than fail closed: dropping stderr off the parsed value means the audit now actually runs in the warning case rather than merely going red. I paired the guard with a failing mutation instead of only confirming the green path.
Prior Findings Dispositioned (1)
-
prior:78e0ac4 important 1 — fixed —
scripts/validate-compose.sh:223— the json call is now2>/dev/nulland the audit is invoked inside the sameif, sorestart_audit's own rc is checked:if ! resolved=$(… config --format json 2>/dev/null) || ! audit=$(restart_audit "$label" <<<"$resolved").I verified the two halves separately rather than trusting the shape.
restart_auditis a barejqcall, so its rc is jq's rc — measured on jq 1.7: valid JSON clean →0, valid JSON with a violation →0(output non-empty), warning-prefixed non-JSON →5, empty input →0. So the new arm fires on exactly the case that was silent, and the comment's "jq exits 0 on EMPTY input" caveat at:220is correct and is why the compose rc check must stay.Then the mutation, head against
78e0ac4b, same fake compose, only the two lines differing:compose behaviour head d7933259prior 78e0ac4bclean JSON okrc=0okrc=0violation FAIL … unbounded retryrc=1FAIL … unbounded retryrc=1violation + one level=warningon stderrFAIL … unbounded retryrc=1okrc=0rc=0, stdout not JSON FAIL audit did not runrc=1okrc=0compose rc≠0 FAIL audit did not runrc=1FAIL could not resolve as jsonrc=1Both silent-green rows are closed with no regression on the other three. Note the third row specifically: head does not just fail closed there, it reports the planted
on-failure:l3violation — because with stderr off the value, the JSON parses and the audit genuinely runs. That is the stronger outcome, and it is the one the2>/dev/nullhalf buys that the rc check alone would not.
Critical Issues (0)
Important Issues (0)
Suggestions (4)
- [errors]
scripts/validate-compose.sh:223—2>/dev/nulldiscards compose's stderr, so on therc≠0branch the operator loses the error text the prior version printed; the message substitutes a reproduce command. I think that trade is defensible and I am not asking you to reverse it — it is the rung that needed no temp file, and:228prints a copy-pasteable command with the real$PROJECT/${FILES[*]}/${args[*]}. The one place it bites is the branch's own stated cause: "the two formatters do not always agree on rc for one project" is a compose-version-dependent disagreement, so the environment that failed is the one whose diagnostic was dropped, and the reproduce command may not reproduce locally — this PR has already measuredv5.3.1onarc-lightagainstv5.5.0elsewhere. If it ever fires in CI, capturing stderr to a file for that branch only is the cheap follow-up. Not worth pre-empting now. - [native-codex]
scripts/validate-compose.sh:188-190— the second explanation in the "now resolves" message is still unreachable:check()validates every profile againstDECLAREDat:169-173and returns 1 before compose is invoked, so by:182the names provably reached compose and "this set resolved to the no-profile baseline" cannot be the cause. Version drift is the cause that can still fire. (Carried from9f52b86/78e0ac4b; unchanged by this head, re-raised not re-counted as a finding.) - [comments]
scripts/validate-compose.sh:69— the collision is still pinned asservices.relay: container name "relay" is already in use, and no run I have seen reportsservices.relay; the realarc-lightlog reportsservices.relay-caddyfor both pinned sets. Three services inheritx-relay-cacheand compose names whichever it reaches first. Cosmetic — nothing asserts on the string — but:185-187sends a developer to "confirm the mechanism" against an example that will not match. (Carried.) - [errors]
scripts/validate-compose.sh:151—elifstill means a service declaring both hazards reports only the retry-count one; thedeploy.restart_policyoverwrite surfaces on the next run. Two round trips for one service. (Carried.)
Strengths
- The fix addresses the class, not the line I cited. I flagged one call; the replacement guards every reason the audit could fail to run, and
:200-220enumerates the two causes with an explicit note that neither subsumes the other — including the jq-exits-0-on-empty detail that is the whole reason the compose rc check cannot be dropped as redundant. That is the argument a future reader needs to not "simplify" one half away. - The comment records the measurement (
rc=1and 20 FAIL lines with$HOSTNAMEset,rc=0and PASS without) rather than the conclusion, so the claim is checkable at the point of the code. It also names why$HOSTNAMEis the trigger — one of only two interpolations carrying no:-default — which is what makes the hazard findable again. if ! A || ! Bshort-circuits correctly: a failed compose never reachesjq, andlocal resolved auditstays on its own line so neither assignment's rc is masked by thelocalbuiltin. That footgun has now survived four edits of this function.- The error message states the property rather than the symptom — "The audit reports nothing in that state, which is indistinguishable from a clean audit" — which is the sentence that stops someone re-merging the streams later.
2>&1 >/dev/nullat:177is still in the correct order for the YAML call, and the two calls now differ deliberately rather than accidentally: stderr wanted there, unwanted here.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Why
mainis the fleet's live fetch path. magma'scompose_managerpulls both manifests fromrefs/heads/mainand reconciles hourly (COMPOSE_CHECK_INTERVAL = 1h), so whatever lands here reaches every enrolled gateway with no tag, no channel and no staged rollout in between. That branch has zero CI and zero branch protection today, so the only thing standing between a typo and the fleet is review. Tracked as BLO-34239.This is a syntactic floor, not a review-throughput change. Additive only — no manifest is modified.
The matrix is not "each profile"
The obvious lane loops over declared profiles asserting
rc=0. That asserts a configuration the fleet never runs, and it misses the ones it does.computeProfilesWithBackend()in magmaorc8r/gateway/go/services/magmad/compose_manager/manager.go:1390always seeds["managed"], then appends the cache backend when relay is enabled andmulticastwhen the standalone multicast service is enabled.m.profilesis written in exactly that one place and consumed only bybuildComposeArgs(). The gateway never invokes a single profile standalone. So the lane tests the sets that function can emit.Two live breaks this surfaces
Both are gateway-reachable, and
atsis the default backend (getCDNConfig()returns"ats"when mconfig is unset):Mechanism — three facts compose:
x-managed(:84) setsprofiles: [managed], and themulticastservice uses it, so multicast already runs undermanagedunconditionally.multicastprofile activatesrelay-caddy(profiles: [caddy, multicast]), not the multicast service.container_name: relay(:62) lives in the shared cache anchor, sorelay-ats/relay-varnishandrelay-caddycollide.So appending the
multicastprofile is redundant when the backend is caddy and destructive otherwise.caddyescapes only becausegetEnabledServicesdrops multicast for that backend (:646).These are pinned as expected failures rather than fixed here. The fix is a fleet-behaviour change on a branch that deploys unattended — it wants its own PR and a named approver, not a ride-along on the CI commit. Candidate fix, confirmed by control 3 below: drop
multicastfromrelay-caddy.profiles.The pin is bidirectional — the lane fails if a pinned set starts passing, so the list cannot rot into a silent permanent allowlist.
Negative controls
A check that has only ever been green on good input has not been shown to work. All three fire:
x-relay-cache: &relay-cache→x-relay-cache:)unknown anchor 'relay-cache' referencedbrandnewRunner
arc-light.docker compose configis parse-only and never dials the daemon — verified by running it against a deadDOCKER_HOST=unix:///nonexistent/docker.sock, which still returnsrc=0on a good manifest andrc=1on a bad one. arc-light pins the same runner image as arc-dind (which carriesdocker-compose-plugin), so no privileged pool is needed.Notes
synchronize, so the verdict belongs to the commit that is actually head at merge time.concurrencywithcancel-in-progress, so pushes supersede rather than stack.persist-credentials: falseon checkout.scripts/validate-compose.shruns locally — operators can check before pushing.luks,blockcastd,relayare declared but never computed by the gateway; they are checked standalone as operator-only paths.Making this a required check on
mainis deliberately not part of this PR — it is only meaningful once the lane has been observed running green here.