Newer
Older
openstack-caracal-dc-dc / scripts / dc-plane-ipam.sh
#!/usr/bin/env bash
# scripts/dc-plane-ipam.sh <check> <site>
#
# Site-keyed per-DC plane IPAM state for VR1. Ruled by the D-134 AMENDMENT of
# 2026-07-27 (R4): "a site-keyed reservation tool on the dc-mirror.sh /
# dc-rack-net.sh pattern (which also gives D-134 an EXECUTABLE gate instead of
# prose)". THIS FILE IS THAT GATE. Until it existed, D-134's band table lived
# only as decision prose and MAAS held ZERO reserved ranges -- a ruled decision
# with no artifact, which is the class the 2026-07-27 audit found twice.
#
# ACTIONS
#   check <site>      READ-ONLY. Compare live MAAS against the ruled expected
#                     state. Exit 0 only if every assertion passes.
#   carve-v6 <site>   Create the six v6 plane subnets, each on the SAME MAAS vlan
#                     as its v4 twin (R2 dual-stack). DRY unless --commit.
#   reserve <site>    Create the D-134 reserved bands + the FIP pool (R4).
#                     DRY unless --commit.
#
# The read-only gate was shipped and RUN FIRST, deliberately (hard rule 3), so the
# mutations are planned against a MEASURED baseline rather than an assumed one.
# Both mutating actions are DRY BY DEFAULT, IDEMPOTENT, and READ BACK every write
# -- this repo has been bitten by a script whose `apply` always silently dry-ran.
#
# EXPECTED STATE IS DERIVED, NEVER HARDCODED (hard rule 3):
#   v4 plane CIDRs  <- scripts/lib-net.sh via lib_net_select_dc  (site-keyed)
#   v6 plane /64s   <- the NetBox APEX record (office1-netbox is the VR1 working
#                      apex, DOCFIX-195). D-136 option (D), ADOPTED 2026-07-27,
#                      makes the apex the source these values come from; reading
#                      it here rather than carrying a second hand-maintained
#                      table is that adoption applied.
#   band table      <- the D-134 2026-07-23 AMENDMENT (the amendment is
#                      authoritative; the original table above it is SUPERSEDED).
#
# Exit: 0 all assertions pass | 1 assertion(s) failed | 2 could not evaluate.
# "Could not look" is NEVER "nothing there" -- an unreachable MAAS or an
# unreadable apex record REFUSES (exit 2) instead of reporting a clean tree.
# ASCII + LF.
set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
ACTION="${1:-}"; SITE="${2:-}"
# Shift ONLY what actually exists: `shift 2` with a single argument fails, leaving
# "$@" holding the action itself, which the option loop below then rejected as an
# unknown option instead of printing usage. Found by the harness, not by review.
if [ "$#" -gt 2 ]; then shift 2; else set --; fi
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 <<EOF
usage: $(basename "$0") <check|carve-v6|reserve> <site> [--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)
EOF
  exit 2
}

case "$ACTION" in check|carve-v6|reserve) ;; *) usage ;; esac
case "$SITE" in
  vr1-dc0|vr1-dc1) ;;
  dc0|dc1|dc2)
    echo "FAIL: bare '$SITE' is RETIRED (D-119) -- it was AMBIGUOUS ACROSS REGIONS." >&2
    echo "      Use the region-qualified selector: vr1-dc0 | vr1-dc1" >&2
    exit 2 ;;
  *) usage ;;
esac

PASS=0; FAIL=0
ok()  { PASS=$((PASS+1)); printf '  [ok]   %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf '  [FAIL] %s\n' "$1"; }
refuse() { echo; echo "REFUSE: $1" >&2; echo "  (could not evaluate -- this is NOT a pass)" >&2; exit 2; }

# ---- D-134 AMENDMENT 2026-07-23 band table (the authoritative one) ------------
# Host-part offsets within each plane. Row 0 of every VR1 DC plane, BOTH DCs.
#   .1-.3     infrastructure (.1 gw, .2 rack, .3 DNS forwarder on metal-admin)
#   .4-.49    RESERVED utility/infra nodes
#   .50-.99   VIP band   (widened from .50-.60 by the R11 D-020 amendment)
#   .100-.200 nodes      (control .100-.119 / compute .120-.149 / storage .150-.200)
#   .201-.254 dynamic    (MOVED here from .100-.200 by the amendment)
BAND_UTIL_LO=4;    BAND_UTIL_HI=49
BAND_VIP_LO=50;    BAND_VIP_HI=99
BAND_DYN_LO=201;   BAND_DYN_HI=254

# ---- v4 planes from lib-net (site-keyed) -------------------------------------
# shellcheck disable=SC1091
source "$REPO/scripts/lib-net.sh" >/dev/null 2>&1 || refuse "cannot source scripts/lib-net.sh"
lib_net_select_dc "$SITE" >/dev/null 2>&1 || refuse "lib_net_select_dc '$SITE' failed"
[ "${#PLANE_CIDRS[@]}" -gt 0 ] || refuse "lib-net produced no PLANE_CIDRS for $SITE"

# ---- v6 planes from the APEX record ------------------------------------------
APEX_RECORD="${APEX_RECORD:-$(ls -1t "$REPO"/netbox/draft/vr1-office1-current-*.json 2>/dev/null | head -1)}"
[ -n "$APEX_RECORD" ] && [ -r "$APEX_RECORD" ] \
  || refuse "no readable apex record (looked for netbox/draft/vr1-office1-current-*.json; set APEX_RECORD)"

V6_MAP="$(python3 - "$APEX_RECORD" "$SITE" <<'PY'
import json, sys
rec, site = sys.argv[1], sys.argv[2]
try:
    d = json.load(open(rec))
except Exception as e:
    sys.stderr.write("apex record unreadable: %s\n" % e); sys.exit(3)
out = []
for p in d.get('ipam/prefixes', []):
    pre = p.get('prefix', '')
    if ':' not in pre or not pre.endswith('/64'):
        continue                      # /60 and /48 are parent blocks, not planes
    sc = p.get('scope') or {}
    if (sc.get('name') or p.get('scope_site') or '') != site:
        continue
    role = p.get('role') or {}
    role = role.get('slug') if isinstance(role, dict) else role
    desc = (p.get('description') or '')
    # the provider VIP /64 is a SEPARATE block from the provider node /64; it is
    # not a node-addressing plane, so it is reported apart rather than as a plane.
    kind = 'vip' if 'VIP' in desc else 'plane'
    out.append("%s\t%s\t%s" % (role, kind, pre))
print("\n".join(sorted(out)))
PY
)" || refuse "could not parse the apex record"
[ -n "$V6_MAP" ] || refuse "apex record carries NO /64 prefixes scoped to $SITE"

# ---- live MAAS ---------------------------------------------------------------
command -v maas >/dev/null 2>&1 \
  || refuse "the 'maas' client is ABSENT on this host. NOTE: a missing binary is a
  MISSING TOOL, not an unreachable MAAS -- the 2026-07-27 audit found three separate
  preflight/deploy failures that were all 'the client is not installed' reported as
  something else. Run this from the D-128 Plane-2 host (voffice1)."

SUBJSON="$(maas "$MAAS_PROFILE" subnets read 2>/dev/null)" \
  || refuse "'maas $MAAS_PROFILE subnets read' failed -- MAAS unreachable or profile '$MAAS_PROFILE' not logged in"
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
echo "-- v4 planes (from lib-net) --"
for c in "${PLANE_CIDRS[@]}"; do
  name="${PLANE_NAME[$c]:-?}"
  fab="$(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('fabric','?')); break
else: print('')
" "$c")"
  if [ -n "$fab" ]; then ok "v4 $name $c present (fabric=$fab)"
  else bad "v4 $name $c ABSENT from MAAS"; fi
done

echo
echo "-- v6 planes (from the apex) --"
while IFS=$'\t' read -r role kind pre; do
  [ -z "$pre" ] && continue
  present="$(printf '%s' "$SUBJSON" | python3 -c "
import json,sys
p=sys.argv[1]
print('yes' if any(x['cidr']==p for x in json.load(sys.stdin)) else 'no')
" "$pre")"
  if [ "$kind" = "vip" ]; then
    # Not asserted as a MAAS subnet: the GUA VIP /64 holds hacluster-managed API
    # VIPs, not node addresses, so MAAS never allocates from it and cannot hand
    # one out. Reported for visibility; whether it should ALSO be a MAAS subnet
    # is an open design question, deliberately not decided by this gate.
    printf '  [note] %-16s %-30s provider VIP block, MAAS subnet=%s (not asserted)\n' "$role" "$pre" "$present"
    continue
  fi
  if [ "$present" = "yes" ]; then ok "v6 $role $pre present"
  else bad "v6 $role $pre ABSENT from MAAS (R2 dual-stack is RULED; the v6 carve has not run)"; fi
done <<< "$V6_MAP"

echo
echo "-- D-134 reserved bands (v4) --"
for c in "${PLANE_CIDRS[@]}"; do
  name="${PLANE_NAME[$c]:-?}"
  base="${c%.*}"; base="${base%.*}"          # 10.12.4.0/22 -> 10.12
  third="$(printf '%s' "$c" | cut -d. -f3)"  # third octet of the /22 base
  for band in "util:$BAND_UTIL_LO:$BAND_UTIL_HI" "vip:$BAND_VIP_LO:$BAND_VIP_HI"; do
    bn="${band%%:*}"; rest="${band#*:}"; lo="${rest%%:*}"; hi="${rest##*:}"
    want_lo="$base.$third.$lo"; want_hi="$base.$third.$hi"
    got="$(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('')
" "$want_lo" "$want_hi")"
    case "$got" in
      reserved) ok "$name $bn band $want_lo-$want_hi reserved" ;;
      "")       bad "$name $bn band $want_lo-$want_hi NOT reserved (D-134 is RULED; MAAS may hand these out mid-deploy)" ;;
      *)        bad "$name $bn band $want_lo-$want_hi exists but type='$got' (want 'reserved') -- an unrecognised state REFUSES rather than passing" ;;
    esac
  done
done

echo
echo "RESULT: pass=$PASS fail=$FAIL"
if [ "$FAIL" -eq 0 ]; then
  echo "PASS: dc-plane-ipam check $SITE"
  exit 0
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 <subnet_cidr> <lo_ip> <hi_ip> <comment>
  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.
    #
    # MEASURED 2026-07-27: an explicit reserved range CANNOT be created here, and
    # does not need to be. MAAS auto-reserves `::1`-`::ffff:ffff` on EVERY IPv6
    # subnet (purpose "reserved"), plus `::` itself under RFC 4291 s2.6.1. The ruled
    # bands sit entirely inside that, so `ipranges create` returns
    #   "Requested reserved range conflicts with an existing range."
    # and the addresses are ALREADY protected -- far more broadly than the band.
    # So this VERIFIES the property instead of attempting a write. R4's "v6 bands
    # follow as a second pass" is satisfied by MAAS's own default, not by our range.
    sid="$(subnet_id_of "$pre")"
    if [ -z "$sid" ]; then err "$role $pre has no subnet id"; continue; fi
    covered="$(maas "$MAAS_PROFILE" subnet reserved-ip-ranges "$sid" 2>/dev/null | python3 -c "
import json,sys,ipaddress
try: d=json.load(sys.stdin)
except Exception: print('ERR'); sys.exit()
lo=ipaddress.ip_address(sys.argv[1]); hi=ipaddress.ip_address(sys.argv[2])
for r in d:
    if ipaddress.ip_address(r['start'])<=lo and ipaddress.ip_address(r['end'])>=hi:
        print('yes'); break
else: print('no')
" "${base}::${BAND_UTIL_LO}" "${base}::${BAND_VIP_HI}")"
    case "$covered" in
      yes) skip "$role $pre bands ${BAND_UTIL_LO}-${BAND_VIP_HI} already protected by MAAS's default IPv6 reservation (::1-::ffff:ffff) -- no explicit range needed or possible" ;;
      no)  err "$role $pre ruled bands are NOT inside MAAS's reserved space -- investigate before deploy" ;;
      *)   err "$role $pre could not read reserved-ip-ranges (could not look is not nothing there)" ;;
    esac
  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