diff --git a/scripts/dc-node-v6-carve.py b/scripts/dc-node-v6-carve.py index b5d50f0..c91eafd 100644 --- a/scripts/dc-node-v6-carve.py +++ b/scripts/dc-node-v6-carve.py @@ -49,6 +49,13 @@ PROFILE_DEFAULT = os.environ.get("MAAS_PROFILE", "admin") +def v6_fam(x): + """'ula' for fc00::/7, else 'gua'. Accepts a CIDR or a bare address.""" + import ipaddress as _ip + n = _ip.ip_network(x, strict=False) if "/" in str(x) else _ip.ip_network(str(x) + "/128") + return "ula" if n.subnet_of(_ip.ip_network("fc00::/7")) else "gua" + + def die(msg, rc=2): print(f"REFUSE: {msg}", file=sys.stderr) print(" (could not evaluate -- this is NOT a pass)", file=sys.stderr) @@ -81,10 +88,13 @@ def main(): ap = argparse.ArgumentParser() - ap.add_argument("action", choices=["check", "apply"]) + ap.add_argument("action", choices=["check", "apply", "replace"]) ap.add_argument("site", choices=["vr1-dc0", "vr1-dc1"]) ap.add_argument("--commit", action="store_true") ap.add_argument("--profile", default=PROFILE_DEFAULT) + ap.add_argument("--v6-family", dest="v6_family", choices=("gua", "ula"), + help="which v6 family to carve when a vlan holds both (the D-139 " + "transition state). Omitted = refuse on ambiguity.") a = ap.parse_args() # Say WHICH region this run targets, and where that came from. A capture that @@ -97,13 +107,25 @@ 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 subnet keyed by (vlan, FAMILY). Was `v6_by_vlan[vid] = s` -- last-writer-wins, + # which decided the FAMILY of all 54 node statics by MAAS's JSON array order once D-139 + # step 2 put a GUA /64 alongside every ULA /64 on the same vlan. Array order is not a + # contract; this is the same defect the 2026-07-29 chain audit found in the apex readers, + # and this was the fourth copy of it. Measured 2026-08-01: it happened to pick GUA, which + # is luck, not correctness. 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 + for sn in subs: + if ":" not in sn["cidr"]: + continue + vid = (sn.get("vlan") or {}).get("id") + if vid is None: + continue + fam = v6_fam(sn["cidr"]) + prev = v6_by_vlan.setdefault(vid, {}).get(fam) + if prev is not None and prev["cidr"] != sn["cidr"]: + die(f"vlan {vid} carries TWO {fam} v6 subnets ({prev['cidr']} and {sn['cidr']}) " + f"-- refusing to pick one by array order") + v6_by_vlan[vid][fam] = sn machines, err = maas_json(a.profile, "machines", "read") if machines is None: @@ -127,23 +149,78 @@ 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: + fams = v6_by_vlan.get(vid) or {} + if a.v6_family: + v6sub = fams.get(a.v6_family) + if not v6sub: + errors.append(f"{m['hostname']}/{iface['name']}: vlan {vid} has no " + f"{a.v6_family} v6 subnet -- carve it first") + continue + elif len(fams) > 1: + die(f"vlan {vid} carries BOTH v6 families ({', '.join(sorted(fams))}) -- the " + f"expected D-139 transition state. Re-run with --v6-family gua|ula; " + f"refusing to pick one by array order.") + elif not fams: 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 + else: + v6sub = next(iter(fams.values())) 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: + if len(have) > 1: + # The wanted address IS present, but so is another global. That is + # NOT "already correct": G19 asserts exactly one global per NIC and + # has no exemption, so a leftover from a half-done family migration + # must surface here rather than be counted as a pass. + extra = [x for x in have if x != want] + errors.append(f"{m['hostname']}/{iface['name']}: carries {want} AND " + f"{extra} -- more than one global v6 on a NIC breaks " + f"G19's sole-global predicate; resolve by hand") + continue 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") + if a.action != "replace": + errors.append(f"{m['hostname']}/{iface['name']}: already carries v6 " + f"{have} but the mirror wants {want} -- REFUSING to add a " + f"second address; use the `replace` action") + continue + # REPLACE (D-139 step 3). The node holds its pre-D-139 v6 static and the + # ruled carve moved to another family, so the old link is UNLINKED and the + # new one linked. Adding alongside is NOT an option: two globals on one NIC + # breaks G19's sole-global-per-NIC predicate, which has no exemption. + old_links = [x for x in links + if x.get("ip_address") and ":" in str(x["ip_address"])] + if len(old_links) != 1: + errors.append(f"{m['hostname']}/{iface['name']}: expected exactly ONE " + f"existing v6 link to replace, found {len(old_links)} " + f"{have} -- refusing to guess which") + continue + if old_links[0].get("id") is None: + # Without a link id there is no unlink, and falling through would + # ADD a second global to the NIC -- the one outcome this action + # exists to avoid. Refuse rather than degrade into `apply`. + errors.append(f"{m['hostname']}/{iface['name']}: existing v6 link " + f"{have[0]} carries no link id -- cannot unlink it, and " + f"adding alongside is not permitted; refusing") + continue + if v6_fam(old_links[0]["ip_address"]) == v6_fam(want): + errors.append(f"{m['hostname']}/{iface['name']}: existing {have[0]} and " + f"wanted {want} are the SAME family -- this is an address " + f"change, not a family migration; refusing") + 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"], + "unlink_id": old_links[0].get("id"), "old": old_links[0]["ip_address"], + }) continue planned.append({ "host": m["hostname"], "sysid": m["system_id"], @@ -152,8 +229,8 @@ "cidr": v6sub["cidr"], }) - mode = "--commit" if (a.action == "apply" and a.commit) else \ - ("DRY RUN" if a.action == "apply" else "check") + mode = "--commit" if (a.action in ("apply", "replace") and a.commit) else \ + ("DRY RUN" if a.action in ("apply", "replace") 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)}") @@ -177,10 +254,24 @@ return 1 if errors else 0 for p in planned: + # REPLACE: unlink the old-family link FIRST. Order matters and is not cosmetic -- + # MAAS will not hold two STATIC links from different subnets on one interface, and + # leaving both would break G19's sole-global-per-NIC predicate. If the unlink fails + # we do NOT attempt the link: a half-done interface is worse than an untouched one. + if p.get("unlink_id") is not None: + uout, uerr = maas(a.profile, "interface", "unlink-subnet", p["sysid"], + str(p["ifid"]), f"id={p['unlink_id']}") + if uout is None: + errors.append(f"{p['host']}/{p['iface']}: unlink of {p['old']} FAILED -- {uerr}" + f" -- NOT attempting the link; interface left as-is") + continue 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}") + errors.append(f"{p['host']}/{p['iface']} {p['addr']}: link-subnet FAILED" + + (f" AFTER unlinking {p['old']} -- THE INTERFACE NOW HAS NO v6" + if p.get("unlink_id") is not None else "") + + f" -- {err}") continue applied += 1 diff --git a/tests/dc-node-v6-carve/run-tests.sh b/tests/dc-node-v6-carve/run-tests.sh index 39b4ca3..a2e16fb 100755 --- a/tests/dc-node-v6-carve/run-tests.sh +++ b/tests/dc-node-v6-carve/run-tests.sh @@ -130,6 +130,105 @@ case "$OUT" in *Traceback*) echo " FAIL T14 absent maas CLI does not traceback"; FAIL=$((FAIL+1)) ;; *) echo " PASS T14 absent maas CLI does not traceback"; PASS=$((PASS+1)) ;; esac +# ---- v6 FAMILY + replace (added 2026-08-01, D-139 step 3) -------------------- +# D-139 step 2 puts a GUA /64 alongside every ULA /64 on the SAME vlan, so from that +# moment `v6_by_vlan[vid] = s` decided the FAMILY of all 54 node statics by MAAS's JSON +# array order. Measured live: it picked GUA -- luck, not a contract, and the FOURTH copy +# of the defect the 2026-07-29 chain audit found in the apex readers. +mkfam() { # $1 dir, $2 subnets: both|dupsame|guaonly, $3 node v6: none|ula|gua|two + local d="$1"; mkdir -p "$d/fakebin" + { + echo '#!/usr/bin/env bash' + echo "if [ \"\$2\" = \"subnets\" ]; then python3 $d/subnets.py; exit 0; fi" + echo "if [ \"\$2\" = \"machines\" ]; then python3 $d/machines.py; exit 0; fi" + echo "if [ \"\$2\" = \"interface\" ]; then echo '{}'; exit 0; fi" + echo 'exit 1' + } > "$d/fakebin/maas" + chmod +x "$d/fakebin/maas" + SUBMODE="$2" python3 -c ' +import json, os, sys +mode = os.environ["SUBMODE"] +s = [{"cidr": "10.12.8.0/22", "id": 6, "vlan": {"id": 5005}}] +if mode in ("both", "guaonly"): + s += [{"cidr": "2602:f3e2:f02:20::/64", "id": 31, "vlan": {"id": 5005}}] +if mode == "both": + s += [{"cidr": "fd50:840e:74e2:220::/64", "id": 21, "vlan": {"id": 5005}}] +if mode == "dupsame": + s += [{"cidr": "2602:f3e2:f02:20::/64", "id": 31, "vlan": {"id": 5005}}, + {"cidr": "2602:f3e2:f02:2f::/64", "id": 32, "vlan": {"id": 5005}}] +open(sys.argv[1], "w").write("import json\nprint(%r)\n" % json.dumps(s)) +' "$d/subnets.py" + NODEV6="$3" python3 -c ' +import json, os, sys +w = os.environ["NODEV6"] +links = [{"mode": "static", "ip_address": "10.12.8.121", "id": 300, + "subnet": {"cidr": "10.12.8.0/22", "vlan": {"id": 5005}}}] +ula = {"mode": "static", "ip_address": "fd50:840e:74e2:220::121", "id": 410, + "subnet": {"cidr": "fd50:840e:74e2:220::/64", "vlan": {"id": 5005}}} +gua = {"mode": "static", "ip_address": "2602:f3e2:f02:20::121", "id": 411, + "subnet": {"cidr": "2602:f3e2:f02:20::/64", "vlan": {"id": 5005}}} +if w == "ula": links.append(ula) +if w == "gua": links.append(gua) +if w == "two": links += [ula, gua] +m = [{"hostname": "vr1-dc0-compute-01", "system_id": "abc123", + "tag_names": ["openstack-vr1-dc0"], + "interface_set": [{"name": "enp1s0", "id": 99, "links": links}]}] +open(sys.argv[1], "w").write("import json\nprint(%r)\n" % json.dumps(m)) +' "$d/machines.py" +} +runfam() { local d="$1"; shift + OUT="$(PATH="$d/fakebin:$PATH" python3 "$REPO/scripts/dc-node-v6-carve.py" "$@" 2>&1)"; RC=$?; } +fok(){ PASS=$((PASS+1)); echo " PASS $1"; } +fbad(){ FAIL=$((FAIL+1)); echo " FAIL $1"; } + +D="$TMP/f1"; mkfam "$D" both none; runfam "$D" check vr1-dc0 --profile p +{ [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -q -- "--v6-family"; } \ + && fok "F1 both families + no flag REFUSES and names --v6-family" \ + || fbad "F1 did not refuse/name the flag (rc=$RC)" + +D="$TMP/f2"; mkfam "$D" both none; runfam "$D" check vr1-dc0 --profile p --v6-family gua +printf '%s' "$OUT" | grep -q '2602:f3e2:f02:20::121' \ + && fok "F2 --v6-family gua selects the GUA /64" || fbad "F2 gua not selected" + +# F3 proves F2 is a CHOICE, not the tool happening to prefer GUA. +D="$TMP/f3"; mkfam "$D" both none; runfam "$D" check vr1-dc0 --profile p --v6-family ula +printf '%s' "$OUT" | grep -q 'fd50:840e:74e2:220::121' \ + && fok "F3 --v6-family ula selects the ULA /64" || fbad "F3 ula not selected" + +D="$TMP/f4"; mkfam "$D" dupsame none; runfam "$D" check vr1-dc0 --profile p --v6-family gua +{ [ "$RC" -ne 0 ] && printf '%s' "$OUT" | grep -qi 'TWO gua'; } \ + && fok "F4 two SAME-family v6 subnets on one vlan REFUSE" || fbad "F4 same-family dup not refused" + +D="$TMP/f5"; mkfam "$D" both ula; runfam "$D" apply vr1-dc0 --profile p --v6-family gua +printf '%s' "$OUT" | grep -q 'use the .replace. action' \ + && fok "F5 apply still refuses a second address, points at replace" || fbad "F5 apply did not refuse" + +D="$TMP/f6"; mkfam "$D" both ula; runfam "$D" replace vr1-dc0 --profile p --v6-family gua +printf '%s' "$OUT" | grep -q 'planned v6 links : 1' \ + && fok "F6 replace plans the ULA->GUA swap" || fbad "F6 replace did not plan the swap" + +D="$TMP/f7"; mkfam "$D" guaonly gua; runfam "$D" replace vr1-dc0 --profile p --v6-family gua +printf '%s' "$OUT" | grep -qi 'correct\|SAME family' \ + && fok "F7 replace declines a same-family change" || fbad "F7 same-family not declined" + +# F8 a NIC carrying the WANTED address AND another global is NOT "already correct". +# ASSERTION REPLACED, not deleted: it first read 'exactly ONE|guess which', which is the +# >1-existing-v6 refusal on the replace path. Writing this case showed the tool never +# reached that path -- `want in have` short-circuited to skipped++ and counted a NIC with +# two globals as CORRECT, silently masking a half-done family migration that G19 forbids. +# The tool was fixed to error there; this is the new invariant. +D="$TMP/f8"; mkfam "$D" both two; runfam "$D" replace vr1-dc0 --profile p --v6-family gua +printf '%s' "$OUT" | grep -qi "more than one global v6 on a NIC" \ + && fok "F8 a NIC with the wanted addr PLUS another global is an ERROR, not 'correct'" \ + || fbad "F8 multi-global NIC not flagged" + +# F9 the >1-existing-v6 refusal on the replace path itself, reached when the wanted +# address is ABSENT and two other globals are present -- the case F8 was written for. +D="$TMP/f9"; mkfam "$D" both two; runfam "$D" replace vr1-dc0 --profile p --v6-family ula +printf '%s' "$OUT" | grep -qi 'exactly ONE\|guess which\|more than one global' \ + && fok "F9 replace will not guess which of two v6 links to drop" \ + || fbad "F9 did not refuse two existing v6 links" + echo echo "RESULT: PASS=$PASS FAIL=$FAIL" [ "$FAIL" -eq 0 ] || exit 1