#!/usr/bin/env bash
# tests/provider-bundle-check/run-tests.sh
#
# Offline regression harness for scripts/provider-bundle-check.py. No live infra;
# fixtures are generated from the repo bundle by targeted mutation, so every check
# is proven to FAIL when its invariant is violated (bash -n / "it parsed" proves
# nothing -- behavior is the contract). Mutates NOTHING outside $TMP.
# Exit: 0 all pass | 1 any case failed.  ASCII + LF.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
CHK="$REPO/scripts/provider-bundle-check.py"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0

# fixture base: the real dc0 DEPLOY INPUT + the policies dir DOCFIX-071 resolves.
#
# `pristine.yaml` is bundle.yaml MERGED WITH overlays/vr1-dc0-vips.yaml -- since
# ruling 3 (2026-07-25) the base is VIP-free and the merged pair is what actually
# deploys. `good.yaml` is that plus the ONE thing R11 (D-020 amendment, RULED
# 2026-07-27) ruled and that is not built yet: designate's VIP at the ruled octet
# .62. Without it the new hacluster-principal check fails the real input, and every
# rc=0 case below would assert "fails for the reason we already know" instead of its
# own invariant. T16 keeps the real tree honest by asserting the merged input DOES
# trip it; T31 asserts the bare base is no longer a deploy input at all.
mkdir -p "$TMP/policies"
cp "$REPO/policies/domain-manager-policy.yaml" "$REPO/policies/overrides.zip" "$TMP/policies/"
# RULING 3 (2026-07-25) made bundle.yaml VIP-FREE: every VIP arrives via a per-DC
# overlay, dc0 INCLUDED. So the dc0 DEPLOY INPUT -- and therefore the fixture base
# -- is base + overlays/vr1-dc0-vips.yaml. The bare base is no longer a deploy
# input at all, and T31 asserts exactly that rather than leaving it untested.
python3 - "$REPO/bundle.yaml" "$REPO/overlays/vr1-dc0-vips.yaml" \
         "$TMP/pristine.yaml" "$TMP/good.yaml" "$TMP/v4only.yaml" "$TMP/dual.yaml" <<'PY'
import sys, yaml, copy
base = yaml.safe_load(open(sys.argv[1]))
ovl  = yaml.safe_load(open(sys.argv[2]))
for n, s in (ovl.get("applications") or {}).items():
    base["applications"].setdefault(n, {}).setdefault("options", {}).update(
        (s or {}).get("options") or {})
yaml.safe_dump(base, open(sys.argv[3], "w"))
# Since R11 landed (2026-07-29) the overlay supplies designate AND vault, so the
# deploy input needs no injection any more -- good == pristine. The case that used
# to assert the .62 gap is RE-POINTED below, not deleted.
yaml.safe_dump(base, open(sys.argv[4], "w"))
# A V4-ONLY twin: every vip truncated to its three v4 legs, prefer-ipv6 dropped.
# UNDER D-141 (2026-08-03) the dc0 VIP overlay was itself REVERTED to IPv4-only (the
# v6 GUA block reserved-until-capable in the apex), so `good.yaml` is ALREADY v4-only
# and this twin is byte-identical to it today. The builder is RETAINED, not removed:
# if D-141 rule-3 promotion re-renders the overlay dual-family, `good.yaml` becomes a
# sextet again and this twin AUTOMATICALLY re-diverges to the three v4 legs the
# v4-shaped cases (T5/T8/T26/T28) need -- a stable v4 base independent of good's family.
v4 = copy.deepcopy(base)
for n, spec in v4["applications"].items():
    o = (spec or {}).get("options") or {}
    if o.get("vip"):
        o["vip"] = " ".join(str(o["vip"]).split()[:3])
        o.pop("prefer-ipv6", None)
yaml.safe_dump(v4, open(sys.argv[5], "w"))
# A DUAL-FAMILY fixture: good.yaml's v4 triples with the three v6 legs appended, the
# v6 host part textually mirroring the v4 octet. This is the shape D-141 rule-3
# promotion RESTORES. The v6 prefixes are the MEASURED all-GUA legs of the pre-revert
# dual-family deploy input (commit 3e691cd^ overlays/vr1-dc0-vips.yaml): provider
# 2602:f3e2:f02:11::/64, metal-admin 2602:f3e2:f02:20::/64, metal-internal
# 2602:f3e2:f02:21::/64 -- all present in the current apex (D-139 step 6, 2026-08-02).
# NOT the module DUAL6 constant, whose fd50: admin/internal legs are the ULA family
# D-139 DEPRECATED (do-not-allocate under D-141) -- flagged in the changelog. Because
# the dc0 deploy input is v4-only under D-141, the dual-family PASS controls (T21/T45)
# and the apex-refusal control (T25) validate against THIS synthetic input, not good.
PROV, ADM, INT = "2602:f3e2:f02:11::", "2602:f3e2:f02:20::", "2602:f3e2:f02:21::"
dual = copy.deepcopy(base)
for n, spec in dual["applications"].items():
    o = (spec or {}).get("options") or {}
    v = o.get("vip")
    if v and len(str(v).split()) == 3:
        legs = str(v).split(); octet = legs[0].split(".")[-1]
        o["vip"] = " ".join(legs + [PROV + octet, ADM + octet, INT + octet])
yaml.safe_dump(dual, open(sys.argv[6], "w"))
PY
# A valid dual-family (R2 dual-stack) VIP for keystone: the three v4 legs plus the
# three v6 legs, host part mirroring the v4 octet per the 2026-07-27 ruling. The
# provider v6 leg is the DEDICATED GUA VIP /64 (f02:11::), not the node /64.
DUAL6="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"

mutate4() { # mutate the V4-ONLY twin -- for cases whose invariant predates dual-stack
  python3 - "$TMP/v4only.yaml" "$TMP/$1" "$2" <<'PY'
import sys, yaml
src, dst, expr = sys.argv[1], sys.argv[2], sys.argv[3]
b = yaml.safe_load(open(src))
exec(expr)
yaml.safe_dump(b, open(dst, "w"))
PY
}

mutate() { # mutate <out> <python-expr operating on dict b>
  python3 - "$TMP/good.yaml" "$TMP/$1" "$2" <<'PY'
import sys, yaml
src, dst, expr = sys.argv[1], sys.argv[2], sys.argv[3]
b = yaml.safe_load(open(src))
exec(expr)
yaml.safe_dump(b, open(dst, "w"))
PY
}

run() { # run <want_rc> <regex> <label> <bundle>
  local want="$1" rx="$2" label="$3" f="$4"
  local out rc
  out="$(python3 "$CHK" "$f" 2>&1)"; rc=$?
  if [[ "$rc" == "$want" ]] && grep -qE "$rx" <<<"$out"; then
    echo "  PASS  $label"; PASS=$((PASS+1))
  else
    echo "  FAIL  $label (rc=$rc want=$want)"; echo "$out" | sed 's/^/        /' | head -6; FAIL=$((FAIL+1))
  fi
}

# T1 happy path: the repo bundle itself must PASS
run 0 'PASS: Pattern A' "T1 repo bundle passes clean" "$TMP/good.yaml"

# T2 relation referencing a ghost app
mutate t2.yaml 'b["relations"].append(["ghost-app:amqp","rabbitmq-server:amqp"])'
run 1 'unknown app' "T2 ghost relation app FAILS" "$TMP/t2.yaml"

# T3 endpoint-less relation side (the missing-colon class)
mutate t3.yaml 'b["relations"].append(["keystone","rabbitmq-server:amqp"])'
run 1 'lacks explicit :endpoint' "T3 endpoint-less relation FAILS" "$TMP/t3.yaml"

# T4 D-062 regression: mysql at 1 unit
mutate t4.yaml 'b["applications"]["mysql-innodb-cluster"]["num_units"]=1'
run 1 'D-062 requires 3' "T4 mysql num_units=1 FAILS" "$TMP/t4.yaml"

# T5 shared VIP octet between two apps
mutate4 t5.yaml 'b["applications"]["glance"]["options"]["vip"]="10.12.4.50 10.12.8.50 10.12.12.50"'
run 1 'shared by' "T5 duplicate VIP octet FAILS" "$TMP/t5.yaml"

# T6 DOCFIX-071 regression: keystone resource stanza removed
mutate t6.yaml 'b["applications"]["keystone"].pop("resources",None)'
run 1 'DOCFIX-071' "T6 missing policyd resource FAILS" "$TMP/t6.yaml"

# T7 DOCFIX-071 drift: zip content differs from the yaml source
mutate t7.yaml 'pass'
printf '\n# drift\n' >> "$TMP/policies/domain-manager-policy.yaml"
run 1 'DIFFERS from policies' "T7 zip/source drift FAILS" "$TMP/t7.yaml"
cp "$REPO/policies/domain-manager-policy.yaml" "$TMP/policies/"   # restore

# T8 original invariant still guarded: octavia VIP off-band (band widened to 50-99 by R11)
mutate4 t8.yaml 'b["applications"]["octavia"]["options"]["vip"]="10.12.4.233 10.12.8.233 10.12.12.233"'
run 1 'outside 50-99' "T8 off-band VIP octet FAILS" "$TMP/t8.yaml"

# ---- Placement / anti-affinity checks (only fire on a role-separated bundle) ----
# Build a valid role-separated fixture from the base: 3 control / 2 compute / 4 storage,
# quorum trio + control containers on control, ceph-osd on storage, nova-compute on compute.
python3 - "$TMP/good.yaml" "$TMP/rolesep.yaml" <<'PY'
import sys, yaml
b = yaml.safe_load(open(sys.argv[1]))
roles = ["control","control","control","compute","compute","storage","storage","storage","storage"]
b["machines"] = {str(i): {"constraints": "arch=amd64 tags=openstack-vr1-dc0,%s" % r}
                 for i, r in enumerate(roles)}
for n, s in b["applications"].items():
    if not isinstance(s, dict) or "to" not in s:
        continue
    if   n == "ceph-osd":     s["num_units"] = 4; s["to"] = ["5","6","7","8"]
    elif n == "nova-compute": s["num_units"] = 2; s["to"] = ["3","4"]
    else:
        # num_units-aware since BUNDLEFIX-053 scaled the control plane 1->3 (D-121):
        # a 3-unit HA app lands on the 3 distinct control machines (lxd:0/1/2), a
        # single-unit app on lxd:0. Before this, control apps were pinned to lxd:0
        # while num_units was 3 -> the anti-affinity/count check FAILED the whole
        # fixture (T9). Guarded: only 3 control machines (0,1,2) exist, so an app with
        # to: and num_units>3 that is not ceph-osd/nova-compute has no home here and
        # the builder must be revisited rather than silently emitting off-role lxd:3.
        nu = int(s.get("num_units", 1) or 1)
        if nu > 3:
            raise SystemExit("rolesep fixture builder: %s num_units=%d exceeds the 3 "
                             "control machines (0,1,2); builder needs update" % (n, nu))
        s["to"] = ["lxd:%d" % j for j in range(nu)]
yaml.safe_dump(b, open(sys.argv[2], "w"))
PY

mutate_rs() { python3 - "$TMP/rolesep.yaml" "$TMP/$1" "$2" <<'PY'
import sys, yaml
b = yaml.safe_load(open(sys.argv[1])); exec(sys.argv[3]); yaml.safe_dump(b, open(sys.argv[2], "w"))
PY
}
runargs() { # runargs <want_rc> <regex> <label> -- <checker args...>
  local want="$1" rx="$2" label="$3"; shift 3
  local out rc; out="$(python3 "$CHK" "$@" 2>&1)"; rc=$?
  if [[ "$rc" == "$want" ]] && grep -qE "$rx" <<<"$out"; then
    echo "  PASS  $label"; PASS=$((PASS+1))
  else
    echo "  FAIL  $label (rc=$rc want=$want)"; echo "$out" | sed 's/^/        /' | head -6; FAIL=$((FAIL+1))
  fi
}

# T9 valid role-separated bundle PASSES (placement active, all invariants hold)
run 0 'counts OK' "T9 valid role-separated bundle passes" "$TMP/rolesep.yaml"

# T10 dangling to: (undefined machine)
mutate_rs t10.yaml 'b["applications"]["glance"]["to"]=["lxd:99"]'
run 1 'undefined machine' "T10 dangling to: FAILS" "$TMP/t10.yaml"

# T11 off-role: ceph-osd on control/compute instead of storage
mutate_rs t11.yaml 'b["applications"]["ceph-osd"]["to"]=["0","1","2","3"]'
run 1 'off-role .want storage' "T11 ceph-osd off-role FAILS" "$TMP/t11.yaml"

# T12 anti-affinity defeat: a 3-unit app stacked on 2 machines
mutate_rs t12.yaml 'b["applications"]["mysql-innodb-cluster"]["to"]=["lxd:0","lxd:0","lxd:1"]'
run 1 'anti-affinity defeat' "T12 stacked control units FAIL" "$TMP/t12.yaml"

# T13 wrong bare-metal count: ceph-osd != number of storage nodes
mutate_rs t13.yaml 'b["applications"]["ceph-osd"]["num_units"]=3'
run 1 '!= 4 storage node' "T13 ceph-osd count mismatch FAILS" "$TMP/t13.yaml"

# T14 overlay merge + DC bands: the REAL dc1 deploy input, --dc vr1-dc1 PASSES.
# RE-POINTED 2026-07-29: this used to apply the dc1 VIP overlay ALONE, which pinned
# "dc1 VIPs over dc0-tagged machines is fine". Invariant 11 rules that shape out --
# it is the 4.3 defect (a dc1 model allocating dc0 hardware) -- so the case is moved
# to the input that actually deploys (machines overlay INCLUDED) and the old shape
# becomes T32's RED fixture rather than being deleted.
runargs 0 'PASS: Pattern A' "T14 real dc1 deploy input (machines+vips) + --dc vr1-dc1 passes" \
  "$TMP/good.yaml" --overlay "$REPO/overlays/vr1-dc1-machines.yaml" \
  --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1

# T15 same overlay but default (dc0) bands -> dc1 VIPs off-band FAIL
runargs 1 'not in 10.12.4' "T15 dc1 VIPs vs dc0 bands FAILS" \
  "$TMP/good.yaml" --overlay "$REPO/overlays/vr1-dc1-vips.yaml"

# ---- R11 (D-020 amendment): an hacluster principal with no VIP is decorative HA ----
# T16 the REAL repo bundle trips it. This is the ruled-but-not-built designate VIP;
#     when .62 lands in bundle.yaml this case must be re-pointed, NOT deleted.
# RE-POINTED 2026-07-29: this asserted designate's VIP was MISSING. R11 built it, so
# the assertion is inverted to the surviving invariant -- the real deploy input is now
# CLEAN -- and T16b keeps the check's teeth by removing a VIP and demanding it fire.
# RE-POINTED 2026-08-05 (BUNDLEFIX-053): bundle.yaml now carries the FULL HA chain,
# vault-hacluster INCLUDED, so pristine (no overlay) already has 13 hacluster principals
# -- was 12 before vault-hacluster moved from the HA overlay into the base. This
# DISSOLVES the old T16(pre-overlay=12)/T17(post-overlay=13) ordering distinction: both
# read 13 now. T16 on its own asserts the real dc0 deploy input is CLEAN, every one of
# the 13 base-bundle hacluster principals carrying a VIP. dc-ha-scaleup.yaml was found
# WHOLLY REDUNDANT (Task-#1 post-wave review) and RETIRED 2026-08-06 (operator ruling
# "Retire the redundancy and archive"; archived to docs/archive/). Every case that used
# it as a fixture (T17/T17b/T32/T33/T34) is re-pointed onto good.yaml (the base HA chain).
run 0 '13 hacluster principal\(s\) all carry a VIP' \
  "T16 the real dc0 deploy input is CLEAN (full HA chain in base)" "$TMP/pristine.yaml"

mutate t16b.yaml 'b["applications"]["designate"]["options"].pop("vip"); b["applications"]["designate"]["options"].pop("prefer-ipv6",None)'
run 1 'hacluster relation but no vip: designate' \
  "T16b removing designate's VIP still FAILS" "$TMP/t16b.yaml"

# T16c/T16d BUNDLEFIX-056 (2026-08-06): designate JOINED public->provider-public (count 11->12).
# These prove EXPECT_PUBLIC_VIP=12 can still FAIL in BOTH directions -- an app dropping off
# provider-public (11) and vault wrongly joining (13). Without them the exact-count constant is
# decoration (the repo's mutation-pass lesson). See scripts/provider-bundle-check.py:121.
mutate t16c.yaml 'b["applications"]["designate"]["bindings"].pop("public")'
run 1 'public->provider-public count=11 \(expect 12\)' \
  "T16c dropping designate's public binding FAILS (count 11 != 12)" "$TMP/t16c.yaml"

mutate t16d.yaml 'b["applications"]["vault"].setdefault("bindings",{})["public"]="provider-public"'
run 1 'public->provider-public count=13 \(expect 12\)' \
  "T16d vault joining public->provider-public FAILS (count 13 != 12; vault is metal-only)" "$TMP/t16d.yaml"

# T31 ruling 3's new invariant: the BARE base is not a deploy input. Validating it
#     alone must name every clustered principal, not silently pass -- a VIP-free
#     bundle deployed without its overlay is 11 charms of decorative HA.
run 1 'hacluster relation but no vip: barbican' \
  "T31 the VIP-free base ALONE fails for all clustered principals" "$REPO/bundle.yaml"

# T17 (R6 ordering) RETIRED 2026-08-06 with dc-ha-scaleup.yaml. It asserted "13 principals
#     carry a VIP" UNDER the overlay; once vault-hacluster moved into the base (BUNDLEFIX-053)
#     the overlay added nothing, making T17 IDENTICAL to T18 (same assertion on good.yaml,
#     no overlay). T18 below keeps that coverage. T17b is KEPT -- it is the vault-SPECIFIC
#     failing direction (T16b covers designate; T31 covers "all principals") -- and RE-POINTED
#     off the retired overlay onto good.yaml, whose base HA chain includes vault-hacluster.
mutate t17b.yaml 'b["applications"]["vault"]["options"].pop("vip"); b["applications"]["vault"]["options"].pop("prefer-ipv6",None)'
run 1 'hacluster relation but no vip: vault' \
  "T17b removing vault's VIP FAILS (vault-specific, on the base HA chain)" \
  "$TMP/t17b.yaml"

# T18 the check can also PASS, not only fail: give designate its ruled VIP -> green.
run 0 'hacluster principal\(s\) all carry a VIP' \
  "T18 hacluster check goes GREEN once the ruled VIP lands" "$TMP/good.yaml"

# ---- R2 dual-stack: VIP arity, family coupling, and the apex-derived v6 bands ----
# T19 RE-POINTED 2026-08-03: the dc0 VIP overlay was reverted to IPv4-only under D-141
#     (the v6 GUA block reserved-until-capable in the apex), so the REAL deploy input is
#     now v4-only. This used to assert "every app is dual-family"; that invariant is
#     REPLACED, not deleted -- the real deploy input must read 0 dual-family. The
#     dual-family full-count positive control moves to T21/T45 on the synthetic
#     dual.yaml. When D-141 rule-3 promotes v6, re-point this back to dual-family.
# COUNT 13->12 (2026-08-05, D-020 amendment): vault is now METAL-ONLY and is counted by
# its own [ok] line, NOT the provider/admin/internal triple count -- so 13 triples -> 12.
run 0 '12 clustered VIP\(s\).*\(0 dual-family\)' \
  "T19 the real dc0 deploy input is v4-only under D-141 (0 dual-family)" "$TMP/good.yaml"

# T20/T21 RE-POINTED 2026-07-31 (D-101 RULING NOTE (b), operator: "Set it false on the
#     seven, keep every v6 VIP leg"). Both previously asserted the COUPLING -- that the
#     option and the v6 legs travel together on a declaring charm. The ruling removes the
#     option from EVERY application until IPv6 is operational, so the coupling form would
#     fail on the ruled artifact. The assertions are RE-POINTED to the new invariant, not
#     deleted, and both subjects are kept: keystone is a DECLARING charm, which is exactly
#     the case the old coupling covered and the case most likely to be "restored" by
#     someone who remembers R2.
# T20 the option on a declaring charm now FAILS, v6 legs or not.
mutate t20.yaml 'b["applications"]["keystone"]["options"]["prefer-ipv6"]=True'
run 1 'RULED OFF for every application' \
  "T20 prefer-ipv6 on a DECLARING charm FAILS (ruled off everywhere)" "$TMP/t20.yaml"

# T21 the ruled shape -- v6 legs present, option absent -- must PASS. This is the
#     positive control the absence rule needs: without it, 9b could be satisfied by
#     rejecting everything. RE-POINTED 2026-08-03 to the synthetic dual.yaml: under
#     D-141 the real deploy input (good.yaml) is v4-only, so the dual-family shape is
#     carried by the fixture promotion restores, not by good.yaml.
run 0 '12 clustered VIP\(s\).*\(12 dual-family\)' \
  "T21 v6 legs WITHOUT the option PASSES (the ruled shape)" "$TMP/dual.yaml"

# T22 v6 provider leg in the NODE /64 instead of the dedicated GUA VIP /64
# NOTE: these no longer set prefer-ipv6. They did, because the OLD coupling required
#       it alongside v6 legs; under the 2026-07-31 (b) absence rule 9b fires FIRST and
#       short-circuits, so the v6-band assertion would never be reached and the case
#       would pass for the wrong reason.
mutate t22.yaml "b['applications']['keystone']['options'].update({'vip':'${DUAL6/f02:11::50/f02:10::50}'})"
run 1 'provider v6 leg .* not in 2602:f3e2:f02:11::/64' \
  "T22 v6 leg in the wrong /64 FAILS" "$TMP/t22.yaml"

# T23 v6 host part not mirroring the v4 octet -- `printf '%x' 50` = 32 is the exact
#     plausible-looking wrong value the 2026-07-27 ruling exists to prevent.
mutate t23.yaml "b['applications']['keystone']['options'].update({'vip':'${DUAL6/f02:11::50/f02:11::32}'})"
run 1 'must MIRROR the v4 octet' "T23 v6 host part not mirroring the v4 octet FAILS" "$TMP/t23.yaml"

# T24 arity that is neither a triple nor a sextet
mutate t24.yaml 'b["applications"]["keystone"]["options"]["vip"]="10.12.4.50 10.12.8.50"'
run 1 'neither a v4 triple .3. nor a dual-family sextet' "T24 2-address vip FAILS" "$TMP/t24.yaml"

# T25 an unreadable apex REFUSES (rc=2) rather than passing -- "could not look" is
#     never "nothing there". Only fires when a dual-family vip is actually present, so
#     RE-POINTED 2026-08-03 to dual.yaml (good.yaml is v4-only under D-141 and needs no
#     apex -- that path is T26). This keeps the refusal control on a live dual input.
APEX_RECORD=/nonexistent/apex.json runargs 2 'cannot evaluate .* no readable apex record' \
  "T25 dual-family + unreadable apex REFUSES (rc=2)" "$TMP/dual.yaml"

# T26 ...and a v4-only bundle does NOT need the apex at all (no spurious refusal)
APEX_RECORD=/nonexistent/apex.json runargs 0 'PASS: Pattern A' \
  "T26 v4-only bundle needs no apex record" "$TMP/v4only.yaml"

# ---- R11 band widen 50-60 -> 50-99 ----
# T27 octet .62 (designate's ruled octet) is now IN band -- proven by the band message
run 0 'octet 50-99' "T27 R11 band widened to 50-99" "$TMP/good.yaml"

# T28 ...but the band is still bounded: .100 is still rejected
mutate4 t28.yaml 'b["applications"]["glance"]["options"]["vip"]="10.12.4.100 10.12.8.100 10.12.12.100"'
run 1 'outside 50-99' "T28 band still bounded (.100 rejected)" "$TMP/t28.yaml"

# ---- the dc1 arm of the apex lookup, exercised for real ----
# T19-T23 all run at the default --dc vr1-dc0, so the dc1 v6 bands (f03:11::/64,
# :320::/64, :321::/64) were resolved by NO case. The renderer's first real output
# is per-DC overlays; a gate observed in only one DC mode is exactly the
# only-ever-seen-one-way untrustworthiness T16/T18 exist to rule out.
cat > "$TMP/dc1-dual.yaml" <<'YAML'
applications:
  keystone:
    options:
      prefer-ipv6: true
      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"
YAML
# T29 asserted a string the dc1 overlay alone already produces, so its own fixture was
# INERT and the case could not fail on it (2026-07-29 chain audit; same defect class as
# T20/T21 the day before). Re-pointed at what ONLY this path proves: a dc1 v6 leg is
# validated against dc1's apex /64s, which a dc0 run cannot reach.
python3 - "$REPO" "$TMP/apex-nodc1.json" <<'PY'
import json, glob, sys
rec = sorted(glob.glob(sys.argv[1] + "/netbox/draft/vr1-office1-current-*.json"))[-1]
d = json.load(open(rec))
d["ipam/prefixes"] = [p for p in d.get("ipam/prefixes", [])
                      if (p.get("scope_site") or "") != "vr1-dc1"]
json.dump(d, open(sys.argv[2], "w"))
PY
APEX_RECORD="$TMP/apex-nodc1.json" runargs 2 'carries no v6 prefix for .* in vr1-dc1' \
  "T29 dc1 v6 legs really come from dc1's OWN apex entries (strip them -> REFUSE)" \
  "$TMP/good.yaml" --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1

# T30 the v6 analogue of T15: dc0 v6 legs under --dc vr1-dc1 must FAIL, so a
#     cross-DC copy-paste of a rendered overlay cannot pass.
# The fixture no longer carries prefer-ipv6 -- same reason as T22/T23: under the
# 2026-07-31 (b) absence rule 9b fires first and the cross-DC band assertion would
# never be reached.
cat > "$TMP/dc1-dual-wrong.yaml" <<'YAML'
applications:
  keystone:
    options:
      vip: "10.12.64.50 10.12.68.50 10.12.72.50 2602:f3e2:f02:11::50 fd50:840e:74e2:220::50 fd50:840e:74e2:221::50"
YAML
runargs 1 'provider v6 leg .* not in 2602:f3e2:f03:11::/64' \
  "T30 dc0 v6 legs under --dc vr1-dc1 FAIL (cross-DC leak)" \
  "$TMP/good.yaml" --overlay "$REPO/overlays/vr1-dc1-vips.yaml" \
  --overlay "$TMP/dc1-dual-wrong.yaml" --dc vr1-dc1

# ---- Invariant 10: HA ARITY -- cluster_count == the principal's num_units ----
# Until 2026-07-29 `cluster_count` was asserted NOWHERE in scripts/ or tests/, so a 3->1
# rewrite of every value was a BYTE-IDENTICAL PASS. These cases are the proof that is over.
# The arity now lives in the base bundle (BUNDLEFIX-053), so the fixtures below mutate
# good.yaml directly (2026-08-06 re-point off the retired dc-ha-scaleup.yaml overlay).
# The checker ENUMERATES (one DECORATIVE line per offending sub) with no aggregate/threshold
# path -- MEASURED 2026-08-06: an all-13 rewrite exits 1 with 13 lines, a single-sub rewrite
# exits 1 with 1 line. So the single-sub mutate() fixtures below are equivalent to the old
# whole-overlay rewrite for proving the gate FIRES; they are not a narrowing.

# T32 the GREEN twin first: the base HA chain (good.yaml, no overlay) declares matching
#     arity for all 13 subordinates. RE-POINTED 2026-08-06 off the retired dc-ha-scaleup.yaml
#     -- BUNDLEFIX-053 folded the arity into the base, so good.yaml is now the fixture.
run 0 '13 hacluster subordinate\(s\) declare cluster_count == principal num_units' \
  "T32 base HA chain: arity matches for all 13 subordinates" \
  "$TMP/good.yaml"

# T33 THE decorative-HA fixture: a sub's cluster_count driven 3 -> 1 while its principal
#     stays num_units=3. RE-POINTED 2026-08-06 onto good.yaml (the base HA chain) via the
#     deterministic mutate() helper -- no overlay, and no sed pattern that could silently
#     miss and yield a byte-identical fixture (a wrong key path KeyErrors loudly instead).
mutate ha-3to1.yaml 'b["applications"]["keystone-hacluster"]["options"]["cluster_count"]=1'
run 1 'cluster_count=1 but principal .* num_units=3 -- DECORATIVE HA' \
  "T33 a sub cluster_count 3->1 FAILS (decorative HA caught)" \
  "$TMP/ha-3to1.yaml"

# T34 the OTHER direction: a cluster_count of 3 outliving a principal scale-down to 1.
#     Equality, not a floor, is what catches this. RE-POINTED 2026-08-06 onto good.yaml
#     via mutate() (deterministic; a wrong key path KeyErrors loudly rather than no-op'ing).
mutate ha-scaledown.yaml 'b["applications"]["keystone"]["num_units"]=1'
run 1 'cluster_count=3 but principal .* num_units=1 -- DECORATIVE HA' \
  "T34 cluster_count 3 outliving a principal scale-down to 1 FAILS" \
  "$TMP/ha-scaledown.yaml"

# T35 an ABSENT cluster_count refuses rather than accepting the charm default --
#     "could not look" is never "nothing there". Mutated in the BASE, not an overlay:
#     juju's overlay merge has no delete-a-nested-key form (a null deletes a whole
#     application, not one option), so an overlay CANNOT unset cluster_count -- only
#     an edit to bundle.yaml can, which is precisely the edit this case guards.
mutate t35.yaml 'b["applications"]["keystone-hacluster"]["options"].pop("cluster_count")'
run 1 'declares NO cluster_count' \
  "T35 hacluster subordinate with no cluster_count FAILS" "$TMP/t35.yaml"

# ---- Invariant 11: MACHINES-BLOCK DC IDENTITY ----
# One MAAS region sees BOTH DCs' nodes, so the machines `tags=` is the only thing
# keeping dc1's model off dc0's hardware. All three shapes below used to exit 0.

# T36 the dc1 VIP overlay WITHOUT the machines overlay -- the exact input T14 used to
#     assert was fine. dc1 VIPs over dc0-tagged machines.
runargs 1 'machines block is tagged openstack-vr1-dc0 but --dc vr1-dc1' \
  "T36 dc1 input with the machines overlay MISSING FAILS" \
  "$TMP/good.yaml" --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1

# T37 a GUTTED machines overlay: it parses, it merges, it contributes nothing.
printf 'machines: {}\n' > "$TMP/mach-gutted.yaml"
runargs 1 'machines block is tagged openstack-vr1-dc0 but --dc vr1-dc1' \
  "T37 gutted machines overlay FAILS (parses, contributes nothing)" \
  "$TMP/good.yaml" --overlay "$TMP/mach-gutted.yaml" \
  --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1

# T38 a MIS-KEYED machines overlay ("01" where the base says "0"): the entries land
#     BESIDE the base's instead of over them, so both tag sets survive. This one is
#     caught under EITHER --dc, which T39 proves -- it needs no declared DC to see it.
sed 's/^  "\([0-8]\)":/  "0\1":/' "$REPO/overlays/vr1-dc1-machines.yaml" > "$TMP/mach-miskeyed.yaml"
runargs 1 'machines block MIXES DC tags' \
  "T38 mis-keyed machines overlay FAILS (--dc vr1-dc1)" \
  "$TMP/good.yaml" --overlay "$TMP/mach-miskeyed.yaml" \
  --overlay "$REPO/overlays/vr1-dc1-vips.yaml" --dc vr1-dc1
runargs 1 'machines block MIXES DC tags' \
  "T39 ...and under --dc vr1-dc0 too (mixing needs no declared DC)" \
  "$TMP/good.yaml" --overlay "$TMP/mach-miskeyed.yaml"

# T40 the inert-entry assertion in its own right: a machine no application places to.
mutate t40.yaml 'b["machines"]["9"]={"constraints":"arch=amd64 tags=openstack-vr1-dc0,storage"}'
run 1 'DEFINED but no application places' \
  "T40 an unreferenced machine entry FAILS" "$TMP/t40.yaml"

# T41 a machine with no DC tag at all -- allocatable from EITHER DC.
mutate t41.yaml 'b["machines"]["4"]={"constraints":"arch=amd64 tags=compute"}'
run 1 'carries no single DC tag' \
  "T41 machine with no DC tag FAILS" "$TMP/t41.yaml"

# T42 THE BYPASS these checks nearly shipped with: an overlay rewriting every machine's
#     constraints to a bare `arch=amd64` strips the role tags AND the DC tag together.
#     While invariant 11 lived inside the role-separation branch, role_sep went False and
#     the entire placement block -- the DC-tag assertions included -- self-skipped behind
#     an [ok]. So the most destructive machines overlay of all exited 0. The checks are
#     now keyed on the machines block existing, not on role tags being present.
{ printf 'machines:\n'
  for i in 0 1 2 3 4 5 6 7 8; do printf '  "%s": {constraints: arch=amd64}\n' "$i"; done
} > "$TMP/mach-untagged.yaml"
runargs 1 'carries no single DC tag' \
  "T42 overlay stripping ALL tags FAILS (does not self-skip via role_sep)" \
  "$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.
#     RE-POINTED 2026-08-03 to dual.yaml (good.yaml is v4-only under D-141).
run 0 '12 clustered VIP\(s\).*\(12 dual-family\)' \
  "T45 the ruled shape PASSES: dual-family vips with no prefer-ipv6 (vault metal-only excluded)" "$TMP/dual.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

# T47 9a must cover applications that carry NO vip. Written inside the VIP loop first,
#     which would have missed the case the repo ALREADY has:
#     overlays/dc-dc-ipv6-family-matrix.yaml sets prefer-ipv6 on ceph-mon, which has no
#     vip and is outside every other check in provider-bundle-check. That overlay is a
#     LATER deploy step, so the miss would not have shown up in attempt 2 -- it would
#     have shown up at the step after, looking like a new fault.
mutate t47.yaml 'b["applications"]["ovn-central"].setdefault("options",{})["prefer-ipv6"]=True'
run 1 'ovn-central sets prefer-ipv6' \
  "T47 prefer-ipv6 on a NON-VIP app whose charm lacks it FAILS" "$TMP/t47.yaml"

# T48 the positive control for T47: ceph-mon has no vip and DOES declare the option
#     (measured, charm-ceph-mon squid/stable rev 491). 9a must not over-fire on it, or
#     the real dc-dc-ipv6-family-matrix overlay becomes un-gateable.
mutate t48.yaml 'b["applications"]["ceph-mon"].setdefault("options",{})["prefer-ipv6"]=True'
run 0 'PASS: Pattern A' \
  "T48 prefer-ipv6 on a NON-VIP app whose charm DOES declare it PASSES" "$TMP/t48.yaml"

# ---- invariant 12: every application carries an explicit base == default-base ----
# RULED 2026-07-31 (GA-R5 option D). MEASURED: a bundle relying on default-base alone is
# deployable exactly ONCE -- on a re-run juju resolves an EXISTING app's charm without
# that default, lands on the ubuntu@24.04 revision (barbican 265 vs 261, mysql-router
# 1154 vs 1178) and refuses the downgrade, so a partial deploy cannot be resumed. The 56
# redundant `base:` lines ARE the fix; these cases stop them being simplified away.

# T49 the whole point: strip every base and the gate must FAIL. Before invariant 12 this
#     was a byte-identical PASS.
mutate t49.yaml 'for a in b["applications"].values(): a.pop("base", None)'
run 1 'application\(s\) carry NO explicit base' \
  "T49 stripping every base FAILS (was a byte-identical PASS)" "$TMP/t49.yaml"

# T50 ONE app losing it is enough -- juju aborts the whole bundle on the first mismatch,
#     so a partial strip is not a partial problem.
mutate t50.yaml 'b["applications"]["vault"].pop("base", None)'
run 1 'application\(s\) carry NO explicit base' \
  "T50 a SINGLE app missing its base FAILS" "$TMP/t50.yaml"

# T51 a base that disagrees with default-base is its own defect: it deploys a charm for
#     the wrong series rather than failing loudly.
mutate t51.yaml 'b["applications"]["keystone"]["base"]="ubuntu@24.04/stable"'
run 1 'not the bundle.s default-base' \
  "T51 an app base disagreeing with default-base FAILS" "$TMP/t51.yaml"

# T52 subordinates are IN SCOPE. mysql-router differs across bases (1154 vs 1178) and
#     hacluster sharing a revision today is not a property to depend on.
mutate t52.yaml 'b["applications"]["keystone-hacluster"].pop("base", None)'
run 1 'keystone-hacluster' \
  "T52 a SUBORDINATE missing its base FAILS (not exempted)" "$TMP/t52.yaml"

# T53 no default-base to compare against REFUSES rather than passing vacuously --
#     "could not look" is never "nothing there".
mutate t53.yaml 'b.pop("default-base", None)'
run 1 'declares no top-level default-base' \
  "T53 a bundle with no default-base REFUSES (does not pass vacuously)" "$TMP/t53.yaml"

# ---- vault METAL-ONLY invariant (D-020 amendment 2026-08-05) failing-direction fixtures ----
# The positive control is the real deploy input: T16/T18/T19 all run it and its output
# carries `vault vip is metal-only ...`. These three prove the new branch can FAIL -- a
# fresh assertion is decoration until a fixture reddens it (this repo's measured rule).
# T54 a provider-public leg on vault FAILS. vault is the PKI/CA root: it must never be
#     tenant-facing. Uses a 2-leg pair so this tests the PROVIDER exclusion, not arity.
mutate t54.yaml 'b["applications"]["vault"]["options"]["vip"]="10.12.4.61 10.12.8.61"'
run 1 'vault vip carries a provider-public leg' \
  "T54 vault vip with a provider-public leg FAILS (metal-only)" "$TMP/t54.yaml"

# T55 the OLD provider/admin/internal triple FAILS -- the pre-amendment .61 shape that
#     findif-failed live is now rejected at the gate, not just in the field.
mutate t55.yaml 'b["applications"]["vault"]["options"]["vip"]="10.12.4.61 10.12.8.61 10.12.12.61"'
run 1 'vault vip must be the metal PAIR' \
  "T55 vault as a provider/admin/internal triple FAILS (must be metal pair)" "$TMP/t55.yaml"

# T56 THE gap the amendment nearly shipped: the first draft `continue`d before the octet
#     band + octet_owner registration, so vault's .61 was un-band-checked AND never
#     registered -- a second app given .61 would collide with NOTHING and pass silently.
#     Give glance vault's .61 and demand the collision fires. This case would PASS (rc=0,
#     the gate disarmed) against that draft; it FAILS now because the metal branch
#     registers octet_owner too. Proves the metal-only path still guards uniqueness.
mutate t56.yaml 'b["applications"]["glance"]["options"]["vip"]="10.12.4.61 10.12.8.61 10.12.12.61"'
run 1 'octet .61 shared by' \
  "T56 another app taking vault's .61 octet FAILS (metal branch still registers uniqueness)" "$TMP/t56.yaml"

echo
echo "RESULT: PASS=$PASS FAIL=$FAIL"
[[ "$FAIL" -eq 0 ]] && { echo "ALL PASS"; exit 0; } || exit 1
