diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f6c70f2..9df2eac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1076,6 +1076,34 @@ charm-schema-aware, (ii) `tests/render-drift` re-pointed, (iii) `provider-bundle-check` invariant 9 REPLACED and proven able to fail both ways -- never deleted to go green. **The SEVEN are NOT covered by this ruling** and were deliberately not bundled into it. + **>>> THE RULING IS BUILT, 2026-07-31 -- all three parts, and the fixed dc0 deploy input + is CLEAN on option names. <<<** (i) `scripts/render-dc-overlays.py` emits `prefer-ipv6` + only for a charm that declares it; the authority is `PREFER_IPV6_CHARMS` in + `provider-bundle-check.py` (the gate), which the renderer READS with `ast` rather than + restating -- the same read-don't-restate rule `APP_OCTET` already uses -- and which carries + the measurement, the revisions and a re-measure-if-a-pin-moves warning. `render()` keeps + its purity property: the set is a PARAMETER, and the default REFUSES rather than defaulting + to empty, because an empty set renders a plausible artifact with the option nowhere. + (ii) BOTH overlays re-rendered from their values files -- **exactly six lines removed per + DC and NOTHING else: every `vip` string is byte-identical, so all v6 legs are retained**, + and the seven keep the option. `tests/render-drift` PASSES on the re-rendered pair. + (iii) **INVARIANT 9 REPLACED, NOT DELETED.** 9a: the option on a charm that does not + declare it FAILS -- asserted on PRESENCE, not truthiness, because `prefer-ipv6: false` is + the same fatal `unknown option` to juju as `true`. 9b: on a declaring charm the option and + the v6 legs still travel together, so the L3-9 protection is retained unchanged for those + seven. 9c: arity. **MUTATION-PROVEN, six mutations, every one killed tests:** 9a deleted + (T43+T44 die); 9a keyed on value instead of presence (T44 dies); 9b restored to all charms + (T19+T45 die, 10 failures); the charm list widened with barbican+vault (T43-T45 die); the + renderer emitting for every dual app, and `prefer6_charms()` returning empty instead of + refusing (both kill `render-drift`). **ONE NEW ASSERTION WAS FOUND TO BE DECORATION AND WAS + REPLACED, not kept:** T46's first form mutated only a vip, and a non-declaring charm with + no option has `prefer6 == dual == False` and reaches neither branch -- it could not fail + under ANY mutation. Re-written to assert the DIAGNOSIS (an input matching both rules must + report 9a's message, not 9b's, or the next session is sent to add v6 legs when the fix is + to remove the option) and re-proven able to fail against the fold-9a-into-9b mutation. + Harness 44 -> **48/48**. **Gauntlet ALL GREEN (93); repo-lint 0 fail.** + **THE dc0 DEPLOY INPUT NOW SWEEPS CLEAN: 75 option assignments across 20 charm schemas, + 0 unknown, 0 unresolvable** (was 81 with 6 unknown -- the six removed lines). **RUNBOOK DEFECT FOUND IN PASSING, LOGGED NOT FIXED (DOCFIX material):** `runbooks/dc-dc-phase4-juju-bundle-per-dc.md:553-557` gives the dc0 deploy WITHOUT `overlays/vr1-dc0-machines.yaml`, while the dc1 block three lines below includes its diff --git a/docs/changelog-20260731-prefer-ipv6-research.md b/docs/changelog-20260731-prefer-ipv6-research.md index 3115ab4..5b4c240 100644 --- a/docs/changelog-20260731-prefer-ipv6-research.md +++ b/docs/changelog-20260731-prefer-ipv6-research.md @@ -119,15 +119,73 @@ **Revert.** Nothing to revert. +## Item 6 -- THE RULING, and the three-part build that implements it + +**Ruling.** Recorded under GA-R5 in `docs/design-decisions.md` as a D-101 RULING NOTE +dated 2026-07-31 (OPS under GA-R3; D-101's matrix UNAMENDED), and in +`docs/CURRENT-STATE.md`. Committed and pushed BEFORE any dependent work. Operator +utterance, verbatim: *"I want it to use IPv6, if there is spam mechanisms being applied +then that is bad. Even if it doesn't cause an issue now, it might in the future. A clean +IPv6 network is better than one with unusable and possibly future breaking +configurations."* -- and, because that stated a principle rather than selecting an +option, a confirming exchange: *"Yes -- keep the v6 legs, remove only the option"*. + +**(i) `scripts/provider-bundle-check.py` -- `PREFER_IPV6_CHARMS` (NEW) + invariant 9 +REPLACED.** The constant is the measured authority, keyed by CHARM name (an application +name is the deployer's choice; only a charm has a schema) and carrying the revisions and +a re-measure-if-a-pin-moves warning. Invariant 9's old text -- "prefer-ipv6 makes HAProxy +bind :::port in ADDITION to *:port, so the two must travel together" -- is refuted by +this session's measurement and is REPLACED, never deleted: 9a the option is only legal on +a charm that declares it (asserted on PRESENCE, since `prefer-ipv6: false` is the same +fatal `unknown option` to juju as `true`); 9b on a declaring charm it still travels with +the v6 legs, keeping the L3-9 protection intact for the seven; 9c arity. + +**(ii) `scripts/render-dc-overlays.py` + both overlays re-rendered.** The renderer emits +the option only for a declaring charm, reading the set from provider-bundle-check with +`ast` rather than restating it (the read-don't-restate rule `APP_OCTET` already uses). +`render()` keeps its purity property -- the set is a PARAMETER, and the default REFUSES +rather than defaulting to empty, because an empty set renders a plausible-looking +artifact with the option nowhere. The values-file headers were updated too, since they +carried the refuted mechanism claim. **Measured delta: exactly six lines removed per DC +and nothing else -- every `vip` string byte-identical, so all v6 legs are retained.** + +**(iii) `tests/provider-bundle-check/` 44 -> 48.** T43 reproduces the attempt-1 defect; +T44 proves presence-not-truthiness; T45 is the positive control (without it, 9a could be +satisfied by rejecting everything); T46 asserts the DIAGNOSIS. T21's comment was +re-pointed to its real rationale -- the assertion is unchanged and still correct, for a +different reason than the one originally written. + +**Mutation pass -- six mutations, every one killed tests.** 9a deleted (T43+T44 die); 9a +keyed on value (T44 dies); 9b restored to all charms (T19+T45 die, 10 failures); the +charm list widened with barbican+vault (T43-T45 die); renderer emitting for every dual +app, and `prefer6_charms()` returning empty instead of refusing (both kill +`render-drift`). Source restored byte-identical after each. + +**ONE NEW ASSERTION WAS DECORATION AND WAS REPLACED.** T46's first form mutated only a +vip; a non-declaring charm with no option has `prefer6 == dual == False` and reaches +neither branch, so it could not fail under ANY mutation of the charm list. Re-written to +assert that an input matching BOTH rules is diagnosed by 9a rather than 9b -- a +misleading diagnosis would send the next session to add v6 legs when the fix is to remove +the option -- and re-proven against the fold-9a-into-9b mutation. **Third time in a week +that "prove each new assertion can FAIL" has caught a real hole.** + +**Verification.** Gauntlet ALL GREEN (93); repo-lint 0 fail; the fixed dc0 deploy input +now sweeps CLEAN on option names (75 assignments, 20 schemas, 0 unknown, 0 unresolvable). + +**Revert.** `git revert` this commit. It restores the previous renderer behaviour, the +previous invariant 9 and the previous overlays together -- they must move as one, since +a re-rendered overlay without the matching renderer fails `tests/render-drift`. + --- ## What was NOT done, stated so it is not assumed -- **The overlay is untouched and R2 is unamended.** The operator's ruling was to - research, not to fix; the fork between (b) and (c) is theirs. -- **No `prefer-ipv6` was removed from anything**, including the seven charms that - declare it -- that is a separate, newly-raised question. -- **`provider-bundle-check` is unchanged.** Its invariant 9 is now known to rest on a - wrong mechanism claim, but changing a gate before the ruling it gates would be the - wrong order. -- **No cloud state changed.** The `vr1-dc0` model is still empty. +- **R2 is unamended and D-101's matrix is unchanged.** The dual-stack posture does not + move; every v6 VIP leg is retained. +- **Nothing changed for the SEVEN charms that DO declare the option** -- that is a + separate, newly-raised question and was deliberately not bundled into the ruling + (GA-R5: one decision per exchange). +- **No cloud state changed.** The `vr1-dc0` model is still empty; no `juju deploy` was + run this session. +- **No charm-schema GATE was built.** The one-shot sweep is a measurement; a gate is + logged as owed. diff --git a/overlays/vr1-dc0-vips.yaml b/overlays/vr1-dc0-vips.yaml index 44b4ce0..1e54c37 100644 --- a/overlays/vr1-dc0-vips.yaml +++ b/overlays/vr1-dc0-vips.yaml @@ -8,11 +8,20 @@ # vr1-dc0's VIPs and the bundle is not deployable without it. # # DUAL-FAMILY per R2 (RULED 2026-07-27): each vip carries the three v4 legs THEN the -# three v6 legs, and `prefer-ipv6: true` travels WITH them -- the two must never be -# separated (a merge that keeps prefer-ipv6 while dropping the v6 legs leaves HAProxy -# bound on :::port with no v6 VIP for pacemaker to manage; provider-bundle-check -# invariant 9 now rejects that pair). Column order is -# provider-public / metal-admin / metal-internal, v4 then v6. +# three v6 legs. Column order is provider-public / metal-admin / metal-internal, +# v4 then v6. +# +# `prefer-ipv6: true` IS EMITTED ONLY FOR THE SEVEN CHARMS THAT DECLARE IT (RULED +# 2026-07-31, D-101 RULING NOTE: "Yes -- keep the v6 legs, remove only the option"). +# The six that do not -- barbican, designate, magnum, octavia, placement, vault -- +# keep every v6 leg and simply do not get the option; juju rejects the WHOLE bundle +# on an unknown option, which killed deploy attempt 1 on 2026-07-31. The option is +# NOT what makes HAProxy bind :::port: that is gated on the kernel disable_ipv6 +# sysctl, and pacemaker picks IPv6addr by address-family detection +# (docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt). Where it IS legal it +# still travels with the v6 legs -- provider-bundle-check invariant 9b. The +# charm list lives in provider-bundle-check.py PREFER_IPV6_CHARMS and the renderer +# READS it; it is a PINNED MEASUREMENT, re-measure if a channel pin moves. # v4 prefixes MEASURED from scripts/lib-net.sh lib_net_select_dc vr1-dc0 # v6 prefixes READ FROM THE NETBOX APEX (D-136 option (D)); the provider leg uses # the DEDICATED GUA VIP /64, admin+internal their own plane /64s @@ -28,7 +37,6 @@ vip: "10.12.4.50 10.12.8.50 10.12.12.50 2602:f3e2:f02:11::50 fd50:840e:74e2:220::50 fd50:840e:74e2:221::50" # B1 front-loaded VIP; IS the catalog endpoint (B5, no os-public-hostname) barbican: options: - prefer-ipv6: true vip: "10.12.4.51 10.12.8.51 10.12.12.51 2602:f3e2:f02:11::51 fd50:840e:74e2:220::51 fd50:840e:74e2:221::51" # B1 cinder: options: @@ -40,7 +48,6 @@ vip: "10.12.4.53 10.12.8.53 10.12.12.53 2602:f3e2:f02:11::53 fd50:840e:74e2:220::53 fd50:840e:74e2:221::53" # B1 magnum: options: - prefer-ipv6: true vip: "10.12.4.54 10.12.8.54 10.12.12.54 2602:f3e2:f02:11::54 fd50:840e:74e2:220::54 fd50:840e:74e2:221::54" # B1 neutron-api: options: @@ -52,7 +59,6 @@ vip: "10.12.4.56 10.12.8.56 10.12.12.56 2602:f3e2:f02:11::56 fd50:840e:74e2:220::56 fd50:840e:74e2:221::56" # B1 octavia: options: - prefer-ipv6: true vip: "10.12.4.57 10.12.8.57 10.12.12.57 2602:f3e2:f02:11::57 fd50:840e:74e2:220::57 fd50:840e:74e2:221::57" # B1 openstack-dashboard: options: @@ -60,7 +66,6 @@ vip: "10.12.4.58 10.12.8.58 10.12.12.58 2602:f3e2:f02:11::58 fd50:840e:74e2:220::58 fd50:840e:74e2:221::58" # B1 -- browse HTTPS by IP (B5); ALLOWED_HOSTS must permit the VIP IP (verify at deploy) placement: options: - prefer-ipv6: true vip: "10.12.4.59 10.12.8.59 10.12.12.59 2602:f3e2:f02:11::59 fd50:840e:74e2:220::59 fd50:840e:74e2:221::59" # B1 ceph-radosgw: options: @@ -68,9 +73,7 @@ vip: "10.12.4.60 10.12.8.60 10.12.12.60 2602:f3e2:f02:11::60 fd50:840e:74e2:220::60 fd50:840e:74e2:221::60" # B1 -- radosgw HA un-deferred for Roosevelt fidelity (decorative HA on testcloud) vault: options: - prefer-ipv6: true vip: "10.12.4.61 10.12.8.61 10.12.12.61 2602:f3e2:f02:11::61 fd50:840e:74e2:220::61 fd50:840e:74e2:221::61" # R11 (D-020 amendment) -- was ruled in D-020's own enumeration and never built designate: options: - prefer-ipv6: true vip: "10.12.4.62 10.12.8.62 10.12.12.62 2602:f3e2:f02:11::62 fd50:840e:74e2:220::62 fd50:840e:74e2:221::62" # R11 (D-020 amendment) -- NEW to D-020's enumeration; dnsaas is already provider-bound diff --git a/overlays/vr1-dc1-vips.yaml b/overlays/vr1-dc1-vips.yaml index 1477c51..40b717f 100644 --- a/overlays/vr1-dc1-vips.yaml +++ b/overlays/vr1-dc1-vips.yaml @@ -8,11 +8,20 @@ # vr1-dc1's VIPs and the bundle is not deployable without it. # # DUAL-FAMILY per R2 (RULED 2026-07-27): each vip carries the three v4 legs THEN the -# three v6 legs, and `prefer-ipv6: true` travels WITH them -- the two must never be -# separated (a merge that keeps prefer-ipv6 while dropping the v6 legs leaves HAProxy -# bound on :::port with no v6 VIP for pacemaker to manage; provider-bundle-check -# invariant 9 now rejects that pair). Column order is -# provider-public / metal-admin / metal-internal, v4 then v6. +# three v6 legs. Column order is provider-public / metal-admin / metal-internal, +# v4 then v6. +# +# `prefer-ipv6: true` IS EMITTED ONLY FOR THE SEVEN CHARMS THAT DECLARE IT (RULED +# 2026-07-31, D-101 RULING NOTE: "Yes -- keep the v6 legs, remove only the option"). +# The six that do not -- barbican, designate, magnum, octavia, placement, vault -- +# keep every v6 leg and simply do not get the option; juju rejects the WHOLE bundle +# on an unknown option, which killed deploy attempt 1 on 2026-07-31. The option is +# NOT what makes HAProxy bind :::port: that is gated on the kernel disable_ipv6 +# sysctl, and pacemaker picks IPv6addr by address-family detection +# (docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt). Where it IS legal it +# still travels with the v6 legs -- provider-bundle-check invariant 9b. The +# charm list lives in provider-bundle-check.py PREFER_IPV6_CHARMS and the renderer +# READS it; it is a PINNED MEASUREMENT, re-measure if a channel pin moves. # v4 prefixes MEASURED from scripts/lib-net.sh lib_net_select_dc vr1-dc1 # v6 prefixes READ FROM THE NETBOX APEX (D-136 option (D)); the provider leg uses # the DEDICATED GUA VIP /64, admin+internal their own plane /64s @@ -28,7 +37,6 @@ vip: "10.12.64.50 10.12.68.50 10.12.72.50 2602:f3e2:f03:11::50 fd50:840e:74e2:320::50 fd50:840e:74e2:321::50" barbican: options: - prefer-ipv6: true vip: "10.12.64.51 10.12.68.51 10.12.72.51 2602:f3e2:f03:11::51 fd50:840e:74e2:320::51 fd50:840e:74e2:321::51" cinder: options: @@ -40,7 +48,6 @@ vip: "10.12.64.53 10.12.68.53 10.12.72.53 2602:f3e2:f03:11::53 fd50:840e:74e2:320::53 fd50:840e:74e2:321::53" magnum: options: - prefer-ipv6: true vip: "10.12.64.54 10.12.68.54 10.12.72.54 2602:f3e2:f03:11::54 fd50:840e:74e2:320::54 fd50:840e:74e2:321::54" neutron-api: options: @@ -52,7 +59,6 @@ vip: "10.12.64.56 10.12.68.56 10.12.72.56 2602:f3e2:f03:11::56 fd50:840e:74e2:320::56 fd50:840e:74e2:321::56" octavia: options: - prefer-ipv6: true vip: "10.12.64.57 10.12.68.57 10.12.72.57 2602:f3e2:f03:11::57 fd50:840e:74e2:320::57 fd50:840e:74e2:321::57" openstack-dashboard: options: @@ -60,7 +66,6 @@ vip: "10.12.64.58 10.12.68.58 10.12.72.58 2602:f3e2:f03:11::58 fd50:840e:74e2:320::58 fd50:840e:74e2:321::58" placement: options: - prefer-ipv6: true vip: "10.12.64.59 10.12.68.59 10.12.72.59 2602:f3e2:f03:11::59 fd50:840e:74e2:320::59 fd50:840e:74e2:321::59" ceph-radosgw: options: @@ -68,9 +73,7 @@ vip: "10.12.64.60 10.12.68.60 10.12.72.60 2602:f3e2:f03:11::60 fd50:840e:74e2:320::60 fd50:840e:74e2:321::60" vault: options: - prefer-ipv6: true vip: "10.12.64.61 10.12.68.61 10.12.72.61 2602:f3e2:f03:11::61 fd50:840e:74e2:320::61 fd50:840e:74e2:321::61" designate: options: - prefer-ipv6: true vip: "10.12.64.62 10.12.68.62 10.12.72.62 2602:f3e2:f03:11::62 fd50:840e:74e2:320::62 fd50:840e:74e2:321::62" diff --git a/render/values/vr1-dc0-vips.yaml b/render/values/vr1-dc0-vips.yaml index f999eed..fedaff3 100644 --- a/render/values/vr1-dc0-vips.yaml +++ b/render/values/vr1-dc0-vips.yaml @@ -75,15 +75,33 @@ # DUAL-FAMILY per R2 (RULED 2026-07-27): each vip carries the three v4 legs THEN the - # three v6 legs, and `prefer-ipv6: true` travels WITH them -- the two must never be + # three v6 legs. Column order is provider-public / metal-admin / metal-internal, - # separated (a merge that keeps prefer-ipv6 while dropping the v6 legs leaves HAProxy + # v4 then v6. - # bound on :::port with no v6 VIP for pacemaker to manage; provider-bundle-check + # - # invariant 9 now rejects that pair). Column order is + # `prefer-ipv6: true` IS EMITTED ONLY FOR THE SEVEN CHARMS THAT DECLARE IT (RULED - # provider-public / metal-admin / metal-internal, v4 then v6. + # 2026-07-31, D-101 RULING NOTE: "Yes -- keep the v6 legs, remove only the option"). + + # The six that do not -- barbican, designate, magnum, octavia, placement, vault -- + + # keep every v6 leg and simply do not get the option; juju rejects the WHOLE bundle + + # on an unknown option, which killed deploy attempt 1 on 2026-07-31. The option is + + # NOT what makes HAProxy bind :::port: that is gated on the kernel disable_ipv6 + + # sysctl, and pacemaker picks IPv6addr by address-family detection + + # (docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt). Where it IS legal it + + # still travels with the v6 legs -- provider-bundle-check invariant 9b. The + + # charm list lives in provider-bundle-check.py PREFER_IPV6_CHARMS and the renderer + + # READS it; it is a PINNED MEASUREMENT, re-measure if a channel pin moves. # v4 prefixes MEASURED from scripts/lib-net.sh lib_net_select_dc vr1-dc0 diff --git a/render/values/vr1-dc1-vips.yaml b/render/values/vr1-dc1-vips.yaml index 2a4e257..d4b4d72 100644 --- a/render/values/vr1-dc1-vips.yaml +++ b/render/values/vr1-dc1-vips.yaml @@ -62,15 +62,33 @@ # DUAL-FAMILY per R2 (RULED 2026-07-27): each vip carries the three v4 legs THEN the - # three v6 legs, and `prefer-ipv6: true` travels WITH them -- the two must never be + # three v6 legs. Column order is provider-public / metal-admin / metal-internal, - # separated (a merge that keeps prefer-ipv6 while dropping the v6 legs leaves HAProxy + # v4 then v6. - # bound on :::port with no v6 VIP for pacemaker to manage; provider-bundle-check + # - # invariant 9 now rejects that pair). Column order is + # `prefer-ipv6: true` IS EMITTED ONLY FOR THE SEVEN CHARMS THAT DECLARE IT (RULED - # provider-public / metal-admin / metal-internal, v4 then v6. + # 2026-07-31, D-101 RULING NOTE: "Yes -- keep the v6 legs, remove only the option"). + + # The six that do not -- barbican, designate, magnum, octavia, placement, vault -- + + # keep every v6 leg and simply do not get the option; juju rejects the WHOLE bundle + + # on an unknown option, which killed deploy attempt 1 on 2026-07-31. The option is + + # NOT what makes HAProxy bind :::port: that is gated on the kernel disable_ipv6 + + # sysctl, and pacemaker picks IPv6addr by address-family detection + + # (docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt). Where it IS legal it + + # still travels with the v6 legs -- provider-bundle-check invariant 9b. The + + # charm list lives in provider-bundle-check.py PREFER_IPV6_CHARMS and the renderer + + # READS it; it is a PINNED MEASUREMENT, re-measure if a channel pin moves. # v4 prefixes MEASURED from scripts/lib-net.sh lib_net_select_dc vr1-dc1 diff --git a/scripts/provider-bundle-check.py b/scripts/provider-bundle-check.py index 2830d28..f54a4b4 100644 --- a/scripts/provider-bundle-check.py +++ b/scripts/provider-bundle-check.py @@ -27,10 +27,32 @@ 8. R11 (D-020 AMENDMENT, 2026-07-27): a principal wired to an hacluster subordinate MUST carry a vip. Without one it binds a UNIT address and pacemaker has nothing to fail over to -- decorative HA. - 9. prefer-ipv6 and dual-family VIP arity are COUPLED. prefer-ipv6 makes HAProxy bind - :::port in ADDITION to *:port, so the two must travel together. Measured (L3-9): - the overlay merge order that keeps prefer-ipv6 while silently dropping the v6 VIP - legs is the one that exits 0 -- the dangerous order is the GREEN one. + 9. prefer-ipv6 IS ONLY LEGAL ON A CHARM THAT DECLARES IT, and where it is legal it + stays COUPLED to dual-family VIP arity. + REPLACED 2026-07-31 (D-101 RULING NOTE, GA-R5: "Yes -- keep the v6 legs, remove + only the option"). The previous text of this invariant read "prefer-ipv6 makes + HAProxy bind :::port in ADDITION to *:port, so the two must travel together". + THAT MECHANISM CLAIM IS REFUTED BY MEASUREMENT + (`docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt`): in BOTH charm + template families the `:::port` frontend bind is gated on + `ipv6_enabled = not is_ipv6_disabled()` -- a read of the kernel + `net.ipv6.conf.all.disable_ipv6` sysctl -- and pacemaker picks + `ocf:heartbeat:IPv6addr` by per-address family detection. Neither consults the + option. The one value prefer-ipv6 sets that resembles a bind address, + `haproxy_host`, is consumed by NO template in any charm downloaded. + THE INVARIANT IS REPLACED, NOT DELETED -- the L3-9 half it was built on is real + and is retained. Three assertions now: + 9a prefer-ipv6 present on a charm that does NOT declare it FAILS. This is the + 2026-07-31 attempt-1 defect: juju rejects the WHOLE bundle atomically + (`unknown option "prefer-ipv6"` on barbican) rather than ignoring it, and + `juju deploy --dry-run` does NOT validate option names, so nothing else + in this repo can catch it. + 9b on a charm that DOES declare it, prefer-ipv6 and the dual-family v6 legs + still travel together -- the L3-9 merge order that keeps the option while + silently dropping the v6 legs is the one that exits 0, so the dangerous + order is the GREEN one. Retained unchanged for those seven. + 9c arity itself: a vip is a v4 triple or a dual-family sextet, never other. + PREFER_IPV6_CHARMS below is the measured authority for 9a/9b. 10. HA ARITY (2026-07-29 gate hardening, the second half of R11's shape): an hacluster subordinate's `cluster_count` MUST equal its principal's `num_units`. Until this landed, `cluster_count` was asserted NOWHERE in scripts/ or tests/ -- measured @@ -84,6 +106,38 @@ OCTET_LO, OCTET_HI = 50, 99 EXPECT_PUBLIC_VIP = 11 +# Invariant 9's measured authority: the charms that DECLARE a `prefer-ipv6` config +# option. Keyed by CHARM NAME, never by application name -- the application name is +# the deployer's choice and only the charm has a schema. +# +# MEASURED 2026-07-31 from the charm ARTIFACT at each pinned channel, downloaded for +# amd64/ubuntu@22.04 and read with `grep -c prefer-ipv6 /config.yaml`. NOT from +# Charmhub's config-yaml API alone, and NOT from a repo comment. Capture: +# docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt. +# +# DECLARE (7) ceph-radosgw 600 (squid/stable), cinder 820, glance 681, +# keystone 857, neutron-api 710, nova-cloud-controller 823, +# openstack-dashboard 750 (2024.1/stable unless noted) +# DO NOT (6) barbican 265, designate 418, magnum 96, octavia 571, +# placement 154, vault 724 (1.8/stable) +# +# barbican returns NO at 2023.2/stable, 2023.1/stable AND ussuri/stable, so this is a +# SUBSET of the OpenStack charms rather than an option newer charms dropped. +# +# THIS IS A PINNED MEASUREMENT, NOT A STANDING TRUTH. A channel is a moving target: if +# a pin changes, re-measure against the new revision rather than trusting this list. +# scripts/render-dc-overlays.py READS this tuple with `ast` rather than restating it -- +# a second copy is a second thing to drift (same pattern as APP_OCTET). +PREFER_IPV6_CHARMS = ( + "ceph-radosgw", + "cinder", + "glance", + "keystone", + "neutron-api", + "nova-cloud-controller", + "openstack-dashboard", +) + # v6 VIP bands are NOT hardcoded: D-136 option (D) (ADOPTED 2026-07-27) makes the # NetBox apex the source for v6 prefixes. Same record and same (role, kind) keying # as scripts/dc-plane-ipam.sh:102-131 and netbox/dc-plane-apex-import.py. @@ -285,11 +339,23 @@ fails.append("%s vip is neither a v4 triple (3) nor a dual-family sextet (6) " "-- got %d address(es): %r" % (n, len(parts), vip)); continue dual = (len(parts) == 6) - # prefer-ipv6 binds :::port in ADDITION to *:port, so it and the v6 legs must - # land together. Measured (L3-9): the overlay merge order that keeps - # prefer-ipv6 while dropping the v6 legs is the one that exits 0 -- pacemaker - # would then have no v6 VIP to manage behind a socket that is already bound. - if prefer6 != dual: + declared = "prefer-ipv6" in opts + charm = str((apps[n] or {}).get("charm") or "") + # 9a -- the option is only legal on a charm that DECLARES it. juju rejects the + # WHOLE bundle atomically on an unknown option and --dry-run does not check + # option names, so this is the only thing in the repo that can catch it. + # Asserted on PRESENCE, not truthiness: `prefer-ipv6: false` on barbican is the + # same fatal `unknown option` to juju as `true`. + if declared and charm not in PREFER_IPV6_CHARMS: + fails.append("%s sets prefer-ipv6 but its charm %r does NOT declare that " + "option -- juju rejects the whole bundle (2026-07-31 attempt 1). " + "The v6 VIP legs do NOT need it: the ':::port' bind is gated on " + "the kernel disable_ipv6 sysctl, not on this option (D-101 " + "RULING NOTE 2026-07-31)" % (n, charm)); continue + # 9b -- where the option IS legal, it stays coupled to the v6 legs. Measured + # (L3-9): the overlay merge order that keeps prefer-ipv6 while dropping the v6 + # legs is the one that exits 0, so the dangerous order is the GREEN one. + if charm in PREFER_IPV6_CHARMS and prefer6 != dual: fails.append("%s prefer-ipv6=%s but its vip carries %d address(es) -- " "prefer-ipv6 and the v6 VIP legs must land TOGETHER" % (n, str(prefer6).lower(), len(parts))); continue diff --git a/scripts/render-dc-overlays.py b/scripts/render-dc-overlays.py index 29222ed..858fc7d 100755 --- a/scripts/render-dc-overlays.py +++ b/scripts/render-dc-overlays.py @@ -53,6 +53,20 @@ # hand-maintained copy, so it is READ from that file rather than restated. OCTET_SRC = os.path.join(REPO, "netbox", "dc-plane-apex-import.py") +# Which charms DECLARE a `prefer-ipv6` option. Same read-don't-restate rule: the +# measured authority is PREFER_IPV6_CHARMS in provider-bundle-check.py (the gate), and +# it carries the measurement, the revisions and the re-measure warning. +# +# NOTE ON KEYING, stated because it is a real (small) gap: that tuple is keyed by CHARM +# name, and a values file only carries APPLICATION names. Measured 2026-07-31: for all +# thirteen VIP applications in bundle.yaml the two are IDENTICAL, so matching on the app +# name is correct today. It is NOT guaranteed in general -- an application may be named +# anything. The charm-keyed assertion lives in provider-bundle-check invariant 9a, which +# reads the merged bundle and therefore SEES the charm; so if the two ever diverge the +# gate fails rather than the artifact silently going wrong. The renderer generates; the +# gate decides. +PREFER6_SRC = os.path.join(REPO, "scripts", "provider-bundle-check.py") + # The three VIP legs, in the column order every reviewed artifact already uses. LEGS = ("provider-public", "metal-admin", "metal-internal") @@ -100,6 +114,38 @@ die("APP_OCTET not found in %s" % OCTET_SRC) +def prefer6_charms(): + """The charms that DECLARE prefer-ipv6, read from provider-bundle-check.py. + + Read rather than restated, for the same reason as APP_OCTET: a second copy is a + second thing to drift, and this one is a PINNED MEASUREMENT that has to be redone + when a channel moves. + + REFUSES rather than defaulting. An empty or missing tuple would silently render + every app without the option -- which is a plausible-looking artifact and exactly + the "could not look is not nothing there" failure this repo keeps hitting. An + unreadable source is an error, not a licence to emit. + """ + import ast + try: + tree = ast.parse(open(PREFER6_SRC, encoding="utf-8").read()) + except Exception as e: + die("cannot read PREFER_IPV6_CHARMS from %s: %s" % (PREFER6_SRC, e)) + for node in tree.body: + if isinstance(node, ast.Assign) and any( + getattr(t, "id", "") == "PREFER_IPV6_CHARMS" for t in node.targets): + try: + names = [e.value for e in node.value.elts] + except Exception as e: + die("PREFER_IPV6_CHARMS in %s is not a flat tuple of strings: %s" + % (PREFER6_SRC, e)) + if not names or not all(isinstance(x, str) and x for x in names): + die("PREFER_IPV6_CHARMS in %s is empty or holds a non-string entry" + % PREFER6_SRC) + return frozenset(names) + die("PREFER_IPV6_CHARMS not found in %s" % PREFER6_SRC) + + def lib_net_planes(dc): """Plane name -> CIDR from lib-net.sh, the site-keyed v4 authority. @@ -217,8 +263,19 @@ return 0 -def render(vals): - """VALUES -> overlay text. Pure: no I/O, no clock, no network.""" +def render(vals, prefer6=None): + """VALUES -> overlay text. Pure: no I/O, no clock, no network. + + `prefer6` is the set of charms that DECLARE a prefer-ipv6 option. It is a + PARAMETER rather than a lookup so this function keeps its purity property -- the + reason the artifacts are byte-reproducible. Callers resolve it once with + prefer6_charms(); the default is an explicit REFUSAL, never an empty set, because + an empty set renders a plausible-looking artifact with the option nowhere. + """ + if prefer6 is None: + die("render() called without the prefer-ipv6 charm set -- resolve it with " + "prefer6_charms() and pass it in; defaulting to empty would silently emit " + "an artifact with the option nowhere") fam = vals.get("family", "v4") legs, pre4 = vals["legs"], vals["prefixes_v4"] pre6 = vals.get("prefixes_v6") or {} @@ -240,9 +297,17 @@ # it; the octet mirror is textual, so the digits stay decimal. addrs += ["%s::%d" % (pre6[l], o) for l in legs] out.append(" %s:\n options:\n" % app["name"]) - if fam == "dual": - # prefer-ipv6 and the v6 legs must land together, or HAProxy binds - # :::port with no v6 VIP for pacemaker to manage. + if fam == "dual" and app["name"] in prefer6: + # RULED 2026-07-31 (D-101 RULING NOTE, GA-R5: "Yes -- keep the v6 legs, + # remove only the option"). This used to be emitted for EVERY dual-family + # app, on the belief that prefer-ipv6 is what makes HAProxy bind :::port. + # MEASURED FALSE: that bind is gated on `ipv6_enabled = not + # is_ipv6_disabled()`, a read of the kernel disable_ipv6 sysctl, and + # pacemaker picks IPv6addr by address-family detection. Six of the thirteen + # VIP charms do not declare the option at all, and juju rejects the WHOLE + # bundle on an unknown option -- which is how bundle deploy attempt 1 died + # on 2026-07-31. The v6 legs stay; only the option is withheld. + # Capture: docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt out.append(" prefer-ipv6: true\n") # An optional trailing comment, carried so the ruling-3 extraction out of # bundle.yaml is LOSSLESS -- ten of those eleven comments are the bare @@ -294,11 +359,11 @@ if a.cmd == "derive": return do_derive(a) if a.cmd == "render": - txt = render(load_values(a.values)) + txt = render(load_values(a.values), prefer6_charms()) (open(a.out, "w", encoding="utf-8") if a.out else sys.stdout).write(txt) return 0 # check - got = render(load_values(a.values)) + got = render(load_values(a.values), prefer6_charms()) try: want = open(a.against, encoding="utf-8").read() except Exception as e: diff --git a/tests/provider-bundle-check/run-tests.sh b/tests/provider-bundle-check/run-tests.sh index de4c246..f4cb1c0 100644 --- a/tests/provider-bundle-check/run-tests.sh +++ b/tests/provider-bundle-check/run-tests.sh @@ -239,10 +239,16 @@ run 1 'prefer-ipv6 and the v6 VIP legs must land TOGETHER' \ "T20 prefer-ipv6 without v6 legs FAILS" "$TMP/t20.yaml" -# T21 the inverse: v6 legs present but nothing binds v6 +# T21 the inverse. RATIONALE RE-POINTED 2026-07-31, assertion UNCHANGED: this used to +# read "v6 legs present but nothing binds v6", which is refuted -- the ':::port' +# bind is gated on the kernel disable_ipv6 sysctl, not on this option +# (docs/audit/stage5-prefer-ipv6-charm-research-20260731.txt). It still FAILS, and +# should: on a charm that DECLARES the option, R2 has it set, and a merge that +# silently drops it is the same undetected-divergence class T20 covers. keystone is +# deliberately the subject here -- it is one of the SEVEN, so invariant 9b applies. mutate t21.yaml 'b["applications"]["keystone"]["options"].pop("prefer-ipv6")' run 1 'prefer-ipv6 and the v6 VIP legs must land TOGETHER' \ - "T21 v6 legs without prefer-ipv6 FAILS" "$TMP/t21.yaml" + "T21 v6 legs without prefer-ipv6 FAILS (declaring charm)" "$TMP/t21.yaml" # T22 v6 provider leg in the NODE /64 instead of the dedicated GUA VIP /64 mutate t22.yaml "b['applications']['keystone']['options'].update({'vip':'${DUAL6/f02:11::50/f02:10::50}','prefer-ipv6':True})" @@ -404,6 +410,53 @@ "$TMP/good.yaml" --overlay "$TMP/mach-untagged.yaml" \ --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1 +# ---- invariant 9a: prefer-ipv6 is only legal on a charm that DECLARES it ---- +# RULED 2026-07-31 (D-101 RULING NOTE, GA-R5). These four cases exist because the +# 2026-07-31 bundle deploy attempt 1 died on exactly this and NOTHING in the repo could +# see it: `juju deploy --dry-run` resolved all 56 charms, planned 108 relations and +# exited 0, then the real deploy aborted the WHOLE bundle with +# `unknown option "prefer-ipv6"` on barbican. + +# T43 the attempt-1 defect itself, reproduced. barbican's charm does not declare the +# option; setting it is fatal to the entire bundle. +mutate t43.yaml 'b["applications"]["barbican"]["options"]["prefer-ipv6"]=True' +run 1 'does NOT declare that option' \ + "T43 prefer-ipv6 on a charm that does not declare it FAILS (attempt-1 defect)" \ + "$TMP/t43.yaml" + +# T44 PRESENCE, not truthiness. `prefer-ipv6: false` is the SAME fatal `unknown option` +# to juju as `true` -- a check keyed on the VALUE would have read this as clean. +mutate t44.yaml 'b["applications"]["vault"]["options"]["prefer-ipv6"]=False' +run 1 'does NOT declare that option' \ + "T44 prefer-ipv6: FALSE on a non-declaring charm ALSO FAILS (presence, not value)" \ + "$TMP/t44.yaml" + +# T45 the RULED shape passes: the six carry full dual-family vips and no option. This is +# the positive control -- without it, 9a could be satisfied by rejecting everything. +run 0 '13 clustered VIP\(s\).*13 dual-family' \ + "T45 the ruled shape PASSES: six dual-family vips with no prefer-ipv6" "$TMP/good.yaml" + +# T46 9a AND 9b BOTH match this input, and the DIAGNOSIS must be 9a's. octavia with +# prefer-ipv6 true AND a v4 triple satisfies the coupling rule's shape (prefer6 +# True, dual False) as well as the declare rule's. If 9a were ever folded into 9b +# -- the obvious "simplification" -- this would report "prefer-ipv6 and the v6 VIP +# legs must land TOGETHER", sending the next session to add v6 legs when the actual +# fix is to remove the option. A misleading diagnosis has cost this project real +# measurements before, so it is asserted rather than left to review. +# FIRST WRITTEN AS A WEAKER CASE AND REPLACED: the original T46 mutated only the +# vip and could not fail under any mutation of the charm list, because a +# non-declaring charm with no option has prefer6 == dual == False and never reaches +# either branch. It was decoration until the mutation pass caught it. +mutate t46.yaml 'o=b["applications"]["octavia"]["options"]; o["vip"]=" ".join(o["vip"].split()[:3]); o["prefer-ipv6"]=True' +t46out="$(python3 "$CHK" "$TMP/t46.yaml" 2>&1)" +if grep -q 'does NOT declare that option' <<<"$t46out" \ + && ! grep -q 'must land TOGETHER' <<<"$t46out"; then + echo " PASS T46 an input matching BOTH rules is diagnosed by 9a, not 9b"; PASS=$((PASS+1)) +else + echo " FAIL T46 wrong diagnosis: expected the declare message alone" + echo "$t46out" | sed 's/^/ /' | head -6; FAIL=$((FAIL+1)) +fi + echo echo "RESULT: PASS=$PASS FAIL=$FAIL" [[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1