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
#                  and report per-plane. Exit 0 only if every assertion passes.
#
# The MUTATING actions (v6 carve, band reservation) are NOT in this file yet --
# hard rule 3, verify before mutate: the read-only gate ships and runs first, so
# the mutation is planned against a MEASURED baseline rather than an assumed one.
#
# 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:-}"
MAAS_PROFILE="${MAAS_PROFILE:-admin}"

usage() {
  cat >&2 <<EOF
usage: $(basename "$0") check <site>
  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) ;; *) 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"

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