#!/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-<site>
* 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())