Repository navigation
fix(chart-drift): compute the pending-deletion half of CLUSTER-ONLY - #3320
Conversation
Chart drift has THREE sides, not two: D = the chart as CI renders it (`helm template`), L = the live cluster objects, M = the last-deployed release manifest (`helm get manifest`), which is the `original` side of helm's three-way merge — exactly what helm previously owned. check-chart-drift.sh computed D vs L only; hosting-audit computes M vs L only. Nobody computed D vs M, and that is where the sole deletion hazard lives: a key that is in L and in M but no longer in D is a chart RETIREMENT that has not landed, and the next `helm upgrade` WILL remove it, because the merge removes exactly what helm previously owned. Everything else survives a deploy (measured 2026-09-03, helm v3.21.1 and v4.2.4, with a positive control). The script now fetches M on both transports and the comparator classifies against it. Such a finding stays in the CLUSTER-ONLY class — the class is "live and not rendered" and both halves are that — but reads PENDING DELETION, carries a count in the summary, and states whether the LIVE VALUE is empty (the removal is a no-op) or not (a real removal). The value decides that, not the key name: all 13 owned-but-retired keys found by hand on 2026-09-04 were zero-length, so a name-only report would have raised a false alarm. No value is ever printed, only its emptiness and, for a ConfigMap key, its length. The manifest is a REQUIRED input on both layers. With M missing every live-only key reads as never-owned — the harmless half — so the report would answer "nothing is about to be deleted" precisely when it could not tell. That is a skip-trapdoor expressed as a default value instead of an `if:`, so the script fails RED naming `helm get manifest` and the comparator refuses to run at all. test-chart-drift-compare.sh drives all four shapes off committed fixtures, plus the two controls: a key that is live, helm-owned AND still rendered must not be reported at all, and three ways of withholding the manifest must each fail red naming the command. Refs #2355 Refs Systemorph/Memex#152 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
It includes future-dated statements (2026-09-05) and a small hardening fix is needed to safely quote variables in the remote aks-invoke manifest command.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR extends the AKS “chart drift” gate to incorporate Helm’s last-deployed release manifest, so the report can distinguish harmless cluster-only drift from cluster-only keys that Helm previously owned and will delete on the next upgrade (“pending deletion”), and documents the updated semantics.
Changes:
- Add manifest fetching (
helm get manifest) tocheck-chart-drift.shand require it as a fail-closed input. - Update
chart-drift-compare.pyto parse the release manifest and mark the owned-but-retired subset ofCLUSTER-ONLYas PENDING DELETION, including “empty vs non-empty” impact reporting without printing secret-bearing values. - Expand the comparator self-test fixtures and assertions to cover D vs M semantics (including negative and fail-closed controls) and update docs accordingly.
File summaries
| File | Description |
|---|---|
| src/MeshWeaver.Documentation/Data/Architecture/DeploymentAKS.md | Updates the AKS deployment documentation to reflect manifest-aware drift semantics and fail-closed behavior. |
| src/MeshWeaver.Documentation/Data/Architecture/ChartDriftSemantics.md | Documents the “three sides” model (D/L/M) and explains the new PENDING DELETION reporting within CLUSTER-ONLY. |
| deploy/aks/scripts/check-chart-drift.sh | Fetches the release manifest and passes it to the comparator; fails closed if the manifest cannot be read. |
| deploy/aks/scripts/chart-drift-compare.py | Adds manifest parsing and splits CLUSTER-ONLY into never-owned vs pending-deletion, plus summary counting. |
| deploy/aks/scripts/test-chart-drift-compare.sh | Adds assertions for pending-deletion reporting, negative controls, and manifest-required fail-closed checks. |
| deploy/aks/scripts/testdata/chart-drift/desired.yaml | Adds a negative-control key (Retention__Days) to ensure manifest membership alone doesn’t generate findings. |
| deploy/aks/scripts/testdata/chart-drift/clean/release-manifest.yaml | Adds a “clean” manifest fixture aligned with desired output (control case). |
| deploy/aks/scripts/testdata/chart-drift/drifted/release-manifest.yaml | Adds a “drifted” manifest fixture to drive pending-deletion cases (ConfigMap + inline env). |
| deploy/aks/scripts/testdata/chart-drift/clean/live-configmap.json | Updates clean live fixture to include the negative-control key. |
| deploy/aks/scripts/testdata/chart-drift/drifted/live-configmap.json | Updates drifted live fixture to include pending-deletion and never-owned examples. |
| deploy/aks/scripts/testdata/chart-drift/drifted/live-deployment.json | Adds an empty inline env entry used to validate NO-OP pending deletion reporting. |
Review details
- Files reviewed: 11/11 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.
| case "$VIA" in | ||
| kubectl) helm get manifest "$RELEASE" -n "$NS" 2>"$WORK/helm-manifest.err" ;; | ||
| aks-invoke) az aks command invoke -g "$RG" -n "$AKS" -o tsv --query logs \ | ||
| --command "helm get manifest $RELEASE -n $NS" 2>"$WORK/helm-manifest.err" ;; | ||
| esac |
…surement Two review findings from the automatic Copilot review, plus the evidence that closes this change's largest unverified claim. 1. The `aks-invoke` transport builds the remote `--command` as a STRING that a shell on the other side re-parses. `$NS`, `$RELEASE` and the resource argument are expanded LOCALLY, so the remote receives the literal — unquoted, a value carrying a space or a `;` would be split or executed there. Quoted in all three builders, not just the new one: it is one defect in one function family, and fixing a third of it reads as an oversight later. `fetch_data_keys` already quoted its go-template expansion; this makes the file consistent with itself. Behaviour is unchanged for every well-formed value. 2. Dated comments moved 2026-09-05 to 2026-09-04, the UTC date this lands on — and the same day the hand measurement it automates was taken, which is the more useful pairing anyway. 3. helm's availability on the `aks-invoke` transport was asserted from the script's own header rather than measured. Now measured, read-only, on the live cluster: `helm version --short` inside `az aks command invoke` answers v3.21.1+gc56dd00, and `helm get manifest` returns a manifest for both production releases (memex 76386 bytes, memexcloud 79884 bytes) carrying every object the comparator hard-requires — the ConfigMap, the Deployment and the memex-portal container. So none of the three "parses but lacks an object" failure paths can fire on either namespace. A new REQUIRED input that turns out to be unreadable in production reddens the gate for a reason indistinguishable from finding drift, which is exactly what this measurement rules out. Recorded with the trap that goes with it: `helm get manifest` emits QUOTED scalars (kind: "ConfigMap"), so grep '^kind: ConfigMap' returns nothing and reads as "the object is absent". safe_load_all unquotes them, which is why the comparator's comparison is right and a shell spot-check is not. Refs #2355 Refs Systemorph/Memex#152 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 1)475 tests 475 ✅ 37s ⏱️ Results for commit daaa961. |
Test Results (shard 0)251 tests 251 ✅ 2m 27s ⏱️ Results for commit daaa961. |
Test Results (shard 3)368 tests 366 ✅ 1m 3s ⏱️ Results for commit daaa961. |
Test Results (shard 5) 4 files 4 suites 1m 28s ⏱️ Results for commit daaa961. |
Test Results (shard 2)588 tests 396 ✅ 2m 56s ⏱️ Results for commit daaa961. |
Test Results (shard 4)2 291 tests 2 291 ✅ 2m 35s ⏱️ Results for commit daaa961. |
Test Results 15 files 15 suites 11m 8s ⏱️ Results for commit daaa961. |
Three sides, not two
Chart drift has three sides, and the gate only ever looked at two of them:
helm templatekubectl get …helm get manifest <release> -n <ns>check-chart-drift.sh+chart-drift-compare.pycomputed D vs L.deploy/aks/operator/bin/hosting-auditcomputes M vs L. Nobody computed D vs M — andthat is where the only real deletion hazard lives.
Mis theoriginalside of helm's three-waymerge, i.e. exactly what helm previously owned, so a key that is in
Land inMbut nolonger in
Dis a chart retirement that has not landed: the nexthelm upgradewill removeit. Everything else survives a deploy — measured 2026-09-03 on helm v3.21.1 and v4.2.4 with a
positive control, and recorded in
ChartDriftSemantics.md; this PR does not re-derive it.Until now both halves were reported identically, as a plain
CLUSTER-ONLYsorted in among dozensof inert ones. The split was measured by hand on 2026-09-04 — 13 owned-but-retired out of 36
findings (
memex7,memex-cloud6) — and nothing recomputed it on the next run.Systemorph/Memex#152asked for exactly this check.What changed
check-chart-drift.shgainsfetch_manifest()besidefetch()—helm get manifeston thekubectltransport, the same throughaz aks command invokeonaks-invoke— and passes it tothe comparator as a new positional argument.
chart-drift-compare.pyparsesMexactly the waydesired.yamlis parsed and splits bothCLUSTER-ONLYbranches (ConfigMap keys, inline env) on manifest membership. The class name isunchanged — the class is "live and not rendered" and both halves are that — so
order, thelegend and every existing assertion still line up; what changes is the finding's verdict, plus a
PENDING DELETIONcount on the summary line.zero-length value is a no-op; of a non-empty value it is a real removal. All 13 found on
2026-09-04 were zero-length, so a key-name-only report would have raised a false alarm. Only the
length/emptiness is printed — inline env entries hold tokens.
than leaving "a
helm upgradePRESERVES it" to be taken on trust.The input fails closed
An unreadable or absent manifest is a hard FAILURE on both layers, naming
helm get manifest.This is not defensiveness: with
Mmissing, every live-only key reads as never-owned — theharmless half — so the report would answer "nothing here is about to be deleted" at exactly the
moment it could not tell. That is AGENTS.md's skip-trapdoor shape expressed as a default value
rather than as an
if:, which is harder to see and worse than silence.Mutation proof
deploy/aks/scripts/test-chart-drift-compare.sh(run bychart-gate.yml, jobinvariants) nowcarries 49 assertions and passes. It was proven by mutation, not by watching it go green:
Mutation 1 — manifest membership always false (
m_data = {},m_env = set()). The suite wentRED with 8 failing assertions; the first was:
Mutation 2 — ignore the value's emptiness (
if value == "":→if False:). RED on exactly thetwo zero-length cases:
Both mutations were reverted and the suite re-run:
EXIT=0, 49oklines, noMUTATIONmarkerleft in the tree.
What the fixtures cover
release-manifest.yamladded under bothtestdata/chart-drift/clean/anddrifted/(theclean one agrees with
desired.yaml, or the control breaks):ConfigMap LogWatch__DefaultRepository/inline env Features__Ai__Clis__Copilot— live, inM, not rendered, non-emptyPENDING DELETION+NON-EMPTYConfigMap Authentication__DevAdminUsers/inline env Legacy__Flag— same shape,""PENDING DELETION+NO-OPConfigMap Ops__Contact/inline env Tok— live, not rendered, not inMPENDING DELETION; must sayPRESERVESRetention__Days— live and inMand still renderedhelm get manifestThe fail-closed control lives in
test-chart-drift-compare.shrather than in a shell-level test:the comparator itself hard-requires the manifest, so the refusal is directly exercisable there with
no cluster.
check-chart-drift.sh's own fetch failure path is the same rule one layer up and isnot covered by an automated test (see Not verified below).
Docs
ChartDriftSemantics.md— the section "CLUSTER-ONLYsplits in two" was a hand measurementwith a "go and run
helm get manifestyourself" instruction. Rewritten to describe the automatedcheck, keeping the 2026-09-04 numbers explicitly as a baseline observation ("the last time
anyone had to"), and the stale "the manifest probe above is that check, and running it before a
deploy is what turns a silent deletion into a reviewed one" framing replaced.
DeploymentAKS.md— the five-class summary and the fail-RED list updated in the same commit.No What's New entry: a CI drift gate is not something a portal user experiences, and the two closest
prior PRs touching these files (#3275, #3166) carried none either.
Verification
deploy/aks/scripts/test-chart-drift-compare.sh→EXIT=0, all 49 assertions pass; RED underboth mutations above.
shellcheck -S warningon both shell scripts → clean.dotnet build src/MeshWeaver.Documentation/MeshWeaver.Documentation.csproj -c Release -warnaserror→
Build succeeded. 0 Warning(s) 0 Error(s); the built assembly contains the edited text(
strings … | grep -c 'PENDING DELETION'→ 5), so the embed is not stale.DocumentationLinkIntegrityTest→Passed! - Failed: 0, Passed: 1,linkintegrity.trxwritten.The new required input, measured on the live cluster before shipping it
A gate whose new required input turns out to be unreadable in production goes red for a
missing-input reason on its first scheduled run — indistinguishable from finding drift. So it was
measured read-only (2026-09-04), not assumed:
Every object the comparator hard-requires — the
memex-portal-configConfigMap, thememex-portal-deploymentDeployment, thememex-portalcontainer inside it — is present in bothrelease manifests, so none of the three "the manifest parses but lacks an object"
sys.exit(1)pathscan fire on either production namespace. Recorded in the doc along with the trap that goes with it:
helm get manifestemits quoted scalars (kind: "ConfigMap"), sogrep '^kind: ConfigMap'returns nothing and reads as "the object is absent" —
yaml.safe_load_allunquotes them, which iswhy the comparator's comparison is right and a shell spot-check is not.
Review follow-up
The automatic Copilot review raised two points, both addressed in the second commit:
--command. Theaks-invoketransport builds a string that a shell onthe far side re-parses;
$NS/$RELEASE/the resource arg are expanded locally, so an odd valuewould be re-split there. Quoted in all three builders (
fetch,fetch_data_keys,fetch_manifest), not only the new one — it is one defect in one function family, andfetch_data_keysalready quoted its go-template expansion, so this makes the file consistent.Behaviour is unchanged for well-formed values.
2026-09-05→2026-09-04, the UTC date this lands on and the day of the handmeasurement it automates.
Still not verified:
check-chart-drift.sh's own manifest fetch-failure branch is exercisedby no automated test (it needs a cluster); the fail-closed control covers the comparator's refusal,
one layer down. The nightly
Chart Driftworkflow remains the first thing to run the fetchend-to-end.
Refs #2355
Refs Systemorph/Memex#152
🤖 Generated with Claude Code