diff --git a/scripts/dc-plane-ipam.sh b/scripts/dc-plane-ipam.sh index 796de6a..305fb9a 100755 --- a/scripts/dc-plane-ipam.sh +++ b/scripts/dc-plane-ipam.sh @@ -34,12 +34,23 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO="$(cd "$HERE/.." && pwd)" -ACTION="${1:-}"; SITE="${2:-}" +ACTION="${1:-}"; SITE="${2:-}"; shift 2 2>/dev/null || true +COMMIT=0 +for a in "$@"; do case "$a" in --commit) COMMIT=1 ;; *) echo "FAIL: unknown option '$a'" >&2; exit 2 ;; esac; done MAAS_PROFILE="${MAAS_PROFILE:-admin}" usage() { cat >&2 < +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). + + 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, + per the maas-node-power.sh precedent. Both mutating actions are IDEMPOTENT -- + an item already in the ruled state is SKIPPED, not rewritten. site: vr1-dc0 | vr1-dc1 (region-qualified per D-119; bare dc0/dc1 are RETIRED) env: MAAS_PROFILE (default: admin) APEX_RECORD (default: newest netbox/draft/vr1-office1-current-*.json) @@ -47,7 +58,7 @@ exit 2 } -case "$ACTION" in check) ;; *) usage ;; esac +case "$ACTION" in check|carve-v6|reserve) ;; *) usage ;; esac case "$SITE" in vr1-dc0|vr1-dc1) ;; dc0|dc1|dc2) @@ -123,6 +134,40 @@ RANGEJSON="$(maas "$MAAS_PROFILE" ipranges read 2>/dev/null)" \ || refuse "'maas $MAAS_PROFILE ipranges read' failed" +# ============================ helpers ======================================== +# vlan id of the fabric carrying a v4 plane -- a v6 subnet must land on the SAME +# vlan so the plane is genuinely dual-stack on one L2, not a parallel fabric. +vlan_of() { printf '%s' "$SUBJSON" | python3 -c " +import json,sys +c=sys.argv[1] +for x in json.load(sys.stdin): + if x['cidr']==c: print((x.get('vlan') or {}).get('id','')); break +else: print('') +" "$1"; } +subnet_id_of() { printf '%s' "$SUBJSON" | python3 -c " +import json,sys +c=sys.argv[1] +for x in json.load(sys.stdin): + if x['cidr']==c: print(x['id']); break +else: print('') +" "$1"; } +cidr_for_role() { local r="$1" c; for c in "${PLANE_CIDRS[@]}"; do + [ "${PLANE_NAME[$c]:-}" = "$r" ] && { printf '%s' "$c"; return 0; }; done; return 1; } +range_type() { printf '%s' "$RANGEJSON" | python3 -c " +import json,sys +lo,hi=sys.argv[1],sys.argv[2] +for r in json.load(sys.stdin): + if r.get('start_ip')==lo and r.get('end_ip')==hi: print(r.get('type','?')); break +else: print('') +" "$1" "$2"; } + +PLANNED=0; APPLIED=0; SKIPPED=0; ERRORS=0 +plan() { PLANNED=$((PLANNED+1)); printf ' [plan] %s\n' "$1"; } +skip() { SKIPPED=$((SKIPPED+1)); printf ' [skip] %s\n' "$1"; } +err() { ERRORS=$((ERRORS+1)); printf ' [ERR] %s\n' "$1"; } +did() { APPLIED=$((APPLIED+1)); printf ' [done] %s\n' "$1"; } + +if [ "$ACTION" = "check" ]; then echo "== dc-plane-ipam check $SITE ==" echo " apex record: ${APEX_RECORD#$REPO/}" echo @@ -194,3 +239,130 @@ fi echo "FAIL: dc-plane-ipam check $SITE -- $FAIL assertion(s) failed" exit 1 +fi + +# ======================= carve-v6 (R2 dual-stack propagation) ================= +# Creates the six v6 plane subnets, each on the SAME MAAS vlan as its v4 twin so +# the plane is genuinely dual-stack on one L2 rather than a parallel fabric. +# The provider GUA VIP /64 is NOT created: it holds hacluster-managed API VIPs, +# not node addresses, so MAAS never allocates from it (see the check action). +if [ "$ACTION" = "carve-v6" ]; then + echo "== dc-plane-ipam carve-v6 $SITE $([ "$COMMIT" -eq 1 ] && echo '--commit' || echo '(DRY RUN)') ==" + echo " apex record: ${APEX_RECORD#$REPO/}" + echo + while IFS=$'\t' read -r role kind pre; do + [ -z "$pre" ] && continue + [ "$kind" = "vip" ] && { skip "$role $pre -- provider VIP block, deliberately not a MAAS subnet"; continue; } + if printf '%s' "$SUBJSON" | python3 -c " +import json,sys +sys.exit(0 if any(x['cidr']==sys.argv[1] for x in json.load(sys.stdin)) else 1)" "$pre"; then + skip "$role $pre already present"; continue + fi + v4="$(cidr_for_role "$role")" || { err "$role: no v4 plane in lib-net for role '$role' -- refusing to guess a fabric"; continue; } + vlan="$(vlan_of "$v4")" + [ -n "$vlan" ] || { err "$role: v4 twin $v4 has no vlan id in MAAS -- refusing to create $pre on an unknown fabric"; continue; } + plan "create $pre on vlan=$vlan (the $v4 fabric) -- role $role" + if [ "$COMMIT" -eq 1 ]; then + if maas "$MAAS_PROFILE" subnets create cidr="$pre" vlan="$vlan" \ + description="VR1 $SITE $role (v6, R2 dual-stack; D-101/D-111 apex value)" >/dev/null 2>&1; then + # 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 dry-ran). + got="$(maas "$MAAS_PROFILE" subnets read 2>/dev/null | python3 -c " +import json,sys +p=sys.argv[1] +for x in json.load(sys.stdin): + if x['cidr']==p: print((x.get('vlan') or {}).get('id','')); break +else: print('')" "$pre")" + if [ "$got" = "$vlan" ]; then did "$pre created and READ BACK on vlan=$got" + else err "$pre create reported success but read-back gives vlan='$got' (want '$vlan')"; fi + else + err "$pre create FAILED" + fi + fi + done <<< "$V6_MAP" + 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 + +# ======================= reserve (D-134 bands + FIP pool, R4) ================= +if [ "$ACTION" = "reserve" ]; then + echo "== dc-plane-ipam reserve $SITE $([ "$COMMIT" -eq 1 ] && echo '--commit' || echo '(DRY RUN)') ==" + echo + + # mk + mk() { + local sub="$1" lo="$2" hi="$3" cmt="$4" sid t + t="$(range_type "$lo" "$hi")" + case "$t" in + reserved) skip "$lo-$hi already reserved"; return 0 ;; + "") ;; + *) err "$lo-$hi exists with type='$t' (want 'reserved') -- REFUSING to rewrite an existing range; resolve by hand"; return 0 ;; + esac + sid="$(subnet_id_of "$sub")" + [ -n "$sid" ] || { err "subnet $sub not in MAAS -- cannot reserve $lo-$hi on it"; return 0; } + plan "reserve $lo-$hi on $sub (subnet id=$sid) -- $cmt" + [ "$COMMIT" -eq 1 ] || return 0 + if maas "$MAAS_PROFILE" ipranges create type=reserved subnet="$sid" \ + start_ip="$lo" end_ip="$hi" comment="$cmt" >/dev/null 2>&1; then + RANGEJSON="$(maas "$MAAS_PROFILE" ipranges read 2>/dev/null)" + if [ "$(range_type "$lo" "$hi")" = "reserved" ]; then did "$lo-$hi reserved and READ BACK" + else err "$lo-$hi create reported success but read-back does not show it reserved"; fi + else + err "$lo-$hi create FAILED" + fi + } + + echo "-- v4 D-134 bands --" + for c in "${PLANE_CIDRS[@]}"; do + name="${PLANE_NAME[$c]:-?}" + b="${c%.*}"; b="${b%.*}"; third="$(printf '%s' "$c" | cut -d. -f3)" + mk "$c" "$b.$third.$BAND_UTIL_LO" "$b.$third.$BAND_UTIL_HI" "D-134 utility band ($name)" + mk "$c" "$b.$third.$BAND_VIP_LO" "$b.$third.$BAND_VIP_HI" "D-134 VIP band ($name)" + done + + echo + echo "-- FIP pool (R4 ruled scope: 'plus the FIP pool') --" + # NOT DERIVED BY PATTERN. lib-net carries FIP_POOL_START/END for vr1-dc0 and + # deliberately UNSETS them for vr1-dc1, whose OpenStack-layer values are "NOT yet + # ruled/measured ... UNSET so any use fails loud". Inventing dc1's pool by mirroring + # dc0's shape would be an inferred value (hard rule 2), so this REFUSES instead. + if [ -n "${FIP_POOL_START:-}" ] && [ -n "${FIP_POOL_END:-}" ]; then + prov="$(cidr_for_role provider-public)" \ + && mk "$prov" "$FIP_POOL_START" "$FIP_POOL_END" "FIP pool for neutron (phase-04-network-verify asserts this)" \ + || err "no provider-public plane for $SITE" + else + err "FIP_POOL_START/END are UNSET for $SITE in lib-net.sh -- this is DELIBERATE + (the dc1 arm unsets the OpenStack-layer values as not-yet-ruled). R4's ruled + scope includes the FIP pool, so R4 CANNOT be fully executed for this site until + the pool is ruled. Refusing to infer it from dc0's shape (hard rule 2)." + fi + + echo + echo "-- v6 bands (mirror the v4 octet; ruled 2026-07-27) --" + # FORCED SEQUENCING, not a deferral: a range cannot be reserved on a subnet that + # does not exist, so this is a no-op until carve-v6 has run. + while IFS=$'\t' read -r role kind pre; do + if [ -z "$pre" ] || [ "$kind" = "vip" ]; then continue; fi + if ! printf '%s' "$SUBJSON" | python3 -c " +import json,sys +sys.exit(0 if any(x['cidr']==sys.argv[1] for x in json.load(sys.stdin)) else 1)" "$pre"; then + skip "$role $pre not in MAAS yet -- run carve-v6 first (forced sequencing)"; continue + fi + base="${pre%%::/64}" + # The ruled mirror is TEXTUAL, not numeric: keystone at v4 octet .50 is `::50`, + # i.e. the same DIGITS in the v6 host part. Converting 50 to hex would give + # `::32` -- a different, unruled band that would still look plausible. The band + # bounds 4/49/50/99 are all digit-only and therefore valid hex literals as-is. + mk "$pre" "${base}::${BAND_UTIL_LO}" "${base}::${BAND_UTIL_HI}" "D-134 v6 utility band ($role)" + mk "$pre" "${base}::${BAND_VIP_LO}" "${base}::${BAND_VIP_HI}" "D-134 v6 VIP band ($role)" + done <<< "$V6_MAP" + + 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