diff --git a/docs/changelog-20260724-stage5-committee-fork1.md b/docs/changelog-20260724-stage5-committee-fork1.md index 86cd498..5fc78d3 100644 --- a/docs/changelog-20260724-stage5-committee-fork1.md +++ b/docs/changelog-20260724-stage5-committee-fork1.md @@ -67,6 +67,30 @@ **Revert:** re-insert the orphan block after the vault-hacluster comment (not advised -- it is the bug). +## Item 6 -- provider-bundle-check.py: overlay merge + per-DC VIP bands + placement invariants + +**What:** extended `scripts/provider-bundle-check.py` with (a) `--overlay FILE` +(juju-like merge, so the checker validates the EFFECTIVE deploy input), (b) `--dc +vr1-dc0|vr1-dc1` selecting expected VIP bands (default dc0 = base bundle, so an +un-parameterized run is byte-identical to before), and (c) placement invariants that +activate ONLY on a role-separated bundle (machines tagged control/compute/storage) +and self-skip on the base/un-rendered bundle: no dangling `to:`; LXD containers on +control, ceph-osd on storage, nova-compute on compute; each num_units>=2 app spread +across that many DISTINCT machines (anti-affinity); ceph-osd == storage count, +nova-compute == compute count. Dropped the "controller-tag" idea (the controller is +the bootstrap machine, not a bundle machine -- verified at the MAAS-tagging step). +**Why:** the committee's non-negotiable "spine" -- the checker was placement-blind, +so any render could silently ship decorative HA. Makes the hand-render machine-checked; +also closes the committee's dc1-VIP-outside-the-check gap. +**Caveat (VERIFY-LIVE):** the checker's machines-block overlay merge MIRRORS juju's +documented behavior; the actual machines-retag-via-overlay is confirmed at +`juju deploy --dry-run` before deploy (no offline juju merger exists). Fallback if it +does not take: inline the dc1 tags into a rendered bundle. +**Validation:** harness `tests/provider-bundle-check/run-tests.sh` 15/15 (7 new +behavioral cases). Gauntlet ALL GREEN (78). repo-lint 0 fail. +**Revert:** `git checkout ~ -- scripts/provider-bundle-check.py +tests/provider-bundle-check/run-tests.sh`. + ## Next (not done this session; gated) Commit+push this batch (GA-R5: rulings land before dependent work). Then, each diff --git a/scripts/provider-bundle-check.py b/scripts/provider-bundle-check.py index ab6ffe5..431b1cf 100644 --- a/scripts/provider-bundle-check.py +++ b/scripts/provider-bundle-check.py @@ -25,15 +25,19 @@ and now also scripts/review-bundle.py (DOCFIX-070). FAIL -> exit 1. ASCII-only output. (repo-lint: allow-stale-tokens -- guard checks name retired tokens by necessity) """ -import sys, re, ipaddress +import sys, re, ipaddress, argparse try: import yaml except ImportError: sys.stderr.write("ERROR: PyYAML not installed (pip install pyyaml --break-system-packages)\n"); sys.exit(2) -PROVIDER = ipaddress.ip_network("10.12.4.0/22") # public API VIPs + FIPs share this plane (Pattern A) -ADMIN = ipaddress.ip_network("10.12.8.0/22") -INTERNAL = ipaddress.ip_network("10.12.12.0/22") +# Per-DC VIP bands (D-101: only the network prefix moves per DC; the octet band is +# unchanged). --dc selects which set the VIP checks expect; default = the base +# bundle's dc0 values, so an un-parameterized run is byte-identical to before. +DC_BANDS = { + "vr1-dc0": ("10.12.4.0/22", "10.12.8.0/22", "10.12.12.0/22"), + "vr1-dc1": ("10.12.64.0/22", "10.12.68.0/22", "10.12.72.0/22"), +} OCTET_LO, OCTET_HI = 50, 60 EXPECT_PUBLIC_VIP = 11 EXPECT_CHASSIS_MACS = { @@ -43,13 +47,71 @@ "52:54:00:99:fc:c2", # openstack3 } MAC_RE = re.compile(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}") +ROLES = ("control", "compute", "storage") + +def _deep_merge(a, b): + if isinstance(a, dict) and isinstance(b, dict): + out = dict(a) + for k, v in b.items(): + out[k] = _deep_merge(a[k], v) if k in a else v + return out + return b + +def merge_overlay(base, over): + """Juju-like overlay merge: applications/machines deep-merge key-by-key (a None + overlay value deletes that key), relations append, other top-level keys override. + NOTE: this MIRRORS juju's documented merge so the checker validates the effective + deploy input; the machines-block merge in particular is CONFIRMED at + `juju deploy --dry-run` before deploy (no offline juju merger exists).""" + base, over = base or {}, over or {} + out = dict(base) + for section in ("applications", "machines"): + b = dict(base.get(section) or {}) + for k, v in (over.get(section) or {}).items(): + if v is None: + b.pop(k, None) + elif isinstance(b.get(k), dict) and isinstance(v, dict): + b[k] = _deep_merge(b[k], v) + else: + b[k] = v + if b: + out[section] = b + rels = list(base.get("relations") or []) + list(over.get("relations") or []) + if rels: + out["relations"] = rels + for k, v in over.items(): + if k not in ("applications", "machines", "relations"): + out[k] = v + return out + +def _role_of(spec): + """Extract control/compute/storage from a machine spec's `constraints: tags=...`.""" + m = re.search(r"tags=(\S+)", str((spec or {}).get("constraints", ""))) + tags = set(m.group(1).split(",")) if m else set() + for r in ROLES: + if r in tags: + return r + return None def main(): - path = sys.argv[1] if len(sys.argv) > 1 else "bundle.yaml" + ap = argparse.ArgumentParser(description="Pattern A / D-052-D-053 + placement bundle invariants") + ap.add_argument("path", nargs="?", default="bundle.yaml", help="base bundle (default bundle.yaml)") + ap.add_argument("--overlay", action="append", default=[], metavar="FILE", + help="overlay bundle(s), applied in order (juju-like merge) to validate the effective deploy input") + ap.add_argument("--dc", choices=sorted(DC_BANDS), default="vr1-dc0", + help="DC whose VIP bands to expect (default vr1-dc0 = the base bundle's own values)") + args = ap.parse_args() + path = args.path try: doc = yaml.safe_load(open(path, encoding="utf-8")) except Exception as e: sys.stderr.write("ERROR: cannot parse %s: %s\n" % (path, e)); return 2 + for ov in args.overlay: + try: + doc = merge_overlay(doc, yaml.safe_load(open(ov, encoding="utf-8"))) + except Exception as e: + sys.stderr.write("ERROR: cannot merge overlay %s: %s\n" % (ov, e)); return 2 + PROVIDER, ADMIN, INTERNAL = (ipaddress.ip_network(x) for x in DC_BANDS[args.dc]) apps = (doc or {}).get("applications", {}) or {} fails, oks = [], [] @@ -168,6 +230,47 @@ except Exception as e: fails.append("cannot read policyd zip: %s" % e) + # -- 8. Placement / anti-affinity (role separation). Activates ONLY when the + # machines block carries control/compute/storage role tags; a base or + # un-rendered bundle has none, so the checks self-skip (base stays green). + # Run on the MERGED bundle (base + overlays) to see the scaled apps' real + # num_units/to:. Catches the decorative-HA paths juju itself won't error on. + machines = (doc or {}).get("machines", {}) or {} + roles = {str(mid): _role_of(spec) for mid, spec in machines.items()} + if any(roles.values()): + pf0 = len(fails) + n_ctl = sum(1 for r in roles.values() if r == "control") + n_cmp = sum(1 for r in roles.values() if r == "compute") + n_stg = sum(1 for r in roles.values() if r == "storage") + for n, s in apps.items(): + s = s or {} + tos = [str(t) for t in (s.get("to") or [])] + if not tos: + continue + ids = [t.split(":")[-1] for t in tos] + for t, mid in zip(tos, ids): # a. no dangling placement + if mid not in roles: + fails.append("%s to: references undefined machine %r" % (n, t)) + want = ("storage" if n == "ceph-osd" else "compute" if n == "nova-compute" + else "control" if any(t.startswith("lxd:") for t in tos) else None) + if want: # b. role placement + off = [t for t, mid in zip(tos, ids) if roles.get(mid) != want] + if off: + fails.append("%s placed off-role (want %s node): %s" % (n, want, ", ".join(off))) + nu = s.get("num_units") # d. anti-affinity + if isinstance(nu, int) and nu >= 2 and len(set(ids)) < nu: + fails.append("%s num_units=%d but to: has %d distinct machine(s) -- anti-affinity defeat: %s" + % (n, nu, len(set(ids)), ids)) + for n, want, lbl in (("ceph-osd", n_stg, "storage"), ("nova-compute", n_cmp, "compute")): + nu = (apps.get(n) or {}).get("num_units") # c. bare-metal role counts + if isinstance(nu, int) and nu != want: + fails.append("%s num_units=%d != %d %s node(s)" % (n, nu, want, lbl)) + if len(fails) == pf0: + oks.append("placement: role-separated (%d control/%d compute/%d storage); " + "anti-affinity + role placement + counts OK" % (n_ctl, n_cmp, n_stg)) + else: + oks.append("placement: no role tags in machines block (base/un-rendered bundle) -- checks skipped") + for o in oks: print(" [ok] %s" % o) for f in fails: print(" [FAIL] %s" % f) print("\n%s: Pattern A / D-052-D-053 bundle invariants (%s)" % ("PASS" if not fails else "FAIL", path)) diff --git a/tests/provider-bundle-check/run-tests.sh b/tests/provider-bundle-check/run-tests.sh index 0894856..569248e 100644 --- a/tests/provider-bundle-check/run-tests.sh +++ b/tests/provider-bundle-check/run-tests.sh @@ -72,6 +72,67 @@ mutate t8.yaml 'b["applications"]["octavia"]["options"]["vip"]="10.12.4.233 10.12.8.233 10.12.12.233"' run 1 'outside 50-60' "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"] + elif n in ("mysql-innodb-cluster","ovn-central","ceph-mon"): s["to"] = ["lxd:0","lxd:1","lxd:2"] + else: s["to"] = ["lxd:0"] +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