diff --git a/scripts/dc-region-topology.sh b/scripts/dc-region-topology.sh new file mode 100644 index 0000000..90a10e2 --- /dev/null +++ b/scripts/dc-region-topology.sh @@ -0,0 +1,374 @@ +#!/usr/bin/env bash +# scripts/dc-region-topology.sh --profile +# --expect-rack [,...] [--commit] +# +# Build (or verify) a per-DC MAAS region's PLANE TOPOLOGY: named plane fabrics, +# the six spaces, the six v4 plane subnets on the right VLANs, the metal-admin +# service config (dynamic range + node DNS), and the site placement tag. +# +# check READ-ONLY. Exit 0 only if every assertion passes. +# apply Idempotent, DRY unless --commit. Reads back every write. +# +# WHY THIS EXISTS (2026-07-30, D-132 q1 per-DC MAAS regions). +# A read-only survey of the dc0 carve found that THREE of its components had no +# repo tool at all -- the named plane fabrics, the six v4 plane subnets, and the +# site tag were all created ad-hoc during the Stage-4 window, with the mutations +# landing only in `~/as-executed/2026-07-23-stage4-carve.log`, which is not in +# the repo. When D-132 q1 required rebuilding that carve on a new region there +# was nothing to re-run. This script closes that gap so the SECOND DC and every +# Roosevelt DC standup rebuild from a tool rather than from a transcript. +# +# WHAT THIS SCRIPT DOES NOT DO, and what owns each piece instead: +# v6 plane subnets scripts/dc-plane-ipam.sh carve-v6 +# D-134 reserved bands scripts/dc-plane-ipam.sh reserve +# role tags on nodes scripts/maas-role-tags.sh apply +# per-node v4 NIC carve scripts/dc-node-carve.sh +# per-node v6 addresses scripts/dc-node-v6-carve.py --profile

+# per-machine power scripts/maas-node-power.sh +# Sequencing matters: carve-v6 refuses to guess a fabric, so the v4 twins this +# script creates must exist first. +# +# REGION SAFETY IS NOT OPTIONAL HERE. Every mutating call is preceded by +# scripts/maas-profile-assert.sh, which proves the profile resolves to the +# intended region by RACK-CONTROLLER IDENTITY. Two regions hold separate +# databases, so against the wrong profile this script's `apply` is an +# idempotent no-op that prints PASS while the real region stays untouched -- +# the failure mode that motivated the assert tool in the first place. A machine +# COUNT is not proof; a rack registers to exactly one region. +# +# NUMERIC MAAS IDS ARE REGION-LOCAL AND ARE NEVER PERSISTED. Fabric, VLAN, +# subnet and space ids are resolved per-run, by NAME or CIDR, under the same +# profile (lib-net.sh:8-10 -- "CIDR is the stable key throughout"). The +# pre-migration capture's `id=`/`vlan_id=` values are DATABASE ROW IDS and are +# deliberately not read by anything here. +# +# Exit: 0 all assertions pass | 1 assertion(s) failed | 2 could not evaluate. +# ASCII + LF. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" + +ACTION="${1:-}"; SITE="${2:-}" +if [ "$#" -gt 2 ]; then shift 2; else set --; fi +PROFILE=""; EXPECT_RACK=""; COMMIT=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --profile) PROFILE="${2:-}"; shift 2 ;; + --expect-rack) EXPECT_RACK="${2:-}"; shift 2 ;; + --commit) COMMIT=1; shift ;; + *) echo "FAIL: unknown option '$1'" >&2; exit 2 ;; + esac +done + +usage() { + cat >&2 <<'U' +usage: dc-region-topology.sh --profile

--expect-rack [,] [--commit] + e.g. dc-region-topology.sh check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid + dc-region-topology.sh apply vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid --commit +U + exit 2 +} +[ -n "$ACTION" ] && [ -n "$SITE" ] || usage +case "$ACTION" in check|apply) ;; *) usage ;; esac +[ -n "$PROFILE" ] || { echo "FAIL: --profile is REQUIRED. There is no default: the repo-wide default 'admin' resolves to the OFFICE1 region, and a topology build against the wrong region is a silent no-op." >&2; exit 2; } +[ -n "$EXPECT_RACK" ] || { echo "FAIL: --expect-rack is REQUIRED -- it is what proves the profile points at the region you mean." >&2; exit 2; } + +command -v maas >/dev/null 2>&1 || { echo "REFUSE: no 'maas' CLI on this host" >&2; exit 2; } + +# ---- region identity gate: run BEFORE reading or writing anything ---------- +ASSERT="$HERE/maas-profile-assert.sh" +[ -f "$ASSERT" ] || { echo "REFUSE: $ASSERT missing -- refusing to touch a region whose identity cannot be proven" >&2; exit 2; } +if ! bash "$ASSERT" "$PROFILE" "$EXPECT_RACK"; then + echo "REFUSE: profile '$PROFILE' does not resolve to the expected region -- nothing was read or written" >&2 + exit 2 +fi + +# ---- expected state: DERIVED from lib-net, never typed --------------------- +# shellcheck source=/dev/null +. "$REPO/scripts/lib-net.sh" +lib_net_select_dc "$SITE" >/dev/null 2>&1 || { echo "FAIL: lib_net_select_dc rejected site '$SITE'" >&2; exit 2; } + +# The DC label ("dc0"/"dc1") drives fabric naming. DERIVE it, never type it. +case "$SITE" in *-*) DCLABEL="${SITE#*-}" ;; *) echo "FAIL: site '$SITE' is not -" >&2; exit 2 ;; esac + +# metal-admin is DELIBERATELY NOT a named fabric. In the dc0 as-built it sits on +# the auto-created fabric that owns the rack's PXE interface (`fabric-4` in the +# Office1 region), because that fabric is created by MAAS when the rack +# registers -- before anything in this repo can name it. Renaming it would fight +# MAAS for ownership of its own discovery artifact for no functional gain: +# Juju binds SPACES, not fabrics. So five planes get named fabrics and +# metal-admin keeps whatever MAAS made. +METAL_ADMIN_CIDR="10.12.8.0/22" +case "$SITE" in + vr1-dc1) METAL_ADMIN_CIDR="10.12.68.0/22" ;; +esac + +P=0; F=0 +ok(){ echo " [ok] $1"; P=$((P+1)); } +bad(){ echo " [FAIL] $1"; F=$((F+1)); } +ck(){ if [ "$1" = 0 ]; then ok "$2"; else bad "$2"; fi; } +refuse(){ echo "REFUSE: $1" >&2; exit 2; } + +m(){ maas "$PROFILE" "$@"; } + +# ---- per-run id resolution (never persisted) ------------------------------ +FABJSON=""; SUBJSON=""; SPCJSON="" +reload() { + FABJSON="$(m fabrics read 2>/dev/null)" || refuse "could not read fabrics" + SUBJSON="$(m subnets read 2>/dev/null)" || refuse "could not read subnets" + SPCJSON="$(m spaces read 2>/dev/null)" || refuse "could not read spaces" + printf '%s' "$FABJSON" | python3 -c 'import json,sys; json.load(sys.stdin)' 2>/dev/null || refuse "fabrics payload is not JSON" +} +fab_id_byname() { printf '%s' "$FABJSON" | python3 -c ' +import json,sys +n=sys.argv[1] +for f in json.load(sys.stdin): + if f.get("name")==n: print(f["id"]); break +' "$1" 2>/dev/null; } +# The untagged (vid 0) VLAN of a fabric -- the one a flat plane rides. +fab_vlan_vid0() { printf '%s' "$FABJSON" | python3 -c ' +import json,sys +n=sys.argv[1] +for f in json.load(sys.stdin): + if f.get("name")==n: + for v in f.get("vlans",[]): + if v.get("vid")==0: print(v["id"]); break + break +' "$1" 2>/dev/null; } +sub_id_bycidr() { printf '%s' "$SUBJSON" | python3 -c ' +import json,sys +c=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("cidr")==c: print(s["id"]); break +' "$1" 2>/dev/null; } +sub_vlan_bycidr() { printf '%s' "$SUBJSON" | python3 -c ' +import json,sys +c=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("cidr")==c: print(s["vlan"]["id"]); break +' "$1" 2>/dev/null; } +sub_fabric_bycidr() { printf '%s' "$SUBJSON" | python3 -c ' +import json,sys +c=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("cidr")==c: print(s["vlan"]["fabric"]); break +' "$1" 2>/dev/null; } +sub_gw_bycidr() { printf '%s' "$SUBJSON" | python3 -c ' +import json,sys +c=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("cidr")==c: print(s.get("gateway_ip") or ""); break +' "$1" 2>/dev/null; } +space_id_byname() { printf '%s' "$SPCJSON" | python3 -c ' +import json,sys +n=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("name")==n: print(s["id"]); break +' "$1" 2>/dev/null; } +vlan_space_of_sub() { printf '%s' "$SUBJSON" | python3 -c ' +import json,sys +c=sys.argv[1] +for s in json.load(sys.stdin): + if s.get("cidr")==c: + print(s["vlan"].get("space") or ""); break +' "$1" 2>/dev/null; } + +fabric_name_for() { # $1 = plane name; "" means metal-admin keeps MAAS's own fabric + [ "$1" = "metal-admin" ] && { echo ""; return; } + echo "${SITE%%-*}-${DCLABEL}-$1" +} + +# ---------------------------------------------------------------- check ---- +do_check() { + reload + echo "dc-region-topology check: site=$SITE profile=$PROFILE" + + # 1. named plane fabrics (five; metal-admin excluded by design) + for cidr in "${PLANE_CIDRS[@]}"; do + plane="${PLANE_NAME[$cidr]}" + fn="$(fabric_name_for "$plane")" + [ -z "$fn" ] && continue + id="$(fab_id_byname "$fn")" + [ -n "$id" ]; ck $? "fabric '$fn' exists" + done + + # 2. six spaces + for sp in "${SPACES6[@]}"; do + id="$(space_id_byname "$sp")" + [ -n "$id" ]; ck $? "space '$sp' exists" + done + + # 3. v4 plane subnets present, on the right fabric, bound to the right space + for cidr in "${PLANE_CIDRS[@]}"; do + plane="${PLANE_NAME[$cidr]}" + sid="$(sub_id_bycidr "$cidr")" + if [ -z "$sid" ]; then bad "subnet $cidr ($plane) exists"; continue; fi + ok "subnet $cidr ($plane) exists" + fn="$(fabric_name_for "$plane")" + actual_fab="$(sub_fabric_bycidr "$cidr")" + if [ -n "$fn" ]; then + [ "$actual_fab" = "$fn" ]; ck $? "subnet $cidr is on fabric '$fn' (got '${actual_fab:-none}')" + else + # metal-admin: assert only that it is NOT sharing a fabric with a named + # plane, which would collapse two L2 domains into one. + case "$actual_fab" in + "${SITE%%-*}-${DCLABEL}-"*) bad "metal-admin $cidr must NOT sit on a named plane fabric (got '$actual_fab')" ;; + "") bad "metal-admin $cidr has no fabric" ;; + *) ok "metal-admin $cidr is on MAAS's own fabric '$actual_fab' (by design)" ;; + esac + fi + # space binding -- this is what Juju actually consumes + sp="$(vlan_space_of_sub "$cidr")" + [ "$sp" = "$plane" ]; ck $? "subnet $cidr VLAN is bound to space '$plane' (got '${sp:-none}')" + # gateway: exactly the planes lib-net rules a gateway for, and no others + want_gw="${PLANE_GW[$cidr]:-}" + got_gw="$(sub_gw_bycidr "$cidr")" + [ "$got_gw" = "$want_gw" ]; ck $? "subnet $cidr gateway '${want_gw:-none}' (got '${got_gw:-none}')" + done + + # 4. metal-admin service config -- the piece that was runbook prose only + masid="$(sub_id_bycidr "$METAL_ADMIN_CIDR")" + if [ -z "$masid" ]; then + bad "metal-admin subnet $METAL_ADMIN_CIDR exists (cannot check its service config)" + else + MAJSON="$(m subnet read "$masid" 2>/dev/null)" + dns="$(printf '%s' "$MAJSON" | python3 -c 'import json,sys; print(",".join(json.load(sys.stdin).get("dns_servers") or []))' 2>/dev/null)" + [ -n "$dns" ]; ck $? "metal-admin carries node-facing dns_servers (got '${dns:-none}') -- D-131" + ad="$(printf '%s' "$MAJSON" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("allow_dns"))' 2>/dev/null)" + [ "$ad" = "False" ]; ck $? "metal-admin allow_dns is False (got '$ad') -- D-131" + dyn="$(m subnet reserved-ip-ranges "$masid" 2>/dev/null | python3 -c ' +import json,sys +try: d=json.load(sys.stdin) +except Exception: sys.exit(1) +print(sum(1 for r in d if "dynamic" in (r.get("purpose") or [])))' 2>/dev/null)" + [ "${dyn:-0}" -ge 1 ]; ck $? "metal-admin has a DHCP dynamic range (got ${dyn:-0})" + fi + + # 5. the site placement tag the bundle and the v6 carve both depend on + tags="$(m tags read 2>/dev/null | python3 -c 'import json,sys; print(" ".join(t["name"] for t in json.load(sys.stdin)))' 2>/dev/null)" + case " $tags " in *" openstack-$SITE "*) ok "tag 'openstack-$SITE' exists" ;; *) bad "tag 'openstack-$SITE' exists (tags: ${tags:-none})" ;; esac + + echo "dc-region-topology: $P passed, $F failed" + [ "$F" -eq 0 ] || return 1 + return 0 +} + +# ---------------------------------------------------------------- apply ---- +say(){ if [ "$COMMIT" -eq 1 ]; then echo " APPLY $1"; else echo " DRY $1"; fi; } +do_apply() { + reload + echo "dc-region-topology apply: site=$SITE profile=$PROFILE commit=$COMMIT" + + # 1. named plane fabrics + for cidr in "${PLANE_CIDRS[@]}"; do + plane="${PLANE_NAME[$cidr]}"; fn="$(fabric_name_for "$plane")" + [ -z "$fn" ] && continue + if [ -n "$(fab_id_byname "$fn")" ]; then echo " [idem] fabric '$fn' present"; continue; fi + say "fabrics create name=$fn" + [ "$COMMIT" -eq 1 ] && { m fabrics create name="$fn" >/dev/null 2>&1 || refuse "could not create fabric $fn"; reload + [ -n "$(fab_id_byname "$fn")" ] || refuse "created fabric $fn but it did not read back"; } + done + + # 2. spaces + for sp in "${SPACES6[@]}"; do + if [ -n "$(space_id_byname "$sp")" ]; then echo " [idem] space '$sp' present"; continue; fi + say "spaces create name=$sp" + [ "$COMMIT" -eq 1 ] && { m spaces create name="$sp" >/dev/null 2>&1 || refuse "could not create space $sp"; reload + [ -n "$(space_id_byname "$sp")" ] || refuse "created space $sp but it did not read back"; } + done + + # 3. subnets: create missing, MOVE any that sit on the wrong fabric, set gw + for cidr in "${PLANE_CIDRS[@]}"; do + plane="${PLANE_NAME[$cidr]}"; fn="$(fabric_name_for "$plane")" + want_gw="${PLANE_GW[$cidr]:-}" + sid="$(sub_id_bycidr "$cidr")" + if [ -z "$sid" ]; then + [ -n "$fn" ] || refuse "metal-admin subnet $cidr is absent -- it is created by the rack's own registration, not by this script; commission the rack first" + vid="$(fab_vlan_vid0 "$fn")" + [ -n "$vid" ] || { [ "$COMMIT" -eq 1 ] && refuse "fabric $fn has no untagged VLAN"; vid=""; } + say "subnets create cidr=$cidr vlan=$vid ${want_gw:+gateway_ip=$want_gw}" + if [ "$COMMIT" -eq 1 ]; then + if [ -n "$want_gw" ]; then m subnets create cidr="$cidr" vlan="$vid" gateway_ip="$want_gw" >/dev/null 2>&1 + else m subnets create cidr="$cidr" vlan="$vid" >/dev/null 2>&1; fi + [ $? -eq 0 ] || refuse "could not create subnet $cidr" + reload; [ -n "$(sub_id_bycidr "$cidr")" ] || refuse "created subnet $cidr but it did not read back" + fi + else + # present: is it on the right fabric? + if [ -n "$fn" ]; then + actual_fab="$(sub_fabric_bycidr "$cidr")" + if [ "$actual_fab" != "$fn" ]; then + vid="$(fab_vlan_vid0 "$fn")" + say "subnet update $sid vlan=$vid # MOVE $cidr off '$actual_fab' onto '$fn'" + if [ "$COMMIT" -eq 1 ]; then + [ -n "$vid" ] || refuse "fabric $fn has no untagged VLAN to move $cidr onto" + m subnet update "$sid" vlan="$vid" >/dev/null 2>&1 || refuse "could not move subnet $cidr" + reload + [ "$(sub_fabric_bycidr "$cidr")" = "$fn" ] || refuse "moved subnet $cidr but it did not read back on $fn" + fi + else + echo " [idem] subnet $cidr already on '$fn'" + fi + fi + got_gw="$(sub_gw_bycidr "$cidr")" + if [ "$got_gw" != "$want_gw" ]; then + say "subnet update $sid gateway_ip='${want_gw:-}' # was '${got_gw:-none}'" + if [ "$COMMIT" -eq 1 ]; then + m subnet update "$sid" gateway_ip="$want_gw" >/dev/null 2>&1 || refuse "could not set gateway on $cidr" + reload + [ "$(sub_gw_bycidr "$cidr")" = "$want_gw" ] || refuse "set gateway on $cidr but it did not read back" + fi + fi + fi + done + + # 4. bind each plane's VLAN to its space + reload + for cidr in "${PLANE_CIDRS[@]}"; do + plane="${PLANE_NAME[$cidr]}" + cur="$(vlan_space_of_sub "$cidr")" + [ "$cur" = "$plane" ] && { echo " [idem] $cidr VLAN already in space '$plane'"; continue; } + vlid="$(sub_vlan_bycidr "$cidr")"; spid="$(space_id_byname "$plane")" + fabname="$(sub_fabric_bycidr "$cidr")" + say "vlan update (fabric '$fabname' vlan $vlid) space=$plane # was '${cur:-none}'" + if [ "$COMMIT" -eq 1 ]; then + [ -n "$vlid" ] && [ -n "$spid" ] || refuse "cannot bind $cidr: vlan='$vlid' space='$spid'" + fabid="$(fab_id_byname "$fabname")" + vidnum="$(m vlans read "$fabid" 2>/dev/null | python3 -c ' +import json,sys +t=sys.argv[1] +for v in json.load(sys.stdin): + if str(v["id"])==t: print(v["vid"]); break +' "$vlid")" + [ -n "$vidnum" ] || refuse "could not resolve the 802.1Q vid for vlan row $vlid" + m vlan update "$fabid" "$vidnum" space="$plane" >/dev/null 2>&1 || refuse "could not bind $cidr to space $plane" + reload + [ "$(vlan_space_of_sub "$cidr")" = "$plane" ] || refuse "bound $cidr to $plane but it did not read back" + fi + done + + # 5. the site placement tag + tags="$(m tags read 2>/dev/null | python3 -c 'import json,sys; print(" ".join(t["name"] for t in json.load(sys.stdin)))' 2>/dev/null)" + case " $tags " in + *" openstack-$SITE "*) echo " [idem] tag 'openstack-$SITE' present" ;; + *) say "tags create name=openstack-$SITE" + [ "$COMMIT" -eq 1 ] && { m tags create name="openstack-$SITE" comment="VR1 $SITE placement tag (bundle constraint; dc-node-v6-carve site membership)" >/dev/null 2>&1 || refuse "could not create tag openstack-$SITE" + t2="$(m tags read 2>/dev/null | python3 -c 'import json,sys; print(" ".join(t["name"] for t in json.load(sys.stdin)))' 2>/dev/null)" + case " $t2 " in *" openstack-$SITE "*) ;; *) refuse "created tag openstack-$SITE but it did not read back" ;; esac; } ;; + esac + + echo + if [ "$COMMIT" -eq 1 ]; then + echo "-- verifying --"; do_check; return $? + fi + echo "DRY RUN complete (no --commit). Nothing was written." + echo "NOTE: metal-admin's dynamic range / dns_servers / DHCP are NOT set here --" + echo " they are the DHCP handover step and are deliberately separate." + return 0 +} + +case "$ACTION" in + check) do_check; exit $? ;; + apply) do_apply; exit $? ;; +esac diff --git a/tests/HARNESS-MANIFEST b/tests/HARNESS-MANIFEST index 2f82784..3670a13 100644 --- a/tests/HARNESS-MANIFEST +++ b/tests/HARNESS-MANIFEST @@ -27,6 +27,7 @@ dc-plane-ipam dc-rack-mgmt-import dc-rack-net +dc-region-topology dc-selector juju-spaces-check keystone-policy-drift diff --git a/tests/dc-region-topology/run-tests.sh b/tests/dc-region-topology/run-tests.sh new file mode 100644 index 0000000..3da8275 --- /dev/null +++ b/tests/dc-region-topology/run-tests.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# tests/dc-region-topology/run-tests.sh -- unit tests for +# scripts/dc-region-topology.sh (2026-07-30). +# +# A stub `maas` on PATH serves JSON fixtures, so no live region is touched. +# STUB_STATE selects a fixture set: +# empty a freshly `maas init`ed region: two auto fabrics from the region +# VM's own NICs, no named fabrics, no spaces, 3 subnets. This is the +# MEASURED starting state of vr1-dc0-maas-01. +# complete the intended end state. +# wrongfab complete, except provider-public's subnet still sits on an auto +# fabric -- the case that needs a MOVE, not a create. +# nospace complete, except one VLAN is not bound to its space. +# +# FIXTURE FIDELITY: an unbound VLAN reads as the STRING "undefined" in MAAS's +# JSON, not as null. The first version of these fixtures used null and passed -- +# graded against a payload MAAS never emits. Corrected after a live read of the +# dc0 region, because a harness whose fixtures do not match reality tests the +# wrong program. +# +# THE POINT OF THIS HARNESS is that `check` must FAIL on every partial state. +# A topology gate that passes on a half-built region is worse than none: the +# migration's whole premise is that the new region is provably equivalent +# before ten machines are deleted from the old one. +# +# It also pins the REGION GATE: the script must refuse entirely when the profile +# does not resolve to the expected rack, and must refuse when --profile or +# --expect-rack is omitted. Defaulting the profile to 'admin' would point this +# at the OFFICE1 region, where `apply` is an idempotent no-op that prints PASS. +set -uo pipefail +SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SD/../.." && pwd)" +SCRIPT="$ROOT/scripts/dc-region-topology.sh" +P=0; F=0 +ok(){ echo "PASS: $1"; P=$((P+1)); } +no(){ echo "FAIL: $1"; F=$((F+1)); } +chk(){ [ "$2" = "$3" ] && ok "$1" || no "$1 (got '$2' want '$3')"; } + +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +cat > "$TMP/bin/maas" <<'STUB' +#!/usr/bin/env bash +# $1 = profile, $2.. = command +prof="$1"; shift +sub="$1"; shift +S="${STUB_STATE:-empty}" + +named='"vr1-dc0-provider-public" "vr1-dc0-metal-internal" "vr1-dc0-data-tenant" "vr1-dc0-storage" "vr1-dc0-replication"' + +emit_fabrics() { + case "$S" in + empty) + echo '[{"id":0,"name":"fabric-0","vlans":[{"id":5001,"vid":0}]},{"id":1,"name":"fabric-1","vlans":[{"id":5002,"vid":0}]}]' ;; + *) + echo '[{"id":0,"name":"fabric-0","vlans":[{"id":5001,"vid":0}]}, + {"id":1,"name":"fabric-1","vlans":[{"id":5002,"vid":0}]}, + {"id":2,"name":"vr1-dc0-provider-public","vlans":[{"id":5189,"vid":0}]}, + {"id":3,"name":"vr1-dc0-metal-internal","vlans":[{"id":5190,"vid":0}]}, + {"id":4,"name":"vr1-dc0-data-tenant","vlans":[{"id":5191,"vid":0}]}, + {"id":5,"name":"vr1-dc0-storage","vlans":[{"id":5192,"vid":0}]}, + {"id":6,"name":"vr1-dc0-replication","vlans":[{"id":5193,"vid":0}]}]' ;; + esac +} +emit_spaces() { + case "$S" in + empty) echo '[{"id":-1,"name":"undefined"}]' ;; + *) echo '[{"id":1,"name":"provider-public"},{"id":2,"name":"metal-admin"},{"id":3,"name":"metal-internal"}, + {"id":4,"name":"data-tenant"},{"id":5,"name":"storage"},{"id":6,"name":"replication"}]' ;; + esac +} +emit_subnets() { + case "$S" in + empty) + echo '[{"id":1,"cidr":"10.12.8.0/22","gateway_ip":null,"vlan":{"id":5001,"fabric":"fabric-0","space":"undefined"}}, + {"id":2,"cidr":"fd50:840e:74e2:220::/64","gateway_ip":null,"vlan":{"id":5001,"fabric":"fabric-0","space":"undefined"}}, + {"id":3,"cidr":"10.12.4.0/22","gateway_ip":"10.12.4.1","vlan":{"id":5002,"fabric":"fabric-1","space":"undefined"}}]' ;; + wrongfab) + echo '[{"id":1,"cidr":"10.12.8.0/22","gateway_ip":null,"vlan":{"id":5001,"fabric":"fabric-0","space":"metal-admin"}}, + {"id":3,"cidr":"10.12.4.0/22","gateway_ip":"10.12.4.1","vlan":{"id":5002,"fabric":"fabric-1","space":"provider-public"}}, + {"id":4,"cidr":"10.12.12.0/22","gateway_ip":null,"vlan":{"id":5190,"fabric":"vr1-dc0-metal-internal","space":"metal-internal"}}, + {"id":5,"cidr":"10.12.16.0/22","gateway_ip":null,"vlan":{"id":5191,"fabric":"vr1-dc0-data-tenant","space":"data-tenant"}}, + {"id":6,"cidr":"10.12.32.0/22","gateway_ip":null,"vlan":{"id":5192,"fabric":"vr1-dc0-storage","space":"storage"}}, + {"id":7,"cidr":"10.12.36.0/22","gateway_ip":null,"vlan":{"id":5193,"fabric":"vr1-dc0-replication","space":"replication"}}]' ;; + nospace) + echo '[{"id":1,"cidr":"10.12.8.0/22","gateway_ip":null,"vlan":{"id":5001,"fabric":"fabric-0","space":"metal-admin"}}, + {"id":3,"cidr":"10.12.4.0/22","gateway_ip":"10.12.4.1","vlan":{"id":5189,"fabric":"vr1-dc0-provider-public","space":"undefined"}}, + {"id":4,"cidr":"10.12.12.0/22","gateway_ip":null,"vlan":{"id":5190,"fabric":"vr1-dc0-metal-internal","space":"metal-internal"}}, + {"id":5,"cidr":"10.12.16.0/22","gateway_ip":null,"vlan":{"id":5191,"fabric":"vr1-dc0-data-tenant","space":"data-tenant"}}, + {"id":6,"cidr":"10.12.32.0/22","gateway_ip":null,"vlan":{"id":5192,"fabric":"vr1-dc0-storage","space":"storage"}}, + {"id":7,"cidr":"10.12.36.0/22","gateway_ip":null,"vlan":{"id":5193,"fabric":"vr1-dc0-replication","space":"replication"}}]' ;; + *) + echo '[{"id":1,"cidr":"10.12.8.0/22","gateway_ip":null,"vlan":{"id":5001,"fabric":"fabric-0","space":"metal-admin"}}, + {"id":3,"cidr":"10.12.4.0/22","gateway_ip":"10.12.4.1","vlan":{"id":5189,"fabric":"vr1-dc0-provider-public","space":"provider-public"}}, + {"id":4,"cidr":"10.12.12.0/22","gateway_ip":null,"vlan":{"id":5190,"fabric":"vr1-dc0-metal-internal","space":"metal-internal"}}, + {"id":5,"cidr":"10.12.16.0/22","gateway_ip":null,"vlan":{"id":5191,"fabric":"vr1-dc0-data-tenant","space":"data-tenant"}}, + {"id":6,"cidr":"10.12.32.0/22","gateway_ip":null,"vlan":{"id":5192,"fabric":"vr1-dc0-storage","space":"storage"}}, + {"id":7,"cidr":"10.12.36.0/22","gateway_ip":null,"vlan":{"id":5193,"fabric":"vr1-dc0-replication","space":"replication"}}]' ;; + esac +} + +case "$sub" in + rack-controllers) case "${STUB_RACK:-hot-kid}" in + none) echo '[]' ;; + *) printf '[{"hostname":"%s"}]\n' "${STUB_RACK:-hot-kid}" ;; + esac ;; + fabrics) case "${1:-read}" in read) emit_fabrics ;; create) echo '{"id":99}' ;; esac ;; + spaces) case "${1:-read}" in read) emit_spaces ;; create) echo '{"id":99}' ;; esac ;; + subnets) case "${1:-read}" in read) emit_subnets ;; create) echo '{"id":99}' ;; esac ;; + subnet) case "${1:-}" in + read) case "$S" in + empty) echo '{"dns_servers":[],"allow_dns":true}' ;; + *) echo '{"dns_servers":["10.12.8.3"],"allow_dns":false}' ;; + esac ;; + reserved-ip-ranges) case "$S" in + empty) echo '[]' ;; + *) echo '[{"purpose":["dynamic"]}]' ;; + esac ;; + update) echo '{}' ;; + esac ;; + vlans) echo '[{"id":5189,"vid":0},{"id":5190,"vid":0},{"id":5191,"vid":0},{"id":5192,"vid":0},{"id":5193,"vid":0},{"id":5001,"vid":0},{"id":5002,"vid":0}]' ;; + vlan) echo '{}' ;; + tags) case "${1:-read}" in + read) case "$S" in + empty) echo '[{"name":"virtual"}]' ;; + *) echo '[{"name":"virtual"},{"name":"openstack-vr1-dc0"}]' ;; + esac ;; + create) echo '{}' ;; + esac ;; + *) echo '[]' ;; +esac +STUB +chmod +x "$TMP/bin/maas" +export PATH="$TMP/bin:$PATH" + +run(){ OUT="$(bash "$SCRIPT" "$@" 2>&1)"; RC=$?; } + +# --- argument validation --------------------------------------------------- +run; chk "no args -> 2" "$RC" 2 +run check; chk "action only -> 2" "$RC" 2 +run check vr1-dc0; chk "no --profile -> 2" "$RC" 2 +case "$OUT" in *"--profile is REQUIRED"*) ok "missing profile names the OFFICE1 hazard" ;; *) no "missing profile names the hazard (got '$OUT')" ;; esac +case "$OUT" in *OFFICE1*) ok "the refusal explains WHY there is no default" ;; *) no "the refusal explains why (got '$OUT')" ;; esac +run check vr1-dc0 --profile vr1-dc0-region; chk "no --expect-rack -> 2" "$RC" 2 +run bogus vr1-dc0 --profile p --expect-rack r; chk "unknown action -> 2" "$RC" 2 +run check vr1-dc0 --profile p --expect-rack r --wat; chk "unknown option -> 2" "$RC" 2 + +# --- the REGION GATE ------------------------------------------------------- +OUT="$(STUB_STATE=complete STUB_RACK=voffice1 bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "wrong region REFUSES -> 2" "$RC" 2 +case "$OUT" in *"nothing was read or written"*) ok "wrong-region refusal states nothing happened" ;; *) no "wrong-region refusal states nothing happened (got '$OUT')" ;; esac +OUT="$(STUB_STATE=complete STUB_RACK=none bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "unidentifiable region (no racks) REFUSES -> 2" "$RC" 2 +# and apply must be gated the same way -- a gate on check only would be useless +OUT="$(STUB_STATE=empty STUB_RACK=voffice1 bash "$SCRIPT" apply vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid --commit 2>&1)"; RC=$? +chk "APPLY --commit against the wrong region REFUSES -> 2" "$RC" 2 +case "$OUT" in *APPLY*) no "wrong-region apply printed APPLY lines before refusing" ;; *) ok "wrong-region apply wrote nothing" ;; esac + +# --- check must FAIL on every partial state -------------------------------- +OUT="$(STUB_STATE=empty bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "freshly-inited region FAILS check -> 1" "$RC" 1 +case "$OUT" in *"[FAIL] fabric 'vr1-dc0-provider-public' exists"*) ok "empty: missing named fabric FAILS" ;; *) no "empty: missing named fabric FAILS" ;; esac +case "$OUT" in *"[FAIL] space 'storage' exists"*) ok "empty: missing space FAILS" ;; *) no "empty: missing space FAILS" ;; esac +case "$OUT" in *"[FAIL] subnet 10.12.32.0/22 (storage) exists"*) ok "empty: missing plane subnet FAILS" ;; *) no "empty: missing plane subnet FAILS" ;; esac +case "$OUT" in *"[FAIL] tag 'openstack-vr1-dc0' exists"*) ok "empty: missing site tag FAILS" ;; *) no "empty: missing site tag FAILS" ;; esac +case "$OUT" in *"[FAIL] metal-admin carries node-facing dns_servers"*) ok "empty: missing D-131 dns_servers FAILS" ;; *) no "empty: missing dns_servers FAILS" ;; esac + +OUT="$(STUB_STATE=wrongfab bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "subnet on the WRONG fabric FAILS check -> 1" "$RC" 1 +case "$OUT" in *"[FAIL] subnet 10.12.4.0/22 is on fabric 'vr1-dc0-provider-public'"*) ok "wrongfab: names the fabric mismatch" ;; *) no "wrongfab: names the fabric mismatch (got '$OUT')" ;; esac + +OUT="$(STUB_STATE=nospace bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "VLAN not bound to its space FAILS check -> 1" "$RC" 1 +case "$OUT" in *"[FAIL] subnet 10.12.4.0/22 VLAN is bound to space 'provider-public'"*) ok "nospace: names the unbound space" ;; *) no "nospace: names the unbound space (got '$OUT')" ;; esac + +# --- the complete state passes --------------------------------------------- +OUT="$(STUB_STATE=complete bash "$SCRIPT" check vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "complete region PASSES check -> 0" "$RC" 0 +case "$OUT" in *"0 failed"*) ok "complete region reports 0 failed" ;; *) no "complete region reports 0 failed (got '$OUT')" ;; esac +# metal-admin must NOT be required to sit on a named fabric -- that is deliberate +case "$OUT" in *"metal-admin 10.12.8.0/22 is on MAAS's own fabric 'fabric-0' (by design)"*) ok "metal-admin keeps MAAS's own fabric by design" ;; *) no "metal-admin keeps MAAS's own fabric (got '$OUT')" ;; esac + +# --- dry run writes nothing and says so ------------------------------------ +OUT="$(STUB_STATE=empty bash "$SCRIPT" apply vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +chk "apply without --commit exits 0" "$RC" 0 +case "$OUT" in *"DRY RUN complete"*) ok "dry run says it wrote nothing" ;; *) no "dry run says it wrote nothing (got '$OUT')" ;; esac +case "$OUT" in *"DRY fabrics create name=vr1-dc0-provider-public"*) ok "dry run plans the named fabrics" ;; *) no "dry run plans the named fabrics (got '$OUT')" ;; esac +case "$OUT" in *"DRY spaces create name=metal-admin"*) ok "dry run plans the spaces" ;; *) no "dry run plans the spaces" ;; esac +case "$OUT" in *APPLY*) no "dry run emitted APPLY lines" ;; *) ok "dry run emitted no APPLY lines" ;; esac + +# a subnet on the wrong fabric must be planned as a MOVE, never a duplicate create +OUT="$(STUB_STATE=wrongfab bash "$SCRIPT" apply vr1-dc0 --profile vr1-dc0-region --expect-rack hot-kid 2>&1)"; RC=$? +case "$OUT" in *"MOVE 10.12.4.0/22 off 'fabric-1' onto 'vr1-dc0-provider-public'"*) ok "wrongfab is planned as a MOVE" ;; *) no "wrongfab is planned as a MOVE (got '$OUT')" ;; esac +case "$OUT" in *"subnets create cidr=10.12.4.0/22"*) no "wrongfab wrongly planned a duplicate subnet create" ;; *) ok "wrongfab did not plan a duplicate create" ;; esac + +# --- fabric naming is DERIVED from the site token, never typed ------------- +OUT="$(STUB_STATE=empty bash "$SCRIPT" apply vr1-dc1 --profile vr1-dc1-region --expect-rack hot-kid 2>&1)"; RC=$? +case "$OUT" in *"vr1-dc1-provider-public"*) ok "dc1 derives vr1-dc1-* fabric names" ;; *) no "dc1 derives vr1-dc1-* fabric names (got '$OUT')" ;; esac +case "$OUT" in *"vr1-dc0-"*) no "dc1 invocation leaked a dc0 fabric name" ;; *) ok "dc1 invocation carries no dc0 fabric name" ;; esac + +echo +echo "dc-region-topology: $P passed, $F failed" +[ "$F" -eq 0 ] || exit 1