#!/usr/bin/env bash
# scripts/maas-node-power.sh [--commit] <power-address> [<hostname-prefix>]
#
# 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 <domain> 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] <power-address> [<domain-name-prefix>]" >&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