scripts/ci-qa-api.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Shared QA work list from public-docs/development/qa.md against a live API (:5523).
# IDs match the cross-platform QA sheet (G/H/N/S/L/I/P/V/C/X).
#
# RUN: G4 G5 H1 H2 H3 N1-N5 S1-S3 L1-L3 I1 I8(GET) I9 P1-P6 V14
# C3 C5 when `firecrab` is on PATH; X1-X4 X6
# SKIP: G1 Linux install job (doctor / install / status)
# G2 GitHub macOS hosted: build only; E2E is self-hosted Mac
# G3 Windows microManager job; E2E is a manual Windows host
# U1 do not POST /api/update
# N6 DELETE busy needs a VM
# I2 I3 I4 I7 I10 M2Image/bootstrap not in this script
# I5 I6 + V* boot: ci-qa-guest.sh (OCI alpine/ubuntu/fedora)
# V1-V13 ci-qa-guest.sh on Ubuntu with /dev/kvm
# C1 C2 C4 guest boot / image import
# X5 no custom OCI alias
# P1-P6, X7, and C6 run in ci-qa-guest.sh because this script does not install an image
# N4: IPv6 SLAAC POST; HTTP 400 → SKIP with body, do not fail.
# P1-P6 use a fake ECR host and never pull, so Docker Hub logins stay untouched.
# Prefix qa-ci-; IPv4 172.31.201.0/24. Never name a network "ci".
set -euo pipefail
API=${FIRECRAB_API:-http://127.0.0.1:5523}
API=${API%/}
PREFIX=qa-ci-
SUBNET=172.31.201.0/24
SUBNET_V6=172.31.211.0/24
SUBNET_CLI=172.31.203.0/24
STORAGE_PATH=${FIRECRAB_QA_STORAGE_PATH:-/tmp/qa-ci-pool}
REG_HOST=000000000000.dkr.ecr.qa-ci-1.amazonaws.com
REG_SECRET=qa-ci-secret-$$
NET_ID=
NET_V6_ID=
NET_CLI_ID=
STORAGE_ID=
SHELL_ID=
VM_ID=
REG_SAVED=
CODE=
BODY=
FIRECRAB=
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
pass() { printf 'PASS %s\n' "$1"; }
skip() { printf 'SKIP %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
}
cleanup() {
if [ -n "${REG_SAVED:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE -H "Origin: ${API}" \
"${API}/api/pipe/registries/${REG_HOST}" || true
fi
if [ -n "${SHELL_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/shells/${SHELL_ID}" || true
fi
if [ -n "${STORAGE_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/micro-storages/${STORAGE_ID}" || true
fi
if [ -n "${VM_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/vms/${VM_ID}" || true
fi
if [ -n "${NET_CLI_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/micro-networks/${NET_CLI_ID}" || true
fi
if [ -n "${NET_V6_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/micro-networks/${NET_V6_ID}" || true
fi
if [ -n "${NET_ID:-}" ]; then
curl -sS -o /dev/null --max-time 15 -X DELETE "${API}/api/micro-networks/${NET_ID}" || true
fi
}
trap cleanup EXIT
http() {
local method path body out
method=$1
path=$2
body=${3-}
out=$(mktemp)
if [ -n "$body" ]; then
CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 45 \
-X "$method" \
-H "Origin: ${API}" \
-H 'content-type: application/json' \
--data "$body" \
"${API}${path}") || CODE=000
else
CODE=$(curl -sS -o "$out" -w '%{http_code}' --connect-timeout 5 --max-time 45 \
-X "$method" \
-H "Origin: ${API}" \
"${API}${path}") || CODE=000
fi
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)"
}
expect() {
local id=$1
local want=$2
if [ "$CODE" != "$want" ]; then
fail "$id" "expected HTTP $want got ${CODE:-empty}"
fi
}
require_request_id() {
local id=$1
local rid
rid=$(json_get 'd.get("error", {}).get("requestId") or ""')
if [ -n "$rid" ]; then
return 0
fi
fail "$id" "JSON 404 missing error.requestId"
}
# Prints the connection row for $1 from a registry list in BODY; fails on any secret.
registry_row() {
printf '%s' "$BODY" | python3 -c '
import json, sys
rows = json.load(sys.stdin)
if not isinstance(rows, list):
sys.exit("not a JSON list")
for row in rows:
if set(row) != {"registry", "username", "provider"}:
sys.exit("unexpected fields: " + ",".join(sorted(row)))
match = [r for r in rows if r["registry"] == sys.argv[1]]
print(json.dumps(match[0], sort_keys=True) if match else "")
' "$1"
}
leftover_qa() {
local path=$1
http GET "$path"
expect "cleanup ${path}" 200
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(r.get("name", "")) for r in rows if str(r.get("name", "")).startswith("qa-")]
if bad:
sys.stderr.write("leftover: " + ", ".join(bad) + "\n")
sys.exit(1)
'
}
command -v curl >/dev/null 2>&1 || fail setup "curl is required"
command -v python3 >/dev/null 2>&1 || fail setup "python3 is required"
if command -v firecrab >/dev/null 2>&1; then
FIRECRAB=$(command -v firecrab)
elif [ -x /usr/local/bin/firecrab ]; then
FIRECRAB=/usr/local/bin/firecrab
elif [ -x "$root/target/debug/firecrab" ]; then
FIRECRAB=$root/target/debug/firecrab
elif [ -x "$root/target/release/firecrab" ]; then
FIRECRAB=$root/target/release/firecrab
fi
skip G1 "Linux install job covers doctor/install/status"
skip G2 "macOS microManager job covers service doctor"
skip G3 "Windows microManager job covers service doctor"
skip U1 "do not POST /api/update"
skip N6 "DELETE busy needs a VM"
skip I2 "kernel install skipped in this script"
skip I3 "M2Image package/install not in OCI guest boot"
skip I4 "kernel pair skipped in this script"
skip I5 "OCI inspect runs in ci-qa-guest.sh"
skip I6 "OCI import runs in ci-qa-guest.sh"
skip I7 "microregistry register skipped in this script"
skip I10 "bootstrap skipped in this script"
skip V1 "guest boot runs in ci-qa-guest.sh"
skip V2 "guest boot runs in ci-qa-guest.sh"
skip V3 "guest boot runs in ci-qa-guest.sh"
skip V4 "guest boot runs in ci-qa-guest.sh"
skip V5 "guest boot runs in ci-qa-guest.sh"
skip V6 "guest boot runs in ci-qa-guest.sh"
skip V7 "guest boot runs in ci-qa-guest.sh"
skip V8 "guest boot runs in ci-qa-guest.sh"
skip V9 "guest boot runs in ci-qa-guest.sh"
skip V10 "guest boot runs in ci-qa-guest.sh"
skip V11 "guest boot runs in ci-qa-guest.sh"
skip V12 "guest boot runs in ci-qa-guest.sh"
skip V13 "guest boot runs in ci-qa-guest.sh"
skip C1 "guest boot runs in ci-qa-guest.sh"
skip C2 "guest boot runs in ci-qa-guest.sh"
skip C4 "guest boot image CLI runs in ci-qa-guest.sh"
skip X5 "no custom OCI alias"
http GET /api/host
expect G4 200
json_get 'd' >/dev/null
http GET /
expect G4 200
printf '%s' "$BODY" | python3 -c '
import sys
body = sys.stdin.read().lower()
if "html" not in body:
sys.exit(1)
' || fail G4 "dashboard GET / was not HTML"
pass G4
http GET /api/no-such-route
expect G5 404
require_request_id G5
pass G5
http GET /api/update
expect H1 200
json_get 'd.get("current")' >/dev/null
http GET /api/update/progress
expect H1 200
json_get 'd.get("phase") + str(d["percent"])' >/dev/null
pass H1
http GET /api/network
expect H2 200
printf '%s' "$BODY" | python3 -c '
import json, sys
d = json.load(sys.stdin)
uplink = d.get("uplink") or ""
if not uplink:
sys.stderr.write("missing uplink\n")
sys.exit(1)
for name in d.get("interfaces") or []:
if name == "lo" or str(name).startswith("fct") or str(name).startswith("mnb"):
sys.stderr.write("picker has " + str(name) + "\n")
sys.exit(1)
' || fail H2 "uplink/interfaces check failed"
pass H2
http GET /api/info
expect H3 200
json_get 'd["version"] + d["datadir"] + d["apiService"] + d["netHelperService"] + d["helperUnit"]' >/dev/null
json_get 'd["firecrackerVersion"] or "unknown"' >/dev/null
pass H3
http POST /api/micro-networks \
"{\"name\":\"${PREFIX}net\",\"subnetCidr\":\"${SUBNET}\",\"internetEnabled\":true}"
expect N1 201
NET_ID=$(json_get 'd["id"]')
[ -n "$NET_ID" ] || fail N1 "create response missing id"
pass N1
http GET /api/micro-networks
expect N2 200
printf '%s' "$BODY" | python3 -c '
import json, sys
rows = json.load(sys.stdin)
want_id, want_name, want_cidr = sys.argv[1], sys.argv[2], sys.argv[3]
found = [r for r in rows if r.get("id") == want_id]
if not found:
sys.exit(1)
if found[0].get("name") != want_name or found[0].get("subnetCidr") != want_cidr:
sys.exit(1)
' "$NET_ID" "${PREFIX}net" "$SUBNET" || fail N2 "list missing created network"
http GET "/api/micro-networks/${NET_ID}"
expect N2 200
[ "$(json_get 'd["id"]')" = "$NET_ID" ] || fail N2 "detail id mismatch"
pass N2
http PATCH "/api/micro-networks/${NET_ID}" '{"internetEnabled":false}'
expect N3 200
[ "$(json_get 'json.dumps(d["internetEnabled"])')" = "false" ] || fail N3 "expected internetEnabled false"
http PATCH "/api/micro-networks/${NET_ID}" '{"internetEnabled":true}'
expect N3 200
[ "$(json_get 'json.dumps(d["internetEnabled"])')" = "true" ] || fail N3 "expected internetEnabled true"
pass N3
http POST /api/micro-networks \
"{\"name\":\"${PREFIX}v6\",\"subnetCidr\":\"${SUBNET_V6}\",\"internetEnabled\":true,\"ipv6AddressMode\":\"slaac\"}"
if [ "$CODE" = 400 ]; then
skip N4 "HTTP 400 ${BODY}"
elif [ "$CODE" = 201 ]; then
NET_V6_ID=$(json_get 'd["id"]')
[ -n "$NET_V6_ID" ] || fail N4 "create response missing id"
pass N4
else
fail N4 "expected HTTP 201 or 400 got ${CODE:-empty}"
fi
http POST /api/micro-networks \
"{\"name\":\"${PREFIX}bad-uplink\",\"subnetCidr\":\"172.31.204.0/24\",\"uplink\":\"\"}"
expect N5 400
pass N5
http GET /api/storage
expect S1 200
printf '%s' "$BODY" | python3 -c '
import json, sys
rows = json.load(sys.stdin)
if not any(r.get("id") == "default" for r in rows):
sys.exit(1)
' || fail S1 "GET /api/storage missing default root"
pass S1
http GET /api/storage/devices
expect S2 200
json_get 'd' >/dev/null
pass S2
http POST /api/micro-storages \
"{\"name\":\"${PREFIX}pool\",\"path\":\"${STORAGE_PATH}\"}"
expect S3 201
STORAGE_ID=$(json_get 'd["id"]')
[ -n "$STORAGE_ID" ] || fail S3 "create response missing id"
pass S3
SHELL_BODY=$(python3 -c 'import json; print(json.dumps({"name":"qa-ci-sh","content":"#!/bin/sh\necho qa-ci-sh\n"}))')
http POST /api/shells "$SHELL_BODY"
expect L1 201
SHELL_ID=$(json_get 'd["shellId"]')
[ -n "$SHELL_ID" ] || fail L1 "create response missing shellId"
pass L1
REV_BODY=$(python3 -c 'import json; print(json.dumps({"content":"#!/bin/sh\necho qa-ci-sh-2\n"}))')
http POST "/api/shells/${SHELL_ID}/revisions" "$REV_BODY"
expect L2 201
REV_ID=$(json_get 'd["revisionId"]')
[ -n "$REV_ID" ] || fail L2 "revision response missing revisionId"
pass L2
http GET "/api/shells/${SHELL_ID}"
expect L3 200
http GET "/api/shells/${SHELL_ID}/revisions/${REV_ID}"
expect L3 200
[ "$(json_get 'd["revisionId"]')" = "$REV_ID" ] || fail L3 "revision GET mismatch"
pass L3
http GET /api/images
expect I1 200
http GET /api/kernels
expect I1 200
http GET /api/microregistry
if [ "$CODE" != 200 ] && [ "$CODE" != 503 ]; then
fail I1 "GET /api/microregistry expected 200 or 503 got ${CODE:-empty}"
fi
pass I1
http GET /api/microregistry/docker-hub
expect I8 200
json_get 'd["configured"]' >/dev/null
pass I8
http GET /api/images/does-not-exist
expect I9 404
require_request_id I9
pass I9
http GET /api/pipe/registries
expect P1 200
row=$(registry_row "$REG_HOST") || fail P1 "registry list shape"
[ -z "$row" ] || fail P1 "${REG_HOST} already has a login; remove it before QA"
pass P1
http PUT "/api/pipe/registries/${REG_HOST}" "{\"username\":\"qa-ci-user\",\"secret\":\"${REG_SECRET}\"}"
REG_SAVED=1
expect P2 200
case $BODY in *"$REG_SECRET"*) fail P2 "save response returned the secret" ;; esac
[ "$(json_get 'd["username"] + "/" + d["provider"]')" = AWS/ecr ] \
|| fail P2 "ECR login must be saved as AWS/ecr"
http GET /api/pipe/registries
expect P2 200
case $BODY in *"$REG_SECRET"*) fail P2 "registry list returned the secret" ;; esac
row=$(registry_row "$REG_HOST") || fail P2 "registry list shape"
[ -n "$row" ] || fail P2 "saved login missing from the list"
pass P2
http PUT /api/pipe/registries/untrusted.invalid '{"username":"qa-ci-user","secret":"qa"}'
expect P3 400
http PUT "/api/pipe/registries/${REG_HOST}" '{"username":"qa-ci-user","secret":" "}'
expect P3 400
http PUT "/api/pipe/registries/${REG_HOST}" '{"username":"qa-ci-user","secret":"qa","extra":1}'
expect P3 400
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 -X PUT \
-H 'Origin: https://untrusted.invalid' -H 'content-type: application/json' \
--data '{"username":"qa-ci-user","secret":"qa"}' \
"${API}/api/pipe/registries/${REG_HOST}") || CODE=000
BODY=
expect P3 403
pass P3
http DELETE "/api/pipe/registries/${REG_HOST}"
expect P4 200
[ "$(json_get 'd["deleted"]')" = True ] || fail P4 "delete response missing deleted"
http GET /api/pipe/registries
expect P4 200
row=$(registry_row "$REG_HOST") || fail P4 "registry list shape"
[ -z "$row" ] || fail P4 "login still listed after delete"
REG_SAVED=
http DELETE "/api/pipe/registries/${REG_HOST}"
expect P4 200
http DELETE /api/pipe/registries/untrusted.invalid
expect P4 200
http DELETE /api/pipe/registries/invalid@host
expect P4 400
pass P4
http GET /api/pipe/registry-images
expect P5 200
json_get 'len(d)' >/dev/null || fail P5 "registry images not a list"
http GET '/api/pipe/registry-tags?reference=untrusted.invalid/team/app:v1'
expect P5 400
http GET "/api/pipe/registry-tags?reference=${REG_HOST}/qa/app:v1&last=bad%20tag"
expect P5 400
pass P5
http POST /api/pipe/registry-transfers '{"action":"pull","reference":"untrusted.invalid/team/app:v1"}'
expect P6 400
http POST /api/pipe/registry-transfers "{\"action\":\"push\",\"reference\":\"${REG_HOST}/qa/app:v1\"}"
expect P6 400
http GET /api/pipe/registry-transfers/not-a-digest
expect P6 400
pass P6
http POST /api/vms '{"name":"qa-ci-nonet","template":"alpine-3.24","cpu":1,"ram":512,"diskGb":2}'
if [ "$CODE" = 201 ]; then
VM_ID=$(json_get 'd.get("id") or ""')
fail V14 "create without microNetworkId returned 201"
fi
expect V14 400
pass V14
if [ -n "$FIRECRAB" ]; then
FIRECRAB_API=$API "$FIRECRAB" network list --json >/dev/null \
|| fail C3 "firecrab network list failed"
CLI_JSON=$(FIRECRAB_API=$API "$FIRECRAB" network create \
--name "${PREFIX}cli" --subnet-cidr "$SUBNET_CLI" --json) \
|| fail C3 "firecrab network create failed"
NET_CLI_ID=$(printf '%s' "$CLI_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
[ -n "$NET_CLI_ID" ] || fail C3 "CLI create missing id"
FIRECRAB_API=$API "$FIRECRAB" network delete "$NET_CLI_ID" \
|| fail C3 "firecrab network delete failed"
NET_CLI_ID=
pass C3
host_home=$(mktemp -d)
HOME=$host_home FIRECRAB_API=$API "$FIRECRAB" host add qa-ci-host "$API" \
|| fail C5 "host add failed"
HOME=$host_home FIRECRAB_API=$API "$FIRECRAB" host list \
| grep -q qa-ci-host || fail C5 "host list missing qa-ci-host"
HOME=$host_home FIRECRAB_API=$API "$FIRECRAB" host use qa-ci-host \
|| fail C5 "host use failed"
HOME=$host_home FIRECRAB_API=$API "$FIRECRAB" host show qa-ci-host \
|| fail C5 "host show failed"
HOME=$host_home FIRECRAB_API=$API "$FIRECRAB" host remove qa-ci-host \
|| fail C5 "host remove failed"
rm -rf "$host_home"
pass C5
else
skip C3 "firecrab binary not on PATH"
skip C5 "firecrab binary not on PATH"
fi
cleanup
NET_ID=
NET_V6_ID=
NET_CLI_ID=
STORAGE_ID=
SHELL_ID=
VM_ID=
leftover_qa /api/vms || fail X1 "leftover qa-* VMs"
pass X1
leftover_qa /api/micro-networks || fail X2 "leftover qa-* networks"
pass X2
leftover_qa /api/micro-storages || fail X3 "leftover qa-* storages"
pass X3
leftover_qa /api/shells || fail X4 "leftover qa-* shells"
pass X4
http GET /api/microregistry/docker-hub
expect X6 200
pass X6