diff --git a/scripts/dc-node-v6-carve.py b/scripts/dc-node-v6-carve.py new file mode 100644 index 0000000..0b217e9 --- /dev/null +++ b/scripts/dc-node-v6-carve.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Assign each node its IPv6 static, mirroring its EXISTING IPv4 octet, on every plane +where it already carries IPv4. Site-keyed. DRY BY DEFAULT. + +WHY THIS EXISTS. D-101's governing rationale (recorded 2026-07-27) sets the standing +principle IPv6 UNLESS IPv4 IS NECESSARY, driven by real IPv4 sizing constraints. The +plane subnets were carved and the VIPs written to the apex earlier that day -- but +MEASURED, all 18 Ready nodes carried ZERO IPv6 links. MAAS `mode=static` means an +EXPLICITLY CONFIGURED address (that is how the Stage-4 carve set the 90 v4 links), so a +node's v6 address does not arrive with the subnet; it has to be assigned. This is that +assignment. Scope: docs/audit/node-v6-carve-scope-20260727.md. + +EVERYTHING IS DERIVED FROM LIVE STATE -- there is no plane table here (hard rule 2): + * site membership <- the MAAS tag openstack- + * which interfaces <- those that ALREADY carry a v4 static. The provider NIC + (enp2s0) is deliberately RAW with no links under D-100, and + br-ex carries provider-public instead; deriving from "has a v4 + link" gets both right without naming either. + * which v6 subnet <- the one on the SAME MAAS vlan as that v4 link, so the node is + dual-stack on one L2 rather than on a parallel fabric. + * the host part <- the last octet of the node's own v4 address, mirrored + TEXTUALLY (RULED 2026-07-27): .121 -> ::121, never the hex + conversion. `printf '%x' 50` -> 32 already caught this project + once during the band work. + +SAFETY. Dry by default; --commit writes then READS BACK every link. Idempotent: an +interface already carrying the right v6 address is SKIPPED. It links subnets only -- it +never re-commissions and never touches a MAC, because the 2026-07-20 incident regenerated +all 9 node NIC MACs on an in-place apply and stranded MAAS's records. + +Exit: 0 ok | 1 error/mismatch | 2 could not evaluate. +""" +import argparse +import json +import subprocess +import sys + +PROFILE_DEFAULT = "admin" + + +def die(msg, rc=2): + print(f"REFUSE: {msg}", file=sys.stderr) + print(" (could not evaluate -- this is NOT a pass)", file=sys.stderr) + sys.exit(rc) + + +def maas(profile, *args): + p = subprocess.run(["maas", profile, *args], capture_output=True, text=True) + if p.returncode != 0: + return None, (p.stderr or p.stdout).strip()[:300] + return p.stdout, None + + +def maas_json(profile, *args): + out, err = maas(profile, *args) + if out is None: + return None, err + try: + return json.loads(out), None + except json.JSONDecodeError as e: + return None, f"unparseable JSON from 'maas {profile} {' '.join(args)}': {e}" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("action", choices=["check", "apply"]) + ap.add_argument("site", choices=["vr1-dc0", "vr1-dc1"]) + ap.add_argument("--commit", action="store_true") + ap.add_argument("--profile", default=PROFILE_DEFAULT) + a = ap.parse_args() + + subs, err = maas_json(a.profile, "subnets", "read") + if subs is None: + die(f"'maas {a.profile} subnets read' failed -- {err}. NOTE an ABSENT `maas` binary " + f"is a MISSING TOOL, not an unreachable MAAS; run from the D-128 Plane-2 host.") + # v6 subnet keyed by the vlan it shares with its v4 twin + v6_by_vlan = {} + for s in subs: + if ":" in s["cidr"]: + vid = (s.get("vlan") or {}).get("id") + if vid is not None: + v6_by_vlan[vid] = s + + machines, err = maas_json(a.profile, "machines", "read") + if machines is None: + die(f"'maas {a.profile} machines read' failed -- {err}") + + tag = f"openstack-{a.site}" + nodes = [m for m in machines if tag in (m.get("tag_names") or [])] + if not nodes: + die(f"no machines carry the tag '{tag}'. Refusing to fall back to a heuristic -- " + f"picking nodes by subnet or MAC prefix could silently carve the wrong DC.") + + planned, skipped, errors, applied = [], 0, [], 0 + + for m in sorted(nodes, key=lambda x: x["hostname"]): + for iface in m.get("interface_set", []): + links = iface.get("links", []) + v4 = [l for l in links + if l.get("ip_address") and ":" not in str(l["ip_address"])] + if not v4: + continue # raw provider NIC (D-100) or an unaddressed iface + for l in v4: + sub = l.get("subnet") or {} + vid = (sub.get("vlan") or {}).get("id") + v6sub = v6_by_vlan.get(vid) + if not v6sub: + errors.append(f"{m['hostname']}/{iface['name']}: v4 {l['ip_address']} on " + f"vlan {vid} has NO v6 subnet on that vlan -- carve it first") + continue + octet = str(l["ip_address"]).rsplit(".", 1)[1] + base = v6sub["cidr"].split("::/")[0] + want = f"{base}::{octet}" # TEXTUAL mirror, ruled 2026-07-27 + have = [x.get("ip_address") for x in links + if x.get("ip_address") and ":" in str(x["ip_address"])] + if want in have: + skipped += 1 + continue + if have: + errors.append(f"{m['hostname']}/{iface['name']}: already carries v6 " + f"{have} but the mirror wants {want} -- REFUSING to add a " + f"second address; resolve by hand") + continue + planned.append({ + "host": m["hostname"], "sysid": m["system_id"], + "iface": iface["name"], "ifid": iface["id"], + "subid": v6sub["id"], "addr": want, "v4": l["ip_address"], + "cidr": v6sub["cidr"], + }) + + mode = "--commit" if (a.action == "apply" and a.commit) else \ + ("DRY RUN" if a.action == "apply" else "check") + print(f"== dc-node-v6-carve {a.action} {a.site} ({mode}) ==") + print(f" nodes tagged {tag}: {len(nodes)}") + print(f" planned v6 links : {len(planned)}") + print(f" already correct : {skipped}") + print(f" errors : {len(errors)}") + for p in planned[:6]: + print(f" [plan] {p['host']:16} {p['iface']:7} {p['v4']:14} -> {p['addr']}") + if len(planned) > 6: + print(f" ... and {len(planned)-6} more") + for e in errors[:6]: + print(f" [ERR] {e}") + + if a.action == "check": + ok = not planned and not errors + print(f"\n{'PASS' if ok else 'FAIL'}: dc-node-v6-carve check {a.site} " + f"({skipped} link(s) correct, {len(planned)} missing, {len(errors)} error(s))") + return 0 if ok else 1 + + if not a.commit: + print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.") + return 1 if errors else 0 + + for p in planned: + out, err = maas(a.profile, "interface", "link-subnet", p["sysid"], str(p["ifid"]), + "mode=STATIC", f"subnet={p['subid']}", f"ip_address={p['addr']}") + if out is None: + errors.append(f"{p['host']}/{p['iface']} {p['addr']}: link-subnet FAILED -- {err}") + continue + applied += 1 + + # READ BACK. A create that reports success but did not land is the class this repo has + # been bitten by (opnsense-plugins.sh apply always silently dry-ran). + machines2, err = maas_json(a.profile, "machines", "read") + if machines2 is None: + die(f"applied {applied} link(s) but could not re-read machines to verify -- {err}") + live = set() + for m in machines2: + for iface in m.get("interface_set", []): + for l in iface.get("links", []): + if l.get("ip_address"): + live.add((m["hostname"], iface["name"], str(l["ip_address"]))) + missing = [p for p in planned if (p["host"], p["iface"], p["addr"]) not in live] + for p in missing[:6]: + errors.append(f"{p['host']}/{p['iface']} {p['addr']}: MISSING AFTER WRITE") + + print(f"\n READ-BACK: {len(planned)-len(missing)}/{len(planned)} link(s) verified live") + print(f"\nRESULT: applied={applied} skipped={skipped} errors={len(errors)}") + for e in errors[:8]: + print(f" [ERR] {e}", file=sys.stderr) + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())