#!/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 os
import subprocess
import sys
# PROFILE RESOLUTION -- reads MAAS_PROFILE, 2026-07-30.
# This script was ENV-BLIND: the default was the literal "admin" and the only
# way to change it was --profile, with no environment read anywhere. Every bash
# tool in this repo honours MAAS_PROFILE, so `export MAAS_PROFILE=vr1-dc0-region`
# -- the idiom a session naturally uses -- left THIS tool silently targeting the
# OFFICE1 region. During the D-132 q1 per-DC region migration that is the
# unrecoverable direction: against the wrong region a carve is an idempotent
# no-op that prints PASS while the region you meant stays untouched.
# An explicit --profile still wins over the environment.
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)
sys.exit(rc)
def maas(profile, *args):
# An ABSENT maas CLI used to raise FileNotFoundError and exit with a
# traceback (exit 1), which is indistinguishable from a real failure and is
# emphatically not this script's "REFUSE = could not evaluate" contract.
# "Could not look" is never "nothing there".
try:
p = subprocess.run(["maas", profile, *args], capture_output=True, text=True)
except FileNotFoundError:
die("no 'maas' CLI on this host -- cannot evaluate the v6 carve")
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", "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
# does not name the profile cannot be audited for wrong-region work.
src = "--profile" if any(x == "--profile" for x in sys.argv) else (
"MAAS_PROFILE env" if "MAAS_PROFILE" in os.environ else "built-in default")
print(f"dc-node-v6-carve {a.action} {a.site}: maas profile '{a.profile}' (from {src})")
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 (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 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:
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")
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:
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"],
"iface": iface["name"], "ifid": iface["id"],
"subid": v6sub["id"], "addr": want, "v4": l["ip_address"],
"cidr": v6sub["cidr"],
})
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)}")
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:
# 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"
+ (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
# 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())