diff --git a/scripts/dc-plane-ipam.sh b/scripts/dc-plane-ipam.sh index ab276c8..d5da514 100755 --- a/scripts/dc-plane-ipam.sh +++ b/scripts/dc-plane-ipam.sh @@ -50,11 +50,16 @@ usage() { cat >&2 < [--commit] +usage: $(basename "$0") [--commit] check READ-ONLY. Compare live MAAS against the ruled expected state. carve-v6 Create the six v6 plane subnets on their v4 twin's fabric (R2). reserve Create the D-134 reserved bands, + the FIP pool (R4). + retire-v6-ula + D-139 step 6, MAAS half: DELETE this site's retired ULA v6 plane + subnets. REFUSES on any subnet that still holds an allocation or a + machine-interface link, NAMING what holds it. Deletes one at a + time, never in a loop, and reads back that each is gone. DRY BY DEFAULT. Without --commit nothing is written: the planned calls are printed and nothing else. With --commit each write is READ BACK and verified, @@ -67,7 +72,7 @@ exit 2 } -case "$ACTION" in check|carve-v6|reserve) ;; *) usage ;; esac +case "$ACTION" in check|carve-v6|reserve|retire-v6-ula) ;; *) usage ;; esac case "$SITE" in vr1-dc0|vr1-dc1) ;; dc0|dc1|dc2) @@ -398,3 +403,107 @@ [ "$ERRORS" -eq 0 ] || exit 1 exit 0 fi + +# ============================================================================ +# retire-v6-ula -- D-139 execution step 6, the MAAS half +# ============================================================================ +# WHY THIS EXISTS. Step 6 says retire the ULA rows "in the apex AND in MAAS". +# The apex half has a tool (netbox/d139-step6-vip-rehome.py). The MAAS half was +# executed BY HAND for dc0 on 2026-08-02 -- four `maas subnet delete` calls -- +# which is precisely the "no repo tool, mutations live only in a transcript" +# gap that D-132 q1 already forced this repo to close once for the region carve. +# Without this, the dc1 rebuild cannot reproduce the dc0 end state. +# +# THE GUARD IS THE POINT, and it is not theoretical -- it stopped a real +# deletion on 2026-08-02. `fd50:840e:74e2:220::/64` looked like the other four +# but held two STICKY allocations: `::5` user `juju-vr1-dc0` (the JUJU +# CONTROLLER, i.e. the deploy client) and `::6` user `MAAS` (the REGION VM the +# delete would be issued TO), neither with a GUA counterpart. Deleting it would +# have destroyed the only recorded v6 of both. So: an allocation or an interface +# link is a REFUSAL, and the refusal NAMES the holder rather than reporting a +# bare count -- "2 allocations" does not tell you it is the controller. +# +# BOTH CHECKS ARE REQUIRED. Zero allocated IPs does NOT mean nothing references +# a subnet; an interface can link a subnet without holding an address. The dc0 +# run measured both, and the zero-link result is also what independently +# confirmed D-139 step 3 had migrated all nine role nodes to GUA. +if [ "$ACTION" = "retire-v6-ula" ]; then + # Source of truth for the retired /48 is netbox/d139-gua-carve.py's + # RETIRED_ULA_48. Retired FOR VR1; still a valid org aggregate elsewhere. + RETIRED_ULA_48="fd50:840e:74e2::/48" + echo "== dc-plane-ipam retire-v6-ula: $SITE (profile $MAAS_PROFILE, ULA $RETIRED_ULA_48) ==" + + MACHJSON="$(maas "$MAAS_PROFILE" machines read 2>/dev/null)" \ + || refuse "'maas $MAAS_PROFILE machines read' failed -- cannot check interface links, and + 'could not look' is NEVER 'nothing there'" + + ULA_ROWS="$(printf '%s' "$SUBJSON" | python3 -c " +import ipaddress,json,sys +ula=ipaddress.ip_network(sys.argv[1]) +for x in json.load(sys.stdin): + c=x.get('cidr','') + try: n=ipaddress.ip_network(c) + except ValueError: continue + if n.version==6 and n.subnet_of(ula): print('%s %s' % (x['id'], c)) +" "$RETIRED_ULA_48")" + + if [ -z "$ULA_ROWS" ]; then + echo " [skip] no ULA v6 subnets remain in this region -- already retired" + echo; echo "RESULT: planned=0 applied=0 skipped=1 errors=0"; exit 0 + fi + + while read -r sid scidr; do + [ -n "$sid" ] || continue + ips="$(maas "$MAAS_PROFILE" subnet ip-addresses "$sid" 2>/dev/null)" \ + || { err "$scidr could not read ip-addresses -- refusing to delete on an unread subnet"; continue; } + holders="$(printf '%s' "$ips" | python3 -c " +import json,sys +try: d=json.load(sys.stdin) +except Exception: print('UNREADABLE'); sys.exit() +print('; '.join('%s (alloc_type=%s user=%s)' % (e.get('ip'), e.get('alloc_type'), e.get('user')) for e in d)) +")" + links="$(printf '%s' "$MACHJSON" | python3 -c " +import json,sys +sid=sys.argv[1]; out=[] +for m in json.load(sys.stdin): + for i in m.get('interface_set',[]): + for l in i.get('links',[]): + if str(((l.get('subnet') or {}).get('id')))==sid: + out.append('%s:%s' % (m.get('hostname'), i.get('name'))) +print('; '.join(out)) +" "$sid")" + + if [ "$holders" = "UNREADABLE" ]; then + err "$scidr ip-addresses did not parse -- refusing"; continue + fi + if [ -n "$holders" ] || [ -n "$links" ]; then + err "$scidr (id=$sid) is NOT empty -- REFUSING to delete. + allocations: ${holders:-none} + interface links: ${links:-none} + Re-home these onto the GUA plane first. A sticky allocation here is a LIVE host: + on dc0 this guard caught the juju controller (::5) and the MAAS region VM (::6)." + continue + fi + + if [ "$COMMIT" -eq 0 ]; then + plan "$scidr (id=$sid) EMPTY -- would delete" + continue + fi + if maas "$MAAS_PROFILE" subnet delete "$sid" >/dev/null 2>&1; then + # READ BACK. A delete that "succeeded" is not evidence the row is gone. + if maas "$MAAS_PROFILE" subnet read "$sid" >/dev/null 2>&1; then + err "$scidr delete returned success but the subnet is STILL READABLE (id=$sid)" + else + APPLIED=$((APPLIED+1)); printf ' [apply] %s (id=%s) deleted and verified gone\n' "$scidr" "$sid" + fi + else + err "$scidr delete FAILED (id=$sid)" + fi + done <<< "$ULA_ROWS" + + echo + echo "RESULT: planned=$PLANNED applied=$APPLIED skipped=$SKIPPED errors=$ERRORS" + [ "$COMMIT" -eq 1 ] || echo "DRY RUN -- nothing was written. Re-run with --commit to apply." + [ "$ERRORS" -eq 0 ] || exit 1 + exit 0 +fi