diff --git a/scripts/maas-node-power.sh b/scripts/maas-node-power.sh new file mode 100755 index 0000000..18bdf20 --- /dev/null +++ b/scripts/maas-node-power.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# scripts/maas-node-power.sh [--commit] [] +# +# e.g. bash scripts/maas-node-power.sh qemu+ssh://jessea123@172.31.0.2/system vr1-dc0 +# bash scripts/maas-node-power.sh --commit qemu+ssh://jessea123@172.31.0.2/system vr1-dc0 +# +# Gives MAAS POWER CONTROL over VMs it has already ENLISTED, by setting +# `power_type=virsh` + power_address/power_id per machine. Domains are matched to +# MAAS machines BY MAC ADDRESS -- never by name, because MAAS assigns its own +# random hostnames at enlistment (a node is "ace-oyster", not "vr1-dc0-control-01"). +# +# WHY PER-MACHINE AND NOT A VM HOST (pod). Operator-ruled 2026-07-20 after both +# alternatives were measured on the live vr1-dc0 substrate: +# - A MAAS virsh POD is INCOMPATIBLE with this repo's node VMs. MAAS inventories +# pod storage with `virsh domblkinfo vda`, which fails on disks +# declared as pool+volume references -- the shape modules/node-vm produces: +# internal error: missing storage backend for 'volume' storage +# REPRODUCED LOCALLY on the rack with an ACTIVE, autostart pool, so it is +# libvirtd, not a snap/SSH artifact. Supporting pods would mean converting +# node-vm to file-path disks and re-applying every node domain. +# - The pod is UNNECESSARY anyway: its D-103 role was DISCOVERY, and PXE +# enlistment already discovered these machines. Only POWER was missing. +# - Per-machine power is also the ROOSEVELT SHAPE: bare-metal nodes each carry +# their own IPMI/BMC parameters. The pod was a VR1-only convenience, so this +# is the smaller Roosevelt delta as well as the smaller change. +# See D-103/D-123 amendments and the 2026-07-20 session changelog. +# +# CREDENTIAL NOTE: MAAS dials the power address from whichever controller it +# chooses -- MEASURED to be the REGION, not the rack that owns the hardware. The +# SSH key must therefore exist in the REGION's MAAS snap, not only the rack's. +# +# DRY BY DEFAULT -- without --commit it prints the intended mapping and changes +# nothing. Run where a `maas admin` CLI profile exists (the Office1 region). +# +# Exit: 0 ok (or dry run) | 1 usage/precondition | 2 a machine update failed. +set -uo pipefail + +COMMIT=0 +if [ "${1:-}" = "--commit" ]; then COMMIT=1; shift; fi + +POWER_ADDRESS="${1:-}" +PREFIX="${2:-vr1-dc0}" + +usage() { + echo "usage: maas-node-power.sh [--commit] []" >&2 + echo " e.g. maas-node-power.sh --commit qemu+ssh://jessea123@172.31.0.2/system vr1-dc0" >&2 + echo " default: DRY RUN -- prints the MAC-matched mapping, changes nothing." >&2 + exit 1 +} +[ -n "$POWER_ADDRESS" ] || usage +case "$POWER_ADDRESS" in + qemu*://*|qemu:///*) : ;; + *) echo "FAIL: '$POWER_ADDRESS' does not look like a libvirt URI" >&2; exit 1 ;; +esac + +command -v maas >/dev/null 2>&1 || { echo "FAIL: no maas CLI on PATH" >&2; exit 1; } +command -v virsh >/dev/null 2>&1 || { echo "FAIL: no virsh on PATH" >&2; exit 1; } +: "${MAAS_PROFILE:=admin}" +: "${VIRSH_URI:=$POWER_ADDRESS}" + +# --- domain MAC -> domain name, from libvirt (the hardware's own view) --- +DOMMAP="$(mktemp)"; trap 'rm -f "$DOMMAP"' EXIT +for d in $(virsh -c "$VIRSH_URI" list --all --name 2>/dev/null | grep "^${PREFIX}-" || true); do + # every NIC: a machine may PXE from any of them, so index them all + virsh -c "$VIRSH_URI" domiflist "$d" 2>/dev/null \ + | awk -v dom="$d" 'NF>=5 && $5 ~ /:/ {print tolower($5)" "dom}' +done > "$DOMMAP" +[ -s "$DOMMAP" ] || { echo "FAIL: no domains matching '${PREFIX}-*' at $VIRSH_URI" >&2; exit 1; } +echo "== domains found: $(cut -d' ' -f2 "$DOMMAP" | sort -u | wc -l) (${PREFIX}-*) ==" + +# --- MAAS machines -> their MACs; match, then set power --- +MACHINES="$(maas "$MAAS_PROFILE" machines read 2>/dev/null)" \ + || { echo "FAIL: 'maas $MAAS_PROFILE machines read' failed -- is the CLI logged in?" >&2; exit 1; } + +MAPPING="$(printf '%s' "$MACHINES" | python3 -c ' +import json,sys +machines=json.load(sys.stdin) +dommap={} +for line in open(sys.argv[1]): + mac,dom=line.split() + dommap[mac]=dom +for m in machines: + for i in m.get("interface_set",[]): + mac=(i.get("mac_address") or "").lower() + if mac in dommap: + print(m["system_id"], m["hostname"], dommap[mac], m.get("power_type") or "-") + break +' "$DOMMAP")" + +[ -n "$MAPPING" ] || { echo "FAIL: no MAAS machine MAC matched any ${PREFIX}-* domain" >&2; exit 1; } + +printf '%-10s %-22s %-24s %s\n' SYSTEM_ID MAAS_HOSTNAME DOMAIN CURRENT_POWER +printf '%s\n' "$MAPPING" | while read -r sid host dom cur; do + printf '%-10s %-22s %-24s %s\n' "$sid" "$host" "$dom" "$cur" +done + +if [ "$COMMIT" != "1" ]; then + echo + echo "DRY RUN -- nothing written. Re-run with --commit to set power on the machines above." + exit 0 +fi + +echo +echo "== setting power_type=virsh ==" +rc=0 +printf '%s\n' "$MAPPING" | while read -r sid host dom cur; do + if maas "$MAAS_PROFILE" machine update "$sid" \ + power_type=virsh \ + "power_parameters_power_address=$POWER_ADDRESS" \ + "power_parameters_power_id=$dom" >/dev/null 2>&1; then + # GROUND TRUTH: MAAS must be able to actually TALK to the power driver. + # A saved parameter is not power control; querying the state proves it. + st="$(maas "$MAAS_PROFILE" machine query-power-state "$sid" 2>/dev/null \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("state","?"))' 2>/dev/null)" + case "$st" in + on|off) echo " [ok] $host -> $dom (power state: $st)" ;; + *) echo " [--] $host -> $dom SET but power query returned '${st:-error}'"; rc=2 ;; + esac + else + echo " [--] $host -> $dom UPDATE FAILED"; rc=2 + fi +done +exit $rc diff --git a/tests/maas-node-power/run-tests.sh b/tests/maas-node-power/run-tests.sh new file mode 100755 index 0000000..bea8813 --- /dev/null +++ b/tests/maas-node-power/run-tests.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# tests/maas-node-power/run-tests.sh +# +# Harness for scripts/maas-node-power.sh. OFFLINE -- stubs `maas` and `virsh` on +# PATH; contacts no MAAS and no libvirt. +# +# Pinned properties, each load-bearing: +# 1. DRY BY DEFAULT (--commit required to write). +# 2. Matching is BY MAC, never by name -- MAAS renames machines at enlistment +# ("ace-oyster"), so a name-based match would silently map nothing (or worse, +# the wrong node) on a real fleet. +# 3. Ground truth is a power QUERY, not a saved parameter: a stored power_id +# proves nothing about whether MAAS can actually reach the hypervisor. +# 4. Preconditions fail loud (bad URI, missing tools, no matches). +# 5. The pod-vs-per-machine reasoning stays documented -- it is a measured +# refutation of D-103/D-123's stated mechanism and must not be lost. +# ASCII + LF. +set -uo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +S="$(cd "$HERE/../../scripts" && pwd)/maas-node-power.sh" +pass=0; fail=0 +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo " FAIL: $1"; } + +[ -f "$S" ] && ok || bad "maas-node-power.sh missing" +bash -n "$S" 2>/dev/null && ok || bad "does not parse" + +# --- stub environment ------------------------------------------------------- +STUB="$(mktemp -d)"; trap 'rm -rf "$STUB"' EXIT +cat > "$STUB/virsh" <<'EOF' +#!/usr/bin/env bash +# stub libvirt: two domains, distinct MACs; one extra domain outside the prefix. +case "$*" in + *"list --all --name"*) printf 'vr1-dc0-control-01\nvr1-dc0-compute-01\nother-vm\n' ;; + *"domiflist vr1-dc0-control-01"*) printf 'Interface Type Source Model MAC\n---\nvnet0 network metal-admin virtio 52:54:00:aa:aa:aa\n' ;; + *"domiflist vr1-dc0-compute-01"*) printf 'Interface Type Source Model MAC\n---\nvnet1 network metal-admin virtio 52:54:00:BB:BB:BB\n' ;; + *) printf '' ;; +esac +EOF +cat > "$STUB/maas" <<'EOF' +#!/usr/bin/env bash +# stub MAAS: machines with MAAS-assigned random hostnames (NOT domain names). +case "$*" in + *"machines read"*) + cat <<'JSON' +[{"system_id":"aaa111","hostname":"ace-oyster","power_type":"","interface_set":[{"mac_address":"52:54:00:aa:aa:aa"}]}, + {"system_id":"bbb222","hostname":"cuddly-bengal","power_type":"","interface_set":[{"mac_address":"52:54:00:bb:bb:bb"}]}, + {"system_id":"zzz999","hostname":"office1-netbox","power_type":"lxd","interface_set":[{"mac_address":"00:16:3e:00:00:01"}]}] +JSON + ;; + *"machine update"*) echo '{"ok":true}'; echo "$*" >> "$TESTLOG" ;; + *"query-power-state"*) echo '{"state":"off"}' ;; + *) printf '' ;; +esac +EOF +chmod +x "$STUB/virsh" "$STUB/maas" +export TESTLOG="$STUB/updates.log"; : > "$TESTLOG" +PATH="$STUB:$PATH"; export PATH + +URI="qemu+ssh://jessea123@172.31.0.2/system" + +# --- 1. usage / precondition failures --- +"$S" >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "no args must fail" +"$S" not-a-uri >/dev/null 2>&1; [ $? -ne 0 ] && ok || bad "a non-libvirt URI must be rejected" +out="$("$S" not-a-uri 2>&1)" +printf '%s' "$out" | grep -q "does not look like a libvirt URI" && ok || bad "bad URI message missing" + +# --- 2. DRY BY DEFAULT --- +out="$("$S" "$URI" vr1-dc0 2>&1)" +printf '%s' "$out" | grep -q "DRY RUN" && ok || bad "no dry-run announcement" +[ -s "$TESTLOG" ] && bad "DRY RUN wrote to MAAS (machine update called)" || ok + +# --- 3. MATCHED BY MAC, not by name. MAAS hostnames are random at enlistment, +# so the mapping must pair ace-oyster <-> vr1-dc0-control-01. +printf '%s' "$out" | grep -q "ace-oyster.*vr1-dc0-control-01" && ok || bad "MAC match ace-oyster -> control-01 missing" +printf '%s' "$out" | grep -q "cuddly-bengal.*vr1-dc0-compute-01" && ok || bad "MAC match is case-sensitive (stub MAC was upper-case) or missing" +# a non-fleet machine must NOT be touched +printf '%s' "$out" | grep -q "office1-netbox" && bad "an unrelated MAAS machine was included" || ok + +# --- 4. --commit writes, and every write is power-QUERY verified --- +: > "$TESTLOG" +out="$("$S" --commit "$URI" vr1-dc0 2>&1)" +grep -q "machine update aaa111" "$TESTLOG" && ok || bad "--commit did not update the first machine" +grep -q "machine update bbb222" "$TESTLOG" && ok || bad "--commit did not update the second machine" +grep -q "power_parameters_power_id=vr1-dc0-control-01" "$TESTLOG" && ok || bad "power_id not set to the DOMAIN name" +grep -q "power_parameters_power_address=$URI" "$TESTLOG" && ok || bad "power_address not passed through" +grep -q "power_type=virsh" "$TESTLOG" && ok || bad "power_type=virsh not set" +printf '%s' "$out" | grep -q "power state: off" && ok || bad "no ground-truth power query after the update" +grep -q "office1-netbox" "$TESTLOG" && bad "an unrelated machine was updated" || ok + +# --- 5. no domains matching the prefix -> fail loud, do not silently no-op --- +out="$("$S" --commit "$URI" no-such-prefix 2>&1)"; rc=$? +[ "$rc" -ne 0 ] && ok || bad "an empty domain match must be nonzero" +printf '%s' "$out" | grep -q "no domains matching" && ok || bad "empty-match message missing" + +# --- 6. the measured reasoning must stay documented --- +grep -q "domblkinfo" "$S" && ok || bad "the pod-incompatibility evidence (domblkinfo) is no longer cited" +grep -qi "missing storage backend" "$S" && ok || bad "the verbatim libvirt error is gone" +grep -qi "roosevelt" "$S" && ok || bad "the Roosevelt per-node-power rationale is gone" +grep -qi "region" "$S" && ok || bad "the credential-lives-where-MAAS-dials-from note is gone" +grep -q "D-103" "$S" && ok || bad "no reference to the decision this amends" + +echo +total=$((pass+fail)) +if [ "$fail" -eq 0 ]; then echo "maas-node-power: $pass/$total PASS"; exit 0; fi +echo "maas-node-power: $fail/$total FAIL"; exit 1