#!/usr/bin/env python3
"""
D-139 execution STEP 6, one DC: re-home the v6 VIP ip-addresses from the retiring ULA
/64s onto their GUA counterparts, then DEPRECATE the ULA addresses and the ULA prefixes.
. ~/vr1-office1-creds/vr1-netbox-sandbox.env # the WORKING VR1 apex (DOCFIX-195)
python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 # DRY RUN (the default)
python3 netbox/d139-step6-vip-rehome.py --dc vr1-dc0 --commit # writes, then reads back
TWO OPERATOR RULINGS, both 2026-08-02 (GA-R5), quoted in docs/design-decisions.md:
ordering -- "Full step 6 first, then deploy"
semantics -- "Deprecate both, delete nothing"
NOTHING IS EVER DELETED. "Retire" == status=deprecated. This preserves
d139-gua-carve.py's never-delete posture repo-wide -- that refusal is why the step-1 push
was safe to run.
REVERSIBILITY, STATED PRECISELY because an earlier draft of this docstring overclaimed it:
the 26+9 DEPRECATIONS are reversible by flipping a status back. The 26 CREATES are NOT --
undoing them would need a delete, and this repo has no delete path by design. The creates
are additive and are exactly the records the deploy configures, so reversal is not wanted;
but "every action is reversible" was wrong and is corrected here.
ORDER IS LOAD-BEARING AND IS NOT AN IMPLEMENTATION DETAIL: CREATE every GUA record,
VERIFY by read-back, and only THEN deprecate. Reversed, there is an interval in which the
apex marks a live VIP's only record unusable -- the same orphaning hazard DEFECT 3 of the
2026-08-01 CORRECTION NOTE identified for prefixes.
WHY IT DERIVES THE ULA->GUA MAP FROM THE ROLE SLUG rather than from hextet arithmetic:
the ULA and GUA hextets differ per DC (dc0's metal-admin is fd50:...:220 -> 2602:f3e2:f02:20)
and a transposition would be silent. Role slug is the stable key both sides share, and it
comes from d139-gua-carve.py's own CARVE table, which is IMPORTED rather than retyped.
If a role maps to more than one GUA /64 (provider-public owns :10 and :11) the tool
REFUSES rather than picking -- an ambiguous mapping is an unrecognised state.
MEASURED, not assumed (2026-08-02, live apex): the existing ULA VIP ip-addresses carry
status="reserved" -- NOT "active" -- and role/dns_name/tenant/vrf/tags/custom_fields are all
empty. dc-plane-apex-import.py:186,200 also creates addresses as "reserved". So the GUA
records are created RESERVED, mirroring the originals, and only address + status +
description carry meaning.
Exit: 0 ok | 1 write/read-back error | 2 REFUSE (could not evaluate).
"""
import argparse
import importlib.util
import ipaddress
import os
import sys
import urllib.parse
_HERE = os.path.dirname(os.path.abspath(__file__))
def _load_carve():
"""Import d139-gua-carve.py by path -- its hyphenated name is not importable normally.
Reusing its NB client and CARVE table is deliberate: a second hand-copied table is
exactly the transposition this repo keeps finding."""
p = os.path.join(_HERE, "d139-gua-carve.py")
spec = importlib.util.spec_from_file_location("d139_gua_carve", p)
if spec is None or spec.loader is None:
print("REFUSE: cannot load %s" % p, file=sys.stderr)
sys.exit(2)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
C = _load_carve()
die = C.die
# The status the GUA VIP records are CREATED with. MEASURED from the ULA originals
# (2026-08-02): they are "reserved", not "active". See module docstring.
VIP_STATUS = "reserved"
DEPRECATED = "deprecated"
class NBW(C.NB):
"""The carve tool's client plus PATCH -- it never needed one, having no update path."""
def patch(self, path, body):
return self._req("PATCH", path, body)
def gua_net_for_role(dc, role, live_gua=None):
"""The GUA /64 carrying `role` for this DC, from the imported CARVE table.
REFUSES on an ambiguous role rather than choosing, and -- when `live_gua` is
supplied -- REFUSES unless the computed /64 ACTUALLY EXISTS in the apex.
DEF-1 (adversarial review 2026-08-02, CRITICAL): computing the target
arithmetically and never asking the apex whether it exists is a silent
orphan-create. MEASURED: vr1-dc1's GUA carve is INCOMPLETE -- only four rows exist
under 2602:f3e2:f03::/48, all provider-public, with no :20::/64 and no :21::/64 --
so `--dc vr1-dc1` planned 26 creates into prefixes that DO NOT EXIST and then
deprecated dc1's only authoritative rows, at rc=0 with no warning. dc0 hid the bug
because all sixteen of its targets happen to exist. The imported carve tool checks
its own preconditions ("refusing to half-carve a DC"); this one checked none.
"""
base48 = ipaddress.ip_network(C.DC_GUA[dc])
hextets = [nn for nn, r, _lbl, _parent in C.CARVE if r == role]
if not hextets:
die("no D-139 GUA plane carries apex role %r -- cannot re-home its addresses" % role)
if len(hextets) > 1:
die("apex role %r maps to %d GUA /64s (%s) -- ambiguous, refusing to choose"
% (role, len(hextets), ", ".join("%#x" % h for h in hextets)))
net = C.sub_at(base48, hextets[0], 64)
if live_gua is not None and str(net) not in live_gua:
die("target GUA prefix %s (role %r) DOES NOT EXIST in the apex for %s. Step 1/2 "
"have not carved this DC, so creating addresses inside it would orphan them. "
"Run netbox/d139-gua-carve.py --dc %s --commit first." % (net, role, dc, dc))
return net
def plan(dc, prefixes, addrs):
"""Returns (creates, dep_addrs, dep_prefixes, already).
creates -- GUA ip-address payloads to POST
dep_addrs -- ULA ip-address rows to mark deprecated
dep_prefixes -- ULA prefix rows to mark deprecated
already -- GUA addresses that already exist (idempotent re-run)
"""
ula48 = ipaddress.ip_network(C.RETIRED_ULA_48)
gua48 = ipaddress.ip_network(C.DC_GUA[dc])
# DEF-1: the set of GUA prefixes that ACTUALLY EXIST for this DC. gua_net_for_role()
# refuses against this, so a target is never computed into thin air.
live_gua = {str(ipaddress.ip_network(p["prefix"])) for p in prefixes
if C.scope_slug(p) == dc}
# The ULA prefixes in scope: this DC's, inside the retired /48. Same selection the
# carve tool's RETIRE-REPORT makes, so the two cannot disagree about scope.
ula_pfx = []
for p in prefixes:
if C.scope_slug(p) != dc:
continue
n = ipaddress.ip_network(p["prefix"])
if n.version == 6 and n.subnet_of(ula48):
ula_pfx.append((n, p))
if not ula_pfx:
die("no ULA prefixes in scope for %s -- nothing to do, and that is not a state this "
"tool should report as success (step 1/2 may target a different apex)" % dc)
existing = {str(ipaddress.ip_interface(a["address"]).ip) for a in addrs}
creates, dep_addrs, already = [], [], []
for n, p in ula_pfx:
if n.prefixlen != 64:
continue # /60 parents hold no addresses; deprecated below
role = (p.get("role") or {}).get("slug")
if not role:
die("ULA prefix %s has no apex role -- cannot map it to a GUA plane" % n)
gua = gua_net_for_role(dc, role, live_gua)
for a in addrs:
ai = ipaddress.ip_interface(a["address"])
if ai.version != 6 or ai.ip not in n:
continue
offset = int(ai.ip) - int(n.network_address)
tgt = ipaddress.ip_address(int(gua.network_address) + offset)
dep_addrs.append(a)
if str(tgt) in existing:
already.append((str(tgt), a.get("description", "")))
else:
creates.append({
"address": "%s/%d" % (tgt, gua.prefixlen),
"status": VIP_STATUS,
# Description kept VERBATIM: it names the service and the plane, both
# still true on GUA, and the 2026-08-01 conformance audit identified
# these objects BY description. Inventing new text would break that.
"description": a.get("description", ""),
})
# DEF-3 (adversarial review 2026-08-02, HIGH): SILENT UNDER-COUNT. Deleting or
# re-scoping ONE ULA /64 prefix row yielded CREATE=13 / DEPA=13 / DEPP=8 at exit 0 --
# thirteen live VIPs neither created nor deprecated, reported as success. Same class
# as DEFECT 2 of the 2026-08-01 CORRECTION NOTE (a carve that does less and exits
# clean). The prefix ROW is not the authority on what exists; the ADDRESSES are.
# So: every v6 address of this DC inside the retired /48 must be covered by an
# in-scope /64. Any that is not is an unrecognised state, and unrecognised REFUSES
# rather than being quietly skipped.
# Addresses belonging to ANOTHER DC are legitimately out of scope -- the retired /48
# is shared (dc0 uses :22x, dc1 uses :32x). Scoping this check to the whole /48 was a
# FALSE POSITIVE caught by running it: it flagged dc1's 26 VIPs while planning dc0.
# So an address is accounted for if EITHER this DC processed it, OR some other DC's
# ULA prefix row covers it. What remains -- inside the retired /48, in none of this
# DC's processed /64s, and claimed by no other DC -- is the real under-count signal.
# Note a /60 PARENT does not count as coverage: the reviewer's scenario was exactly a
# missing /64 row whose /60 parent still existed, and treating the parent as coverage
# would re-open the hole this guard closes.
other_dc_ula = []
for p in prefixes:
if C.scope_slug(p) == dc:
continue
try:
n = ipaddress.ip_network(p["prefix"])
except ValueError:
continue
if n.version == 6 and n.subnet_of(ula48):
other_dc_ula.append(n)
covered = {id(a) for a in dep_addrs}
orphans = []
for a in addrs:
try:
ai = ipaddress.ip_interface(a["address"])
except ValueError:
continue
if ai.version != 6 or ai.ip not in ula48:
continue
if id(a) in covered:
continue
if any(ai.ip in n for n in other_dc_ula):
continue # another DC's row owns it -- not our business
orphans.append(a)
if orphans:
die("%d ULA ip-address(es) inside %s are claimed by NO prefix row -- not by an "
"in-scope /64 for %s, and not by any other DC. Their /64 row is missing, "
"mis-scoped or roleless, and processing the rest would silently do less than "
"step 6 requires. First: %s"
% (len(orphans), ula48,
dc, ", ".join(a["address"] for a in orphans[:4])))
dep_prefixes = [p for _n, p in ula_pfx
if (p.get("status") or {}).get("value") != DEPRECATED]
dep_addrs = [a for a in dep_addrs
if (a.get("status") or {}).get("value") != DEPRECATED]
return creates, dep_addrs, dep_prefixes, already
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dc", required=True, choices=sorted(C.DC_GUA))
ap.add_argument("--commit", action="store_true",
help="apply; without it this is a DRY RUN and writes nothing")
a = ap.parse_args()
url, tok = os.environ.get("NETBOX_URL"), os.environ.get("NETBOX_TOKEN")
if not url or not tok:
die("set NETBOX_URL and NETBOX_TOKEN from the env (never argv -- it would land in "
"shell history): . ~/vr1-office1-creds/vr1-netbox-sandbox.env")
# DEF-2 (adversarial review 2026-08-02, CRITICAL): the apex-IDENTITY guard. It exists
# in d139-gua-carve.py's main() (:159-163), and importing that module runs its
# constants and classes but NEVER its main() -- so subclassing C.NB inherited the
# TRANSPORT and left the SAFETY POSTURE behind. Identity is checked BEFORE any network
# call, because reachability is not identity: pointing this at the v1 reference would
# connect fine and write to the wrong NetBox.
host = urllib.parse.urlsplit(url).hostname or ""
if host not in C.SANDBOX_HOSTS:
die("'%s' is not the VR1 working apex. office1-netbox (10.10.1.10) takes ALL VR1 "
"reads and writes; netbox.baldurkeep.com is the v1 REFERENCE and stays "
"untouched (DOCFIX-195)." % host)
nb = NBW(url, tok)
try:
prefixes = nb.get_all("/ipam/prefixes/")
addrs = nb.get_all("/ipam/ip-addresses/")
except RuntimeError as e:
die("apex unreachable or rejecting: %s" % e)
creates, dep_addrs, dep_prefixes, already = plan(a.dc, prefixes, addrs)
print("D-139 STEP 6 -- %s -- apex %s" % (a.dc, url))
print(" CREATE %d | ALREADY %d | DEPRECATE-ADDR %d | DEPRECATE-PFX %d"
% (len(creates), len(already), len(dep_addrs), len(dep_prefixes)))
print("\n CREATE (GUA, status=%s):" % VIP_STATUS)
for c in creates:
print(" %-34s %s" % (c["address"], c["description"]))
if already:
print("\n ALREADY PRESENT (idempotent -- left untouched):")
for addr, desc in already:
print(" %-34s %s" % (addr, desc))
print("\n DEPRECATE ip-addresses (ULA -- NOT deleted):")
for x in dep_addrs:
print(" %-34s %s" % (x["address"], x.get("description", "")))
print("\n DEPRECATE prefixes (ULA -- NOT deleted):")
for x in dep_prefixes:
print(" %-34s role=%s" % (x["prefix"], (x.get("role") or {}).get("slug")))
if not a.commit:
print("\nDRY RUN -- nothing was written. Re-run with --commit to apply.")
return 0
# ---- PHASE 1: CREATE. Nothing is deprecated until every create is read back. ----
try:
for c in creates:
nb.post("/ipam/ip-addresses/", c)
except RuntimeError as e:
print("FAIL: create: %s" % e, file=sys.stderr)
print("NOTHING WAS DEPRECATED -- the ULA records are untouched and still "
"authoritative. Re-run; creates are idempotent.", file=sys.stderr)
return 1
# ---- PHASE 2: VERIFY BY READ-BACK, against the artifact and not the response. ----
try:
back = {str(ipaddress.ip_interface(x["address"]).ip)
for x in nb.get_all("/ipam/ip-addresses/")}
except RuntimeError as e:
die("read-back failed: %s" % e)
missing = [c["address"] for c in creates
if str(ipaddress.ip_interface(c["address"]).ip) not in back]
if missing:
print("FAIL: %d GUA address(es) not present on read-back: %s"
% (len(missing), ", ".join(missing)), file=sys.stderr)
print("NOTHING WAS DEPRECATED -- the ULA records remain authoritative.",
file=sys.stderr)
return 1
print("\n read-back OK: %d GUA VIP address(es) present" % len(creates))
# ---- PHASE 3: DEPRECATE. Only now, and never a delete. ----
try:
for x in dep_addrs:
nb.patch("/ipam/ip-addresses/%d/" % x["id"], {"status": DEPRECATED})
for x in dep_prefixes:
nb.patch("/ipam/prefixes/%d/" % x["id"], {"status": DEPRECATED})
except RuntimeError as e:
print("FAIL: deprecate: %s" % e, file=sys.stderr)
print("The GUA records ARE created and verified; some ULA rows may still read "
"active. Re-run -- deprecation is idempotent.", file=sys.stderr)
return 1
# ---- PHASE 4: verify the deprecation landed, again on the artifact. ----
try:
pfx2 = {p["id"]: p for p in nb.get_all("/ipam/prefixes/")}
adr2 = {x["id"]: x for x in nb.get_all("/ipam/ip-addresses/")}
except RuntimeError as e:
die("post-deprecate read-back failed: %s" % e)
bad = [x["address"] for x in dep_addrs
if (adr2.get(x["id"], {}).get("status") or {}).get("value") != DEPRECATED]
bad += [p["prefix"] for p in dep_prefixes
if (pfx2.get(p["id"], {}).get("status") or {}).get("value") != DEPRECATED]
if bad:
print("FAIL: %d row(s) did not take status=deprecated: %s"
% (len(bad), ", ".join(bad)), file=sys.stderr)
return 1
print(" read-back OK: %d address(es) + %d prefix(es) now deprecated"
% (len(dep_addrs), len(dep_prefixes)))
print("\nSTEP 6 APPLIED. Nothing was deleted; every change is reversible by "
"flipping a status back.")
return 0
if __name__ == "__main__":
sys.exit(main())