#!/usr/bin/env bash # Nightly / workflow_dispatch helper: create a MicroNetwork, boot one M2 # (guest MicroVM) for the given template, ping it, then stop+delete. # # When FIRECRAB_QA_FIRST_REFERENCE=1, the VM lifecycle also drives the CLI # (create/list/start/console/stop/delete) and exercises the API-only storage # assignment and busy-network guards. Later references intentionally retain # the API flow below so one OCI run does not multiply those control-plane rows. # # Usage: scripts/ci-m2-guest-boot.sh # e.g. scripts/ci-m2-guest-boot.sh alpine-3.24.1 # # Prerequisites: firecrab-api listening on :5523, a registered template, # KVM available, micro_network create works (explicit networks only). set -euo pipefail TEMPLATE=${1:?template alias required (e.g. alpine-3.24.1)} API=${FIRECRAB_API:-http://127.0.0.1:5523} root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) # Unique /24 per template hash so parallel matrix jobs on one host would not # collide if ever co-located (each CI job is its own runner today). SUBNET_THIRD=$(printf '%s' "$TEMPLATE" | cksum | awk '{print ($1 % 200) + 20}') # Keep every generated subnet in RFC1918 space. 172..0.0 sampled # public prefixes too, outside 172.16/12, and is unsuitable for NAT-only labs. SUBNET="10.231.${SUBNET_THIRD}.0/24" NAME_NET="boot-$(echo "$TEMPLATE" | tr -c 'a-zA-Z0-9' '-')" NAME_VM="m2-$(echo "$TEMPLATE" | tr -c 'a-zA-Z0-9' '-')" FIRST_REFERENCE=${FIRECRAB_QA_FIRST_REFERENCE:-0} CLI_BIN=${FIRECRAB_QA_CLI:-} if [ "$FIRST_REFERENCE" = 1 ]; then if [ -z "$CLI_BIN" ]; then CLI_BIN=$(command -v firecrab || true) fi [ -n "$CLI_BIN" ] || { echo "firecrab CLI is required for the first OCI reference" >&2 exit 1 } fi cli() { "$CLI_BIN" --api "$API" "$@" } echo "M2 boot: template=$TEMPLATE subnet=$SUBNET api=$API" NET= VM= DELETED_VM= STORAGE= STORAGE_PATH= CONSOLE_ERROR= cleanup_boot() { local result=$? if [ "$result" -ne 0 ] && [ -n "${VM:-}" ]; then # Preserve the guest failure before stop/delete removes its serial log. curl -sS --max-time 15 "$API/api/vms/$VM/log" | python3 -c ' import json, sys try: log = json.load(sys.stdin).get("consoleLog") or "" sys.stderr.write("Guest console tail:\n" + log[-8000:] + "\n") except (ValueError, AttributeError): pass ' || true fi # This trap runs for both the happy path and a failed lifecycle assertion. # Keep every cleanup operation best-effort: the original failure is more # useful than a second error from a partially torn-down VM/network. if [ -n "${VM:-}" ]; then curl -sS -o /dev/null --max-time 120 -X POST "$API/api/vms/$VM/stop" || true curl -sS -o /dev/null --max-time 30 -X DELETE "$API/api/vms/$VM" || true fi if [ -n "${STORAGE:-}" ]; then curl -sS -o /dev/null --max-time 30 -X DELETE "$API/api/micro-storages/$STORAGE" || true fi if [ -n "${NET:-}" ]; then curl -sS -o /dev/null --max-time 15 -X DELETE "$API/api/micro-networks/$NET" || true fi if [ -n "${STORAGE_PATH:-}" ] && [ "$(uname -s)" = Linux ]; then rmdir "$STORAGE_PATH" 2>/dev/null || true fi if [ -n "${CONSOLE_ERROR:-}" ]; then rm -f "$CONSOLE_ERROR" fi } trap cleanup_boot EXIT NET=$(curl -fsS -X POST "$API/api/micro-networks" \ -H 'content-type: application/json' \ -d "{\"name\":\"$NAME_NET\",\"subnetCidr\":\"$SUBNET\",\"internetEnabled\":true}" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') echo "micro_network_id=$NET" if [ "$(uname -s)" = Linux ]; then ip -br link show type bridge | grep -Eq '^mnb' || { echo "expected mnb* bridge after MicroNetwork create" >&2 exit 1 } fi if [ "$FIRST_REFERENCE" = 1 ]; then echo "creating first VM through firecrab CLI" cli vm list --json >/dev/null || { echo "firecrab vm list failed before create" >&2 exit 1 } VM=$(cli vm create \ --name "$NAME_VM" \ --template "$TEMPLATE" \ --network "$NET" \ --cpu 1 \ --ram "${FIRECRAB_QA_RAM:-1024}" \ --disk-gb "${FIRECRAB_QA_DISK_GB:-2}" \ --json \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') echo "vm_id=$VM" cli_vms=$(cli vm list --json) printf '%s' "$cli_vms" | python3 -c ' import json, sys if not any(row.get("id") == sys.argv[1] for row in json.load(sys.stdin)): raise SystemExit("CLI VM list did not show the created VM") ' "$VM" || { echo "firecrab vm list did not show the created VM" >&2 exit 1 } else VM=$(curl -fsS -X POST "$API/api/vms" \ -H 'content-type: application/json' \ -d "{\"name\":\"$NAME_VM\",\"template\":\"$TEMPLATE\",\"cpu\":1,\"ram\":${FIRECRAB_QA_RAM:-1024},\"diskGb\":${FIRECRAB_QA_DISK_GB:-2},\"microNetworkId\":\"$NET\"}" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') echo "vm_id=$VM" fi if [ "$FIRST_REFERENCE" = 1 ]; then # V2 is intentionally API-backed, not inferred from CLI output. vm_list=$(curl -fsS --max-time 30 "$API/api/vms") printf '%s' "$vm_list" | python3 -c ' import json, sys rows = json.load(sys.stdin) want = sys.argv[1] matches = [row for row in rows if row.get("id") == want] if len(matches) != 1 or matches[0].get("state") != "created": raise SystemExit("API VM list did not show exactly one created VM") ' "$VM" || { echo "V2 API VM list did not show the CLI-created VM" >&2 exit 1 } vm_detail=$(curl -fsS --max-time 30 "$API/api/vms/$VM") printf '%s' "$vm_detail" | python3 -c ' import json, sys d = json.load(sys.stdin) if d.get("id") != sys.argv[1] or d.get("state") != "created": raise SystemExit("API VM detail is not the created CLI VM") ' "$VM" || { echo "V2 API VM detail did not show the CLI-created VM" >&2 exit 1 } echo "PASS V2/$TEMPLATE (API list/detail)" # Register a disposable storage root from the API. The path is resolved by # the API host, which keeps this valid through the macOS localhost tunnel. # Keep it short because Firecracker's nested runtime socket also lives under # this root and Unix-domain socket paths have a small platform limit. storage_suffix=$(printf '%s' "$TEMPLATE" | tr -c 'a-zA-Z0-9' '-')-$$ STORAGE_PATH=${FIRECRAB_QA_STORAGE_PATH:-/tmp/fcqa-${VM%%-*}} storage_json=$(curl -fsS --max-time 30 -X POST "$API/api/micro-storages" \ -H 'content-type: application/json' \ -d "{\"name\":\"qa-m2-storage-$storage_suffix\",\"path\":\"$STORAGE_PATH\"}") STORAGE=$(printf '%s' "$storage_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') [ -n "$STORAGE" ] || { echo "V6 MicroStorage response did not contain an id" >&2 exit 1 } storage_vm=$(curl -fsS --max-time 30 -X PUT "$API/api/vms/$VM/storage" \ -H 'content-type: application/json' \ -d "{\"storageRoot\":\"$STORAGE\"}") printf '%s' "$storage_vm" | python3 -c ' import json, sys d = json.load(sys.stdin) if d.get("id") != sys.argv[1] or d.get("storageRoot") != sys.argv[2]: raise SystemExit("storage assignment response did not contain the requested root") ' "$VM" "$STORAGE" || { echo "V6 storageRoot was not updated" >&2 exit 1 } echo "PASS V6/$TEMPLATE storageRoot=$STORAGE" # The VM has a lease as soon as it is created, so the network must reject a # delete even before the guest is started. network_delete_code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ -X DELETE "$API/api/micro-networks/$NET") [ "$network_delete_code" = 409 ] || { echo "N6 expected network DELETE 409, got $network_delete_code" >&2 exit 1 } echo "PASS N6/$TEMPLATE (network DELETE while VM attached -> 409)" cli vm list --json >/dev/null || { echo "firecrab vm list failed after create" >&2 exit 1 } cli vm start "$VM" --json >/dev/null || { echo "firecrab vm start failed" >&2 exit 1 } else curl -fsS -X POST "$API/api/vms/$VM/start" --max-time 300 >/dev/null fi # Poll until running (start may return before guest is ready in some paths). for _ in $(seq 1 60); do state=$(curl -fsS "$API/api/vms/$VM" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])') echo "state=$state" case "$state" in running) break ;; error|stopped) echo "guest entered terminal state $state before running" >&2 curl -fsS "$API/api/vms/$VM" || true exit 1 ;; esac sleep 5 done test "$state" = running ipv4=$(curl -fsS "$API/api/vms/$VM" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("ipv4") or "")') test -n "$ipv4" echo "ipv4=$ipv4" if [ "$(uname -s)" = Linux ]; then ping -c 3 -W 5 "$ipv4" else # Nested Firecracker lives inside the management guest. The Mac/Windows # runner cannot ping 172.31.x.x; the serial marker is the reachability check. curl -fsS "$API/api/vms/$VM/log" | python3 -c ' import json, sys log = json.load(sys.stdin).get("consoleLog") or "" if "FIRECRAB_NETWORK_READY" not in log: sys.stderr.write("missing FIRECRAB_NETWORK_READY in console log\n") sys.exit(1) ' fi if [ "$FIRST_REFERENCE" = 1 ]; then # stdin is a pipe, so the CLI does not enter raw terminal mode. Ctrl+] is # the documented detach byte and proves the WebSocket upgrade completed. CONSOLE_ERROR=$(mktemp) if ! printf '\035' | cli vm console "$VM" >/dev/null 2>"$CONSOLE_ERROR"; then cat "$CONSOLE_ERROR" >&2 rm -f "$CONSOLE_ERROR" CONSOLE_ERROR= echo "V9/C2 CLI console attach failed" >&2 exit 1 fi rm -f "$CONSOLE_ERROR" CONSOLE_ERROR= echo "PASS V9/$TEMPLATE (console WebSocket)" echo "PASS C2/$TEMPLATE (CLI console attach/detach)" # V15/C2b (#303): `exit` ends the session. stdin stays open for the whole # wait, so only the API's session_ended close can end the CLI; an EOF or # Ctrl+] detach would print "console detached" instead. CONSOLE_ERROR=$(mktemp) if ! timeout 90 "$CLI_BIN" --api "$API" vm console "$VM" >/dev/null 2>"$CONSOLE_ERROR" \ < <(printf '\r'; sleep 2; printf 'exit\r'; sleep 120); then cat "$CONSOLE_ERROR" >&2 rm -f "$CONSOLE_ERROR" CONSOLE_ERROR= echo "V15/C2b CLI console did not end on guest exit" >&2 exit 1 fi if ! grep -q 'guest session ended' "$CONSOLE_ERROR"; then cat "$CONSOLE_ERROR" >&2 rm -f "$CONSOLE_ERROR" CONSOLE_ERROR= echo "V15/C2b CLI console ended without the session_ended close" >&2 exit 1 fi rm -f "$CONSOLE_ERROR" CONSOLE_ERROR= state=$(curl -fsS "$API/api/vms/$VM" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])') [ "$state" = running ] || { echo "V15 VM left running state after guest exit (state=$state)" >&2 exit 1 } echo "PASS V15/$TEMPLATE (guest exit closes the console with session_ended)" echo "PASS C2b/$TEMPLATE (CLI console ends on guest exit)" running_delete_code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ -X DELETE "$API/api/vms/$VM") [ "$running_delete_code" = 409 ] || { echo "V12 expected VM DELETE 409 while running, got $running_delete_code" >&2 exit 1 } echo "PASS V12/$TEMPLATE (running VM DELETE -> 409)" fi FIRECRAB_API=$API "$root/scripts/ci-qa-ssh.sh" "$VM" "$ipv4" if [ "$FIRST_REFERENCE" = 1 ]; then cli vm stop "$VM" --json >/dev/null || { echo "firecrab vm stop failed" >&2 exit 1 } for _ in $(seq 1 30); do state=$(curl -fsS "$API/api/vms/$VM" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])') [ "$state" = stopped ] && break sleep 2 done [ "$state" = stopped ] || { echo "VM did not reach stopped after firecrab vm stop (state=$state)" >&2 exit 1 } cli vm delete "$VM" || { echo "firecrab vm delete failed" >&2 exit 1 } else curl -fsS -X POST "$API/api/vms/$VM/stop" --max-time 120 >/dev/null for _ in $(seq 1 30); do state=$(curl -fsS "$API/api/vms/$VM" | python3 -c 'import json,sys; print(json.load(sys.stdin)["state"])') [ "$state" = stopped ] && break sleep 2 done [ "$state" = stopped ] || { echo "VM did not reach stopped after API stop (state=$state)" >&2 exit 1 } delete_code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 \ -X DELETE "$API/api/vms/$VM") [ "$delete_code" = 200 ] || [ "$delete_code" = 204 ] || { echo "VM DELETE returned $delete_code" >&2 exit 1 } fi DELETED_VM=$VM VM= if [ "$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "$API/api/vms/$DELETED_VM")" != 404 ]; then echo "V13 expected GET deleted VM 404" >&2 exit 1 fi remaining_vms=$(curl -fsS --max-time 30 "$API/api/vms") printf '%s' "$remaining_vms" | python3 -c ' import json, sys if any(row.get("id") == sys.argv[1] for row in json.load(sys.stdin)): raise SystemExit("deleted VM remains in API list") ' "$DELETED_VM" || { echo "V13 deleted VM remains in API list" >&2 exit 1 } if [ "$FIRST_REFERENCE" = 1 ]; then cli_vms=$(cli vm list --json) printf '%s' "$cli_vms" | python3 -c ' import json, sys if any(row.get("id") == sys.argv[1] for row in json.load(sys.stdin)): raise SystemExit("CLI VM list still contains the deleted VM") ' "$DELETED_VM" || { echo "firecrab vm list failed after delete" >&2 exit 1 } echo "PASS C1/$TEMPLATE (CLI list/create/start/stop/delete)" fi echo "PASS V13/$TEMPLATE (delete + API 404/list absence)" if [ -n "${STORAGE:-}" ]; then curl -fsS -X DELETE "$API/api/micro-storages/$STORAGE" >/dev/null STORAGE= fi curl -fsS -X DELETE "$API/api/micro-networks/$NET" >/dev/null NET= echo "M2 boot ok: template=$TEMPLATE"