Ctrl K
ComunidadGuías…

scripts/ci-qa-guest.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# QA guest boot from OCI images (public-docs/development/qa.md I5, I6, N6, V1, V2, V6,
# V7, V8, V9, V11, V12, V13, P1-P7, C1, C2, C4, C6, X5, X7).
# P1-P6 and C6 run only on the first OCI reference, after guest boot and
# before that reference's image delete. X7 runs at the end of this script.
# Default references: alpine, ubuntu, fedora from Docker Hub.
# GitHub Ubuntu: chmod 666 /dev/kvm first (nested virt is not guaranteed).
# Prerequisites: firecrab-api on :5523, fakeroot, outbound registry, KVM rw.
# FIRECRAB_QA_POOL_RESTART=1 additionally restarts the API on the disposable
# QA host and checks that ready members, leased VMs, and retry keys survive.
set -euo pipefail

API=${FIRECRAB_API:-http://127.0.0.1:5523}
API=${API%/}
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
if [ $# -eq 0 ]; then
    set -- alpine:3.21 ubuntu:24.04 fedora:42
fi
REFERENCES=("$@")

pass() { printf 'PASS %s\n' "$1"; }
warning() { printf 'WARNING %s: %s\n' "$1" "$2"; }
fail() {
    printf 'FAILED %s: %s\n' "$1" "$2" >&2
    if [ -n "${BODY:-}" ]; then
        printf '%s\n' "$BODY" >&2
    fi
    exit 1
}

CODE=
BODY=
CURRENT_IMPORTED_ALIAS=
POOL_ID=
POOL_NET_ID=
POOL_LEASE_ID=

cleanup_on_exit() {
    # Keep the status that entered EXIT. A cleanup curl must not replace it.
    local status=$?
    local code deadline
    # Release only a lease id this process stored.
    # Do not invent an id that was never observed.
    if [ -n "${POOL_LEASE_ID:-}" ] && [ -n "${POOL_ID:-}" ]; then
        curl -sS -o /dev/null --max-time 30 \
            -X DELETE "${API}/api/pools/${POOL_ID}/leases/${POOL_LEASE_ID}" || true
    fi
    if [ -n "${POOL_ID:-}" ]; then
        # DELETE is accepted before members are gone, and it is refused while
        # a lease is still active. Retry until the row is gone or 60s pass,
        # then drop the network, still without failing the original command.
        deadline=$((SECONDS + 60))
        while true; do
            curl -sS -o /dev/null --max-time 15 \
                -X DELETE "${API}/api/pools/${POOL_ID}" || true
            code=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 5 --max-time 15 \
                "${API}/api/pools/${POOL_ID}" || true)
            if [ "$code" = 404 ] || [ "$code" = 000 ] || [ "$SECONDS" -ge "$deadline" ]; then
                break
            fi
            sleep 2
        done
    fi
    if [ -n "${POOL_NET_ID:-}" ]; then
        curl -sS -o /dev/null --max-time 60 \
            -X DELETE "${API}/api/micro-networks/${POOL_NET_ID}" || true
    fi
    # Only aliases imported by this process are eligible for cleanup. A
    # pre-existing catalog/custom image is never put in this variable.
    if [ -n "${CURRENT_IMPORTED_ALIAS:-}" ]; then
        curl -sS -o /dev/null --max-time 60 \
            -X DELETE "${API}/api/images/${CURRENT_IMPORTED_ALIAS}" || true
    fi
    return "$status"
}
trap cleanup_on_exit EXIT

http() {
    local method path body header out
    method=$1
    path=$2
    body=${3-}
    header=${4-}
    out=$(mktemp)
    if [ -n "$body" ] && [ -n "$header" ]; then
        CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 60 \
            -X "$method" \
            -H "Origin: ${API}" \
            -H 'content-type: application/json' \
            -H "$header" \
            --data "$body" \
            "${API}${path}") || CODE=000
    elif [ -n "$body" ]; then
        CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 60 \
            -X "$method" \
            -H "Origin: ${API}" \
            -H 'content-type: application/json' \
            --data "$body" \
            "${API}${path}") || CODE=000
    elif [ -n "$header" ]; then
        CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 60 \
            -X "$method" \
            -H "Origin: ${API}" \
            -H "$header" \
            "${API}${path}") || CODE=000
    else
        CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 60 \
            -X "$method" \
            -H "Origin: ${API}" \
            "${API}${path}") || CODE=000
    fi
    BODY=$(cat "$out")
    rm -f "$out"
}

inspect_ref() {
    local ref=$1
    local out
    out=$(mktemp)
    CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 60 \
        -G "${API}/api/oci/inspect" \
        --data-urlencode "reference=${ref}" \
        -H "Origin: ${API}") || CODE=000
    BODY=$(cat "$out")
    rm -f "$out"
}

json_get() {
    local expr=$1
    printf '%s' "$BODY" | python3 -c "import json,sys; d=json.load(sys.stdin); print($expr)"
}

poll_job() {
    local id=$1
    local path=$2
    local status
    for _ in $(seq 1 240); do
        http GET "$path"
        if [ "$CODE" != 200 ] && [ "$CODE" != 202 ]; then
            fail "$id" "poll ${path} HTTP ${CODE}"
        fi
        status=$(json_get 'd.get("status") or ""')
        case "$status" in
            succeeded) return 0 ;;
            failed)
                fail "$id" "job failed: $(json_get 'd.get("log") or ""')"
                ;;
            running | idle) sleep 5 ;;
            *)
                fail "$id" "unexpected job status ${status}"
                ;;
        esac
    done
    fail "$id" "timed out waiting for ${path}"
}

pool_create_body() {
    python3 -c 'import json, sys
print(json.dumps({
    "name": sys.argv[1],
    "template": sys.argv[2],
    "cpu": 1,
    "ram": 512,
    "diskGb": int(sys.argv[3]),
    "egressPolicy": "internet",
    "microNetworkId": sys.argv[4],
    "minReady": int(sys.argv[5]),
    "maxSize": int(sys.argv[6]),
    "leaseTtlSeconds": int(sys.argv[7]),
}))' "$@"
}

pool_ready_count() {
    local max_size=$1
    python3 -c '
import json, sys
max_size = int(sys.argv[1])
pool = json.load(sys.stdin)
members = pool.get("members")
if not isinstance(members, list):
    raise SystemExit("members is not a list")
if len(members) > max_size:
    raise SystemExit("members.length %d exceeds maxSize %d" % (len(members), max_size))
print(sum(1 for member in members if member.get("state") == "ready"))
' "$max_size"
}

pool_replacement_phase() {
    local old_vm=$1
    local max_size=$2
    python3 -c '
import json, sys
old = sys.argv[1]
max_size = int(sys.argv[2])
pool = json.load(sys.stdin)
members = pool.get("members")
if not isinstance(members, list):
    raise SystemExit("members is not a list")
if len(members) > max_size:
    raise SystemExit("members.length %d exceeds maxSize %d" % (len(members), max_size))
if any(str(member.get("vmId") or "") == old for member in members):
    print("wait")
    raise SystemExit(0)
ready = [
    str(member.get("vmId") or "")
    for member in members
    if member.get("state") == "ready" and str(member.get("vmId") or "") not in ("", old)
]
print("ready" if ready else "wait")
' "$old_vm" "$max_size"
}

wait_for_ready_member() {
    local id=$1
    local max_size=$2
    local deadline=$3
    local ready_count
    while true; do
        http GET "/api/pools/${id}"
        [ "$CODE" = 200 ] || fail P6 "GET pool HTTP ${CODE}"
        ready_count=$(printf '%s' "$BODY" | pool_ready_count "$max_size") \
            || fail P6 "pool member check failed"
        if [ "$ready_count" -ge 1 ]; then
            return 0
        fi
        if [ "$SECONDS" -ge "$deadline" ]; then
            fail P6 "timed out waiting for a ready member"
        fi
        sleep 5
    done
}

wait_for_replacement() {
    local id=$1
    local old_vm=$2
    local max_size=$3
    local deadline=$4
    local phase pool_body
    while true; do
        http GET "/api/pools/${id}"
        [ "$CODE" = 200 ] || fail P6 "GET pool HTTP ${CODE}"
        pool_body=$BODY
        phase=$(printf '%s' "$BODY" | pool_replacement_phase "$old_vm" "$max_size") \
            || fail P6 "pool member check failed"
        case "$phase" in
            ready)
                http GET "/api/vms/${old_vm}"
                if [ "$CODE" = 404 ]; then
                    return 0
                fi
                if [ "$CODE" != 200 ]; then
                    fail P6 "GET leased VM HTTP ${CODE}"
                fi
                ;;
            wait) ;;
            *)
                BODY=$pool_body
                fail P6 "unexpected pool poll result"
                ;;
        esac
        if [ "$SECONDS" -ge "$deadline" ]; then
            BODY=$pool_body
            fail P6 "timed out waiting for the leased VM to be replaced"
        fi
        sleep 5
    done
}

delete_pool_row() {
    local id=$1
    local row=$2
    local deadline
    http DELETE "/api/pools/${id}"
    [ "$CODE" = 202 ] || fail "$row" "DELETE pool HTTP ${CODE}"
    [ "$(json_get 'str(bool(d.get("deleting"))).lower()')" = "true" ] \
        || fail "$row" "deleting is not true"
    deadline=$((SECONDS + 60))
    while true; do
        http GET "/api/pools/${id}"
        if [ "$CODE" = 404 ]; then
            if [ "${POOL_ID:-}" = "$id" ]; then
                POOL_ID=
            fi
            return 0
        fi
        if [ "$SECONDS" -ge "$deadline" ]; then
            fail "$row" "timed out waiting for pool delete"
        fi
        sleep 2
    done
}

# Runs privileged commands on the same QA host as ci-qa-lifetime.sh.
pool_qa_host() {
    if [ -n "${FIRECRAB_QA_MANAGER_HOST:-}" ]; then
        local key=${FIRECRAB_QA_MANAGER_KEY:?management VM key is required}
        ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=10 \
            -o StrictHostKeyChecking=accept-new \
            -o "UserKnownHostsFile=$(dirname -- "$key")/known_hosts" \
            "root@${FIRECRAB_QA_MANAGER_HOST}" "set -e; $1" </dev/null
    elif [ "$(id -u)" -eq 0 ]; then
        sh -ec "$1" </dev/null
    else
        sudo sh -ec "$1" </dev/null
    fi
}

pool_restart_qa() {
    local before after ids vm simple pids_before='' pids_after='' lease_id=$POOL_LEASE_ID
    wait_for_ready_member "$POOL_ID" 2 "$((SECONDS + 300))"
    http GET "/api/pools/${POOL_ID}"
    [ "$CODE" = 200 ] || fail P7 "GET pool before restart HTTP ${CODE}"
    before=$BODY
    ids=$(json_get '" ".join(sorted(m["vmId"] for m in d["members"]))')
    for vm in $ids; do
        simple=${vm//-/}
        pids_before+="$(pool_qa_host "pgrep -f '([v]m-shim --vm-id $vm|[f]irecracker --api-sock .*/$simple/)' | sort") "
    done
    [ -n "${pids_before// /}" ] || fail P7 "no live VM processes before restart"
    pool_qa_host "systemctl restart firecrab-api" || fail P7 "API restart failed"
    local ready=0
    for _ in $(seq 1 60); do
        if curl -fsS --max-time 5 "$API/api/host" >/dev/null 2>&1; then ready=1; break; fi
        sleep 1
    done
    [ "$ready" = 1 ] || fail P7 "API did not return after restart"
    http GET "/api/pools/${POOL_ID}"
    [ "$CODE" = 200 ] || fail P7 "GET pool after restart HTTP ${CODE}"
    after=$BODY
    python3 - "$before" "$after" <<'PY'
import json, sys
before, after = map(json.loads, sys.argv[1:])
for field in ('id', 'template', 'templateVersion', 'minReady', 'maxSize'):
    assert before[field] == after[field], field
members = lambda p: sorted((m['vmId'], m['state']) for m in p['members'])
assert members(before) == members(after), 'pool members changed across restart'
assert sum(m['state'] == 'ready' for m in after['members']) == 1
assert sum(m['state'] == 'leased' for m in after['members']) == 1
PY
    for vm in $ids; do
        simple=${vm//-/}
        pids_after+="$(pool_qa_host "pgrep -f '([v]m-shim --vm-id $vm|[f]irecracker --api-sock .*/$simple/)' | sort") "
        http GET "/api/vms/${vm}"
        [ "$CODE" = 200 ] || fail P7 "GET surviving VM HTTP ${CODE}"
        [ "$(json_get 'd["state"]')" = running ] || fail P7 "member is not running"
    done
    [ "$pids_before" = "$pids_after" ] || fail P7 "shim or Firecracker PID changed"
    http POST "/api/pools/${POOL_ID}/acquire" "" "Idempotency-Key: qa-ci-pool-live"
    [ "$CODE" = 200 ] || fail P7 "retried acquire HTTP ${CODE}"
    [ "$(json_get 'd["id"]')" = "$lease_id" ] || fail P7 "retry created a different lease"
    [ "$(json_get 'd["state"]')" = active ] || fail P7 "lease did not survive"
    pass "P7 API restart preserved ready/leased members, VM PIDs, and acquire key"
}

# First OCI reference only. The network is not the boot script's subnet.
run_pool_qa() {
    local alias=$1
    local disk=$2
    local body show_json update_json lease_vm lease_vm_id
    case "$disk" in
        ''|*[!0-9]*) fail P1 "diskGb is not an integer (${disk})" ;;
    esac

    http POST /api/micro-networks \
        '{"name":"qa-ci-pool","subnetCidr":"172.31.205.0/24","internetEnabled":true}'
    [ "$CODE" = 201 ] || fail P1 "POST /api/micro-networks HTTP ${CODE}"
    POOL_NET_ID=$(json_get 'd.get("id") or ""')
    [ -n "$POOL_NET_ID" ] || fail P1 "micro-network response missing id"

    body=$(pool_create_body qa-ci-pool "$alias" "$disk" "$POOL_NET_ID" 0 1 600)
    http POST /api/pools "$body"
    [ "$CODE" = 201 ] || fail P1 "POST /api/pools HTTP ${CODE}"
    POOL_ID=$(json_get 'd.get("id") or ""')
    [ -n "$POOL_ID" ] || fail P1 "create response missing id"
    [ -n "$(json_get 'd.get("templateVersion") or ""')" ] \
        || fail P1 "templateVersion is empty"
    [ "$(json_get 'len(d["members"]) if isinstance(d.get("members"), list) else -1')" = 0 ] \
        || fail P1 "members are not empty"
    pass P1

    http GET /api/pools
    [ "$CODE" = 200 ] || fail P2 "GET /api/pools HTTP ${CODE}"
    printf '%s' "$BODY" | python3 -c '
import json, sys
want = sys.argv[1]
rows = json.load(sys.stdin)
if not isinstance(rows, list) or not any(
    isinstance(row, dict) and row.get("id") == want for row in rows
):
    raise SystemExit("list missing pool id")
' "$POOL_ID" || fail P2 "list missing pool id"
    http GET "/api/pools/${POOL_ID}"
    [ "$CODE" = 200 ] || fail P2 "GET pool HTTP ${CODE}"
    [ "$(json_get 'd.get("name") or ""')" = "qa-ci-pool" ] || fail P2 "detail name mismatch"
    pass P2

    show_json=$(qa_cli pool show "$POOL_ID" --json) || {
        BODY=$show_json
        fail C6 "firecrab pool show failed"
    }
    printf '%s' "$show_json" | python3 -c '
import json, sys
if json.load(sys.stdin).get("name") != "qa-ci-pool":
    raise SystemExit("name mismatch")
' || {
        BODY=$show_json
        fail C6 "pool show name mismatch"
    }
    update_json=$(qa_cli pool update "$POOL_ID" --lease-ttl 120 --json) || {
        BODY=$update_json
        fail C6 "firecrab pool update failed"
    }
    printf '%s' "$update_json" | python3 -c '
import json, sys
if json.load(sys.stdin).get("leaseTtlSeconds") != 120:
    raise SystemExit("cli lease ttl")
' || {
        BODY=$update_json
        fail C6 "pool update did not set leaseTtlSeconds 120"
    }
    http GET "/api/pools/${POOL_ID}"
    [ "$CODE" = 200 ] || fail C6 "GET pool after update HTTP ${CODE}"
    [ "$(json_get 'd.get("leaseTtlSeconds")')" = 120 ] || fail C6 "leaseTtlSeconds is not 120"
    pass C6

    http PATCH "/api/pools/${POOL_ID}" '{"minReady":0,"maxSize":1,"leaseTtlSeconds":120}'
    [ "$CODE" = 200 ] || fail P3 "PATCH pool HTTP ${CODE}"
    [ "$(json_get 'd.get("minReady")')" = 0 ] || fail P3 "minReady is not 0"
    [ "$(json_get 'd.get("maxSize")')" = 1 ] || fail P3 "maxSize is not 1"
    [ "$(json_get 'd.get("leaseTtlSeconds")')" = 120 ] || fail P3 "leaseTtlSeconds is not 120"
    pass P3

    http POST "/api/pools/${POOL_ID}/acquire" "" "Idempotency-Key: qa-ci-pool-empty"
    [ "$CODE" = 409 ] || fail P4 "acquire HTTP ${CODE}"
    [ "$(json_get 'd.get("error", {}).get("code") or ""')" = "pool_exhausted" ] \
        || fail P4 "error.code is not pool_exhausted"
    pass P4

    delete_pool_row "$POOL_ID" P5
    pass P5

    # The name is free only after P5 has removed the idle pool.
    body=$(pool_create_body qa-ci-pool "$alias" "$disk" "$POOL_NET_ID" 1 2 600)
    http POST /api/pools "$body"
    [ "$CODE" = 201 ] || fail P6 "POST /api/pools HTTP ${CODE}"
    POOL_ID=$(json_get 'd.get("id") or ""')
    [ -n "$POOL_ID" ] || fail P6 "create response missing id"
    [ "$(json_get 'd.get("minReady")')" = 1 ] || fail P6 "minReady is not 1"
    [ "$(json_get 'd.get("maxSize")')" = 2 ] || fail P6 "maxSize is not 2"
    wait_for_ready_member "$POOL_ID" 2 "$((SECONDS + 300))"

    http POST "/api/pools/${POOL_ID}/acquire" "" "Idempotency-Key: qa-ci-pool-live"
    [ "$CODE" = 201 ] || fail P6 "acquire HTTP ${CODE}"
    POOL_LEASE_ID=$(json_get 'd.get("id") or ""')
    [ -n "$POOL_LEASE_ID" ] || fail P6 "acquire body missing lease id"
    lease_vm=$(json_get '(d.get("vm") or {}).get("id") or ""') || fail P6 "acquire body missing vm.id"
    lease_vm_id=$(json_get 'd.get("vmId") or ""') || fail P6 "acquire body missing vmId"
    if [ -z "$lease_vm" ]; then
        lease_vm=$lease_vm_id
    fi
    if [ -n "$lease_vm_id" ] && [ "$lease_vm" != "$lease_vm_id" ]; then
        fail P6 "vm.id does not match vmId"
    fi
    [ -n "$lease_vm" ] || fail P6 "acquire body missing vm id"

    http GET /api/vms
    [ "$CODE" = 200 ] || fail P6 "GET /api/vms HTTP ${CODE}"
    printf '%s' "$BODY" | python3 -c '
import json, sys
want = sys.argv[1]
rows = json.load(sys.stdin)
found = [row for row in rows if str(row.get("id") or "") == want]
if not found:
    raise SystemExit("leased VM is not listed")
if found[0].get("purpose") != "pool":
    raise SystemExit("leased VM purpose is not pool")
' "$lease_vm" || fail P6 "leased VM is missing from GET /api/vms"

    http DELETE "/api/vms/${lease_vm}"
    [ "$CODE" = 409 ] || fail P6 "DELETE pool VM HTTP ${CODE}"
    [ "$(json_get 'd.get("error", {}).get("code") or ""')" = "pool_owned" ] \
        || fail P6 "error.code is not pool_owned"

    if [ "${FIRECRAB_QA_POOL_RESTART:-0}" = 1 ]; then
        pool_restart_qa
    else
        warning P7 "set FIRECRAB_QA_POOL_RESTART=1 on a disposable host to check API restart"
    fi

    http DELETE "/api/pools/${POOL_ID}/leases/${POOL_LEASE_ID}"
    [ "$CODE" = 200 ] || fail P6 "release HTTP ${CODE}"
    [ "$(json_get 'd.get("state") or ""')" = "released" ] || fail P6 "lease state is not released"
    POOL_LEASE_ID=

    wait_for_replacement "$POOL_ID" "$lease_vm" 2 "$((SECONDS + 300))"
    delete_pool_row "$POOL_ID" P6

    http DELETE "/api/micro-networks/${POOL_NET_ID}"
    [ "$CODE" = 204 ] || fail P6 "DELETE micro-network HTTP ${CODE}"
    POOL_NET_ID=
    pass P6
}

if [ "$(uname -s)" = Linux ]; then
    test -e /dev/kvm || fail KVM "/dev/kvm is missing"
    if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then
        fail KVM "runner cannot read/write /dev/kvm"
    fi
    if id firecrab >/dev/null 2>&1; then
        if ! sudo -u firecrab test -r /dev/kvm; then
            fail KVM "user firecrab cannot read /dev/kvm"
        fi
        if ! sudo -u firecrab test -w /dev/kvm; then
            fail KVM "user firecrab cannot write /dev/kvm"
        fi
    fi
    pass KVM
else
    printf '%s\n' 'KVM will be proven by the nested guest boot'
fi

CLI_BIN=${FIRECRAB_QA_CLI:-}
qa_cli() {
    "$CLI_BIN" --api "$API" "$@"
}

boot_oci() {
    local reference=$1
    local first_reference=${2:-0}
    local alias installed disk body imported_image=0 image_list inspect_json
    local import_json import_status_json import_status
    printf 'OCI guest boot reference=%s\n' "$reference"

    if [ "$first_reference" = 1 ]; then
        if [ -z "$CLI_BIN" ]; then
            CLI_BIN=$(command -v firecrab || true)
        fi
        [ -n "$CLI_BIN" ] || fail C4 "firecrab CLI is not on PATH"

        image_list=$(qa_cli image list --json) \
            || fail "C4/${reference}" "firecrab image list failed"
        inspect_json=$(qa_cli image inspect "$reference" --json) \
            || fail "C4/${reference}" "firecrab image inspect failed"
        alias=$(printf '%s' "$inspect_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["alias"])')
        [ -n "$alias" ] || fail "I5/${reference}" "inspect missing alias"
        pass "I5/${reference} alias=${alias} (CLI)"

        installed=$(printf '%s' "$image_list" | python3 -c '
import json, sys
alias = sys.argv[1]
row = next((item for item in json.load(sys.stdin) if item.get("alias") == alias), None)
print(str(bool(row and row.get("installed"))).lower())
' "$alias")
        if [ "$installed" = true ]; then
            # An alias that was present before this run is deliberately not
            # imported or deleted. Report the rows as skipped so an existing
            # image cannot become a false PASS for an import that did not run.
            import_status_json=$(qa_cli image import-status "$alias" --json) \
                || fail "C4/${reference}" "firecrab image import-status failed"
            import_status=$(printf '%s' "$import_status_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
            warning "I6/${reference}" "${alias} was already installed; import not attempted (status=${import_status})"
            warning "C4/${reference}" "${alias} was already installed; import not attempted"
        else
            import_json=$(qa_cli image import "$reference" --json) \
                || fail "I6/${reference}" "firecrab image import failed"
            imported_image=1
            CURRENT_IMPORTED_ALIAS=$alias
            import_status=$(printf '%s' "$import_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
            case "$import_status" in
                idle|running|succeeded) ;;
                failed) fail "I6/${reference}" "firecrab image import returned failed" ;;
                *) fail "I6/${reference}" "unexpected import status ${import_status}" ;;
            esac

            for _ in $(seq 1 240); do
                import_status_json=$(qa_cli image import-status "$alias" --json) \
                    || fail "I6/${reference}" "firecrab image import-status failed"
                import_status=$(printf '%s' "$import_status_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')
                case "$import_status" in
                    succeeded) break ;;
                    failed)
                        fail "I6/${reference}" "firecrab image import failed: $(printf '%s' "$import_status_json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("log") or "")')"
                        ;;
                    running|idle) sleep 5 ;;
                    *) fail "I6/${reference}" "unexpected import status ${import_status}" ;;
                esac
            done
            [ "$import_status" = succeeded ] || fail "I6/${reference}" "timed out waiting for CLI import"

            image_list=$(qa_cli image list --json) \
                || fail "C4/${reference}" "firecrab image list after import failed"
            installed=$(printf '%s' "$image_list" | python3 -c '
import json, sys
alias = sys.argv[1]
row = next((item for item in json.load(sys.stdin) if item.get("alias") == alias), None)
if not row or row.get("installed") is not True:
    raise SystemExit("imported alias is not installed")
' "$alias") || fail "I6/${reference}" "alias ${alias} not installed"
            pass "I6/${alias} (CLI)"
            pass "C4/${reference} (CLI list/inspect/import/import-status)"
        fi
    else
        inspect_ref "$reference"
        [ "$CODE" = 200 ] || fail "I5/${reference}" "inspect HTTP ${CODE}"
        alias=$(json_get 'd["alias"]')
        [ -n "$alias" ] || fail "I5/${reference}" "inspect missing alias"
        pass "I5/${reference} alias=${alias}"

        http GET "/api/images/${alias}"
        installed=false
        if [ "$CODE" = 200 ]; then
            installed=$(json_get 'str(d.get("installed") or False).lower()')
        fi

        if [ "$installed" != "true" ]; then
            body=$(python3 -c 'import json,sys; print(json.dumps({"reference":sys.argv[1]}))' "$reference")
            http POST /api/oci/import "$body"
            if [ "$CODE" != 200 ] && [ "$CODE" != 202 ]; then
                fail "I6/${reference}" "POST /api/oci/import HTTP ${CODE}"
            fi
            imported_image=1
            CURRENT_IMPORTED_ALIAS=$alias
            poll_job "I6/${reference}" "/api/oci/import/${alias}"
            http GET "/api/images/${alias}"
            [ "$CODE" = 200 ] || fail "I6/${reference}" "GET image after import HTTP ${CODE}"
            [ "$(json_get 'str(d.get("installed") or False).lower()')" = "true" ] \
                || fail "I6/${reference}" "alias ${alias} not installed"
            pass "I6/${alias}"
        else
            warning "I6/${reference}" "${alias} was already installed; import not attempted"
        fi
    fi

    if [ "$first_reference" = 1 ]; then
        disk=$(printf '%s' "$image_list" | python3 -c '
import json, sys
alias = sys.argv[1]
row = next((item for item in json.load(sys.stdin) if item.get("alias") == alias), None)
print(row.get("minDiskGb") or 2 if row else 2)
' "$alias")
        FIRECRAB_QA_DISK_GB=$disk \
            FIRECRAB_QA_FIRST_REFERENCE=1 \
            FIRECRAB_QA_CLI="$CLI_BIN" \
            "$root/scripts/ci-m2-guest-boot.sh" "$alias"
    else
        disk=$(json_get 'd.get("minDiskGb") or 2')
        FIRECRAB_QA_DISK_GB=$disk "$root/scripts/ci-m2-guest-boot.sh" "$alias"
    fi
    if [ "$first_reference" = 1 ] && [ "$(uname -s)" != Linux ]; then
        pass "KVM nested Firecracker guest boot"
    fi
    pass "V1/${alias}"
    pass "V7/${alias}"
    pass "V8/${alias}"
    pass "V11/${alias}"
    pass "V13/${alias}"

    if [ "$first_reference" = 1 ]; then
        run_pool_qa "$alias" "$disk"
    fi

    if [ "$imported_image" = 1 ]; then
        http DELETE "/api/images/${alias}"
        if [ "$CODE" != 204 ] && [ "$CODE" != 200 ]; then
            fail "X5/${alias}" "DELETE image HTTP ${CODE}"
        fi
        CURRENT_IMPORTED_ALIAS=
        pass "X5/${alias}"
    fi
}

first_reference=1
failed_references=0
reference_results=()
for REFERENCE in "${REFERENCES[@]}"; do
    # A failed public-image boot must not hide later image results. Keep each
    # import and its cleanup in a subshell, with errexit enabled there. Calling
    # boot_oci from an `if` would disable errexit inside the function as well.
    set +e
    (
        set -e
        trap cleanup_on_exit EXIT
        boot_oci "$REFERENCE" "$first_reference"
    )
    reference_exit=$?
    set -e
    if [ "$reference_exit" -eq 0 ]; then
        reference_results+=("PASS ${REFERENCE} exit=0")
    else
        reference_results+=("FAILED ${REFERENCE} exit=${reference_exit}")
        failed_references=$((failed_references + 1))
    fi
    first_reference=0
done
printf 'Guest QA summary:\n'
printf '%s\n' "${reference_results[@]}"

http GET /api/vms
[ "$CODE" = 200 ] || fail X1 "GET /api/vms HTTP ${CODE}"
printf '%s' "$BODY" | python3 -c '
import json, sys
rows = json.load(sys.stdin)
bad = [str(r.get("name", "")) for r in rows if str(r.get("name", "")).startswith("qa-") or str(r.get("name", "")).startswith("m2-")]
if bad:
    sys.stderr.write("leftover VMs: " + ", ".join(bad) + "\n")
    sys.exit(1)
' || fail X1 "leftover guest-boot VMs"
pass X1

http GET /api/pools
[ "$CODE" = 200 ] || fail X7 "GET /api/pools HTTP ${CODE}"
printf '%s' "$BODY" | python3 -c '
import json, sys
rows = json.load(sys.stdin)
if not isinstance(rows, list):
    sys.stderr.write("not a JSON list\n")
    sys.exit(1)
bad = [str(row.get("name", "")) for row in rows if str(row.get("name", "")).startswith("qa-")]
if bad:
    sys.stderr.write("leftover pools: " + ", ".join(bad) + "\n")
    sys.exit(1)
' || fail X7 "leftover qa-* pools"
pass X7
[ "$failed_references" -eq 0 ] || exit 1