Newer
Older
openstack-caracal-dc-dc / scripts / dc-node-v6-carve.py
#!/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())