#!/usr/bin/env bash SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lab-host.sh" PASS=0 FAIL=0 SECRET="secret-de-test-a1b2c3" PROJECT="11111111-1111-1111-1111-111111111111" OTHER_PROJECT="22222222-2222-2222-2222-222222222222" HOURLY_ID="offer-hourly-0001" MONTHLY_ID="offer-monthly-0001" fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } write_fakes () { local DIR="${1}" mkdir -p "${DIR}/bin" cat > "${DIR}/bin/curl" <<'PY' #!/usr/bin/env python3 import json, os, sys, time, urllib.parse d = os.environ["FAKE_DIR"] args = sys.argv[1:] with open(os.path.join(d, "argv.log"), "a") as f: f.write(json.dumps(args) + "\n") method, body, headers, url = "GET", None, [], None i = 0 while i < len(args): a = args[i] if a == "-X": method = args[i + 1]; i += 2; continue if a == "-H": h = args[i + 1] if h.startswith("@"): with open(h[1:]) as f: headers += [l.strip() for l in f if l.strip()] else: headers.append(h) i += 2; continue if a == "--data": body = args[i + 1]; i += 2; continue if a in ("-w", "--max-time", "--connect-timeout"): i += 2; continue if a.startswith("-"): i += 1; continue url = a; i += 1 state_path = os.path.join(d, "state.json") with open(state_path) as f: st = json.load(f) def reply(code, obj): with open(state_path, "w") as f: json.dump(st, f) sys.stdout.write(json.dumps(obj) + "\n" + str(code)) sys.exit(0) u = urllib.parse.urlparse(url) path = u.path with open(os.path.join(d, "calls.log"), "a") as f: f.write(method + " " + path + ("?" + u.query if u.query else "") + "\n") if "X-Auth-Token: " + os.environ["FAKE_SECRET"] not in headers: reply(401, {"message": "unauthorized"}) zone = "/baremetal/v1/zones/fr-par-1" if method == "GET" and path == zone + "/offers": reply(200, {"offers": st["offers"], "total_count": len(st["offers"])}) if method == "GET" and path == zone + "/os": reply(200, {"os": st["os"], "total_count": len(st["os"])}) if method == "GET" and path == "/iam/v1alpha1/ssh-keys": reply(200, {"ssh_keys": st["keys"], "total_count": len(st["keys"])}) if method == "GET" and path == zone + "/servers": if st["list_errors"] > 0: st["list_errors"] -= 1 reply(503, {"message": "service unavailable"}) listed = [v for v in st["servers"].values() if not v.get("hidden")] reply(200, {"servers": listed, "total_count": len(listed)}) if method == "POST" and path == zone + "/servers": req = json.loads(body) with open(os.path.join(d, "create.json"), "w") as f: json.dump(req, f) st["seq"] += 1 sid = "srv-%d" % st["seq"] st["servers"][sid] = {"id": sid, "name": req["name"], "project_id": req["project_id"], "tags": req["tags"], "offer_name": "EM-B212X-SSD", "status": "delivering", "install": {"status": "to_install"}, "ips": [], "created_at": "now", "hidden": st["hide_from_list"]} if st["post_fail_after_create"]: reply(502, {"message": "bad gateway"}) if st["post_no_id"]: reply(200, {}) reply(200, st["servers"][sid]) if path.startswith(zone + "/servers/"): sid = path.rsplit("/", 1)[1] if sid not in st["servers"]: reply(404, {"message": "not found"}) srv = st["servers"][sid] if method == "GET" and st["get_errors"] > 0: st["get_errors"] -= 1 reply(503, {"message": "service unavailable"}) if method == "GET" and srv["status"] == "deleting": if st["linger"] > 0: st["linger"] -= 1 reply(200, srv) del st["servers"][sid] reply(404, {"message": "not found"}) if method == "GET" and st["vanish_after"] == 0: del st["servers"][sid] reply(404, {"message": "not found"}) if method == "GET" and st["vanish_after"] > 0: st["vanish_after"] -= 1 if method == "GET": if st["progress"]: status, install = st["progress"].pop(0) srv["status"] = status srv["install"]["status"] = install if status == "ready": srv["ips"] = [{"address": "2001:db8::1", "version": "IPv6"}, {"address": "203.0.113.7", "version": "IPv4"}] reply(200, srv) if method == "DELETE": if time.time() < st["refuse_until"]: reply(409, {"message": "server is installing"}) if st["delete_refusals"] > 0: st["delete_refusals"] -= 1 reply(409, {"message": "server is installing"}) if st["linger"] > 0: srv["status"] = "deleting" else: del st["servers"][sid] reply(200, srv) reply(500, {"message": "route inconnue du faux serveur : " + method + " " + path}) PY cat > "${DIR}/bin/ssh" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >> "${FAKE_DIR}/ssh.log" printf 'key=%s\n' "${SCW_SECRET_KEY:-}" >> "${FAKE_DIR}/ssh-env.log" case "$*" in *BatchMode=yes*) N=$(cat "${FAKE_DIR}/probe_fails" 2>/dev/null || echo 0) if [ "${N}" -gt 0 ]; then echo $(( N - 1 )) > "${FAKE_DIR}/probe_fails" exit 255 fi exit 0 ;; esac [ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}" exit "${FAKE_SSH_RC:-0}" SH chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" } base_state () { jq -n --arg h "${HOURLY_ID}" --arg m "${MONTHLY_ID}" --arg p "${PROJECT}" --arg o "${OTHER_PROJECT}" '{ seq: 0, progress: [], delete_refusals: 0, linger: 0, get_errors: 0, list_errors: 0, hide_from_list: false, refuse_until: 0, post_fail_after_create: false, post_no_id: false, vanish_after: -1, offers: [ {id: $m, name: "EM-B212X-SSD", subscription_period: "monthly", enable: true, stock: "available", price_per_hour: null, price_per_month: {currency_code: "EUR", units: 115, nanos: 990000000}, fee: {currency_code: "EUR", units: 115, nanos: 990000000}}, {id: "offer-other", name: "EM-A610R-NVMe", subscription_period: "hourly", enable: true, stock: "available", price_per_hour: {currency_code: "EUR", units: 0, nanos: 110000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}}, {id: $h, name: "EM-B212X-SSD", subscription_period: "hourly", enable: true, stock: "available", price_per_hour: {currency_code: "EUR", units: 0, nanos: 321000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}} ], os: [ {id: "os-deb11", name: "Debian", version: "11 (Bullseye)", enabled: true, allowed: true, user: {default_value: "debian"}}, {id: "os-deb12", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true, user: {default_value: "debian"}}, {id: "os-deb13", name: "Debian", version: "13 (Trixie)", enabled: true, allowed: true, user: {default_value: "debian"}}, {id: "os-ubu", name: "Ubuntu", version: "24.04 LTS (Noble Numbat)", enabled: true, allowed: true, user: {default_value: "ubuntu"}} ], keys: [{id: "key-1", disabled: false}, {id: "key-off", disabled: true}], servers: { "foreign-1": {id: "foreign-1", name: "prod-db", project_id: $o, tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}, "foreign-2": {id: "foreign-2", name: "autre", project_id: $p, tags: ["autre"], status: "ready", install: {status: "completed"}, ips: []} } }' } setup () { CURRENT="${1}" CASE_OK=1 WORK=$(mktemp -d "${TMPDIR:-/tmp}/labhost.XXXXXX") write_fakes "${WORK}" base_state > "${WORK}/state.json" : > "${WORK}/calls.log" : > "${WORK}/argv.log" : > "${WORK}/ssh.log" : > "${WORK}/ssh-env.log" } mutate () { local TMP TMP=$(jq "${1}" "${WORK}/state.json") && printf '%s\n' "${TMP}" > "${WORK}/state.json" } lab_env () { local EXEC="" [[ "${1}" == "exec" ]] && { EXEC="exec"; shift; } ${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \ FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" \ SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \ SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \ LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \ LAB_SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-60}" \ "$@" } run_lab () { lab_env bash "${SCRIPT}" "$@" > "${WORK}/out.log" 2>&1 } remaining () { jq -r '.servers | keys | sort | join(",")' "${WORK}/state.json" } teardown () { [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } rm -rf "${WORK}" } test_plan_picks_hourly_offer_even_when_monthly_is_listed_first () { setup "plan : offre horaire choisie même si la mensuelle est listée d'abord" run_lab plan || fail "code de sortie $?" grep -q "\"offer_id\": \"${HOURLY_ID}\"" "${WORK}/out.log" || fail "offre horaire absente de la requête" grep -q "${MONTHLY_ID}" "${WORK}/out.log" && fail "offre mensuelle présente dans la sortie" grep -q "^POST" "${WORK}/calls.log" && fail "plan a créé quelque chose" teardown } test_plan_selects_debian_12_only () { setup "plan : Debian 12 et rien d'autre" run_lab plan || fail "code de sortie $?" grep -q '"os_id": "os-deb12"' "${WORK}/out.log" || fail "os-deb12 non retenu" teardown } test_plan_passes_only_enabled_ssh_keys () { setup "plan : seules les clés SSH actives sont transmises" run_lab plan || fail "code de sortie $?" grep -q '"key-1"' "${WORK}/out.log" || fail "key-1 absente" grep -q '"key-off"' "${WORK}/out.log" && fail "clé désactivée transmise" teardown } test_refuses_offer_with_setup_fee () { setup "refus d'une offre horaire avec frais de mise en service" mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .fee.units) = 30' run_lab up && fail "up a réussi malgré des frais" grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé malgré des frais" teardown } test_refuses_when_hourly_offer_missing () { setup "refus quand seule l'offre mensuelle existe" mutate 'del(.offers[] | select(.id == "'"${HOURLY_ID}"'"))' run_lab up && fail "up a réussi sans offre horaire" grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé sur une offre mensuelle" teardown } test_refuses_ambiguous_os () { setup "refus d'un OS ambigu" mutate '.os += [{id: "os-deb12b", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true}]' run_lab plan && fail "plan a réussi avec deux Debian 12" teardown } test_refuses_out_of_stock () { setup "refus d'une offre en rupture" mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .stock) = "empty"' run_lab up && fail "up a réussi en rupture de stock" grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé en rupture de stock" teardown } test_up_creates_and_waits_for_installation () { setup "up : création puis attente de la fin d'installation" mutate '.progress = [["delivering","to_install"],["ready","installing"],["ready","completed"]]' run_lab up || fail "code de sortie $?" [[ $(jq -r '.offer_id' "${WORK}/create.json") == "${HOURLY_ID}" ]] || fail "offre créée incorrecte" [[ $(jq -r '.tags | join(",")' "${WORK}/create.json") == "two-lab" ]] || fail "tag absent" [[ $(jq -r '.project_id' "${WORK}/create.json") == "${PROJECT}" ]] || fail "projet incorrect" [[ $(cat "${WORK}/.cache/two-lab/ip" 2>/dev/null) == "203.0.113.7" ]] || fail "IPv4 non retenue" [[ $(cat "${WORK}/.cache/two-lab/user" 2>/dev/null) == "debian" ]] || fail "utilisateur non retenu" teardown } test_up_refuses_when_lab_server_exists () { setup "up : refus si un serveur de lab existe déjà" mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' run_lab up && fail "up a réussi" grep -q "^POST" "${WORK}/calls.log" && fail "second serveur créé" teardown } test_secret_never_in_argv () { setup "la clé d'API n'apparaît jamais dans les arguments de curl" mutate '.progress = [["ready","completed"]]' run_lab up || fail "code de sortie $?" grep -q "${SECRET}" "${WORK}/argv.log" && fail "secret visible dans argv" grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" teardown } test_down_deletes_only_lab_servers_of_project () { setup "down : ne supprime que les serveurs de lab du projet" mutate '.servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' run_lab down || fail "code de sortie $?" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_down_retries_refused_deletion () { setup "down : réessaie une suppression refusée" mutate '.delete_refusals = 2 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' run_lab down || fail "code de sortie $?" [[ $(grep -c "^DELETE" "${WORK}/calls.log") -eq 3 ]] || fail "$(grep -c "^DELETE" "${WORK}/calls.log") DELETE au lieu de 3" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_down_fails_loudly_when_deletion_keeps_failing () { setup "down : échec bruyant si la suppression n'aboutit jamais" mutate '.delete_refusals = 1000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" teardown } test_down_waits_until_server_is_gone () { setup "down : attend que le serveur ait disparu" mutate '.linger = 3 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' run_lab down || fail "code de sortie $?" [[ $(grep -c "^GET /baremetal/v1/zones/fr-par-1/servers/srv-lab$" "${WORK}/calls.log") -ge 4 ]] || fail "disparition non attendue" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_down_fails_loudly_when_server_never_disappears () { setup "down : échec bruyant si le serveur ne disparaît jamais" mutate '.linger = 100000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" teardown } test_down_does_not_mistake_api_error_for_disappearance () { setup "down : une erreur de l'API n'est pas prise pour une disparition" mutate '.linger = 100000 | .get_errors = 1 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi alors que le serveur existe encore" grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" teardown } test_session_deletes_server_after_remote_command () { setup "session : serveur supprimé après la commande distante" mutate '.progress = [["ready","completed"]]' run_lab session uname -a || fail "code de sortie $?" grep -q "uname -a" "${WORK}/ssh.log" || fail "commande distante non lancée" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_session_deletes_server_and_propagates_failure_of_remote_command () { setup "session : serveur supprimé et code d'échec propagé" mutate '.progress = [["ready","completed"]]' FAKE_SSH_RC=7 run_lab session false [[ $? -eq 7 ]] || fail "code de sortie différent de 7" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_session_deletes_server_when_installation_fails () { setup "session : serveur supprimé quand l'installation échoue" mutate '.progress = [["ready","installing"],["ready","error"]]' run_lab session true && fail "session a réussi" grep -q "true" "${WORK}/ssh.log" && fail "commande lancée sur un serveur en échec" grep -q "état terminal en échec : ready/error" "${WORK}/out.log" || fail "échec d'installation non reconnu comme terminal" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_session_deletes_server_when_installation_times_out () { setup "session : serveur supprimé quand l'installation n'aboutit pas" mutate '.progress = [range(0; 100000) | ["delivering","to_install"]]' LAB_INSTALL_TIMEOUT=1 run_lab session true && fail "session a réussi" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_session_does_not_touch_existing_lab_server () { setup "session : ne supprime pas un serveur de lab lancé à côté" mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' run_lab session true && fail "session a réussi" grep -q "^DELETE" "${WORK}/calls.log" && fail "suppression lancée" [[ $(remaining) == "foreign-1,foreign-2,srv-old" ]] || fail "restants : $(remaining)" teardown } test_missing_credentials_are_refused_before_any_call () { setup "refus sans clé d'API, avant tout appel" env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" FAKE_DIR="${WORK}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 \ && fail "plan a réussi sans clé" [[ -s "${WORK}/calls.log" ]] && fail "appel réseau sans clé" teardown } LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' INSTALLING_LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' test_down_keeps_retrying_while_server_is_installing () { setup "down : réessaie au-delà du délai tant que le serveur s'installe" mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 5)" LAB_DELETE_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 run_lab down || fail "code de sortie $?" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" grep -q "pendant la livraison ou l'installation" "${WORK}/out.log" || fail "installation non reconnue" teardown } test_down_gives_up_on_installing_server_after_hard_deadline () { local RC setup "down : abandonne quand même après délai d'installation + délai de suppression" mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 100000)" LAB_DELETE_TIMEOUT=2 LAB_INSTALL_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 lab_env timeout 30 bash "${SCRIPT}" down > "${WORK}/out.log" 2>&1 RC=$? [[ "${RC}" -ne 124 ]] || fail "aucun plafond : down tourne encore après 30 s" [[ "${RC}" -ne 0 ]] || fail "down a réussi" grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" teardown } test_down_retries_listing_servers () { setup "down : réessaie la liste des serveurs" mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 2" run_lab down || fail "code de sortie $?" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_down_fails_loudly_when_listing_never_works () { setup "down : échec bruyant si la liste reste indisponible" mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 100000" LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" teardown } test_session_deletes_created_server_even_if_missing_from_list () { setup "session : supprime le serveur créé même absent de la liste" mutate '.hide_from_list = true | .progress = [["ready","completed"]]' run_lab session true || fail "code de sortie $?" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_every_api_call_has_timeouts () { setup "chaque appel à l'API a un délai de connexion et un délai total" mutate '.progress = [["ready","completed"]]' run_lab up || fail "code de sortie $?" [[ -s "${WORK}/argv.log" ]] || fail "aucun appel" [[ $(jq -s '[.[] | select((index("--max-time") | not) or (index("--connect-timeout") | not))] | length' "${WORK}/argv.log") -eq 0 ]] \ || fail "appel sans délai" teardown } test_up_reports_uncertain_creation_when_post_fails () { setup "up : création incertaine signalée quand le POST échoue" mutate '.post_fail_after_create = true' run_lab up && fail "up a réussi" grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" grep -q "un serveur de lab existe bien : srv-1" "${WORK}/out.log" || fail "serveur créé non signalé" teardown } test_session_deletes_server_when_post_fails_after_creation () { setup "session : serveur supprimé quand le POST échoue après création" mutate '.post_fail_after_create = true' run_lab session true && fail "session a réussi" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_up_refuses_response_without_id () { setup "up : réponse de création sans identifiant refusée" mutate '.post_no_id = true' run_lab up && fail "up a réussi" grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" grep -q "servers/null" "${WORK}/calls.log" && fail "appel sur un identifiant null" teardown } test_up_stops_at_once_when_server_vanishes () { setup "up : arrêt immédiat si le serveur disparaît pendant l'installation" mutate '.vanish_after = 1 | .progress = [["delivering","to_install"]]' LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" grep -q "erreur définitive HTTP 404" "${WORK}/out.log" || fail "404 non traité comme définitif" teardown } test_up_stops_at_once_when_server_is_stopped_during_installation () { setup "up : arrêt immédiat si le serveur passe à l'arrêt pendant l'installation" mutate '.progress = [["delivering","to_install"],["stopped","installing"]]' LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" grep -q "état terminal en échec : stopped/installing" "${WORK}/out.log" || fail "arrêt non reconnu comme terminal" teardown } test_session_cleanup_survives_a_second_signal () { local PID RC I setup "session : un second signal n'interrompt pas le nettoyage" mutate '.progress = [["ready","completed"]] | .linger = 15' set -m FAKE_SSH_SLEEP=5 LAB_POLL_INTERVAL=0.2 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & PID=$! set +m for I in $(seq 1 100); do grep -q "sleep-long" "${WORK}/ssh.log" && break sleep 0.1 done kill -TERM -- "-${PID}" for I in $(seq 1 100); do grep -q "suppression de srv-1" "${WORK}/out.log" && break sleep 0.1 done grep -q "suppression de srv-1" "${WORK}/out.log" || fail "nettoyage jamais commencé" kill -INT -- "-${PID}" 2>/dev/null kill -TERM -- "-${PID}" 2>/dev/null wait "${PID}" RC=$? [[ "${RC}" -eq 143 ]] || fail "code ${RC} au lieu de 143" grep -q "aucun serveur de lab ne reste" "${WORK}/out.log" || fail "nettoyage interrompu" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_ssh_never_receives_api_key () { setup "ssh ne reçoit jamais la clé d'API dans son environnement" mutate '.progress = [["ready","completed"]]' run_lab session uname || fail "code de sortie $?" [[ -s "${WORK}/ssh-env.log" ]] || fail "ssh jamais lancé" grep -qv '^key=$' "${WORK}/ssh-env.log" && fail "clé transmise à ssh" teardown } test_session_waits_for_ssh_before_running_command () { setup "session : attend que SSH réponde avant la commande" mutate '.progress = [["ready","completed"]]' echo 3 > "${WORK}/probe_fails" run_lab session uname || fail "code de sortie $?" [[ $(grep -c "BatchMode=yes" "${WORK}/ssh.log") -eq 4 ]] || fail "$(grep -c "BatchMode=yes" "${WORK}/ssh.log") sondes au lieu de 4" [[ $(tail -n 1 "${WORK}/ssh.log") == *" uname" ]] || fail "commande non lancée en dernier" teardown } test_session_deletes_server_when_ssh_never_answers () { setup "session : serveur supprimé si SSH ne répond jamais" mutate '.progress = [["ready","completed"]]' echo 100000 > "${WORK}/probe_fails" LAB_SSH_TIMEOUT=1 LAB_POLL_INTERVAL=0.2 run_lab session uname && fail "session a réussi" grep -qv "BatchMode=yes" "${WORK}/ssh.log" && fail "commande lancée sans SSH joignable" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_ssh_uses_dedicated_lab_key_only_when_present () { setup "ssh : clé dédiée du lab utilisée seule quand elle existe" mutate '.progress = [["ready","completed"]]' mkdir -p "${WORK}/.config/two-lab/ssh" : > "${WORK}/.config/two-lab/ssh/lab_ed25519" run_lab session uname || fail "code de sortie $?" grep -q " uname" "${WORK}/ssh.log" || fail "commande non lancée" [[ $(grep -c -- "-i ${WORK}/.config/two-lab/ssh/lab_ed25519 -o IdentitiesOnly=yes -o IdentityAgent=none" "${WORK}/ssh.log") -eq $(wc -l < "${WORK}/ssh.log") ]] \ || fail "une connexion n'a pas utilisé la clé dédiée seule" teardown } test_ssh_falls_back_to_agent_without_lab_key () { setup "ssh : agent SSH utilisé quand la clé dédiée est absente" mutate '.progress = [["ready","completed"]]' run_lab session uname || fail "code de sortie $?" grep -q -- "-i " "${WORK}/ssh.log" && fail "clé imposée alors qu'elle n'existe pas" grep -q "IdentityAgent=none" "${WORK}/ssh.log" && fail "agent désactivé sans clé dédiée" teardown } run_lab_without_env_credentials () { env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \ LAB_POLL_INTERVAL=0 "$@" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 } write_credentials () { mkdir -p "${WORK}/.config/two-lab" printf '%s\n' "$@" > "${WORK}/.config/two-lab/scaleway.env" chmod 600 "${WORK}/.config/two-lab/scaleway.env" } test_credentials_file_is_used_when_environment_is_empty () { setup "identifiants : fichier lu quand l'environnement est vide" write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" "SCW_DEFAULT_ZONE=fr-par-1" run_lab_without_env_credentials || fail "code de sortie $?" grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet du fichier non utilisé" grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" teardown } test_credentials_file_without_final_newline_is_read () { setup "identifiants : dernière ligne sans retour à la ligne lue quand même" mkdir -p "${WORK}/.config/two-lab" printf 'SCW_DEFAULT_PROJECT_ID=%s\nSCW_SECRET_KEY=%s' "${PROJECT}" "${SECRET}" > "${WORK}/.config/two-lab/scaleway.env" chmod 600 "${WORK}/.config/two-lab/scaleway.env" run_lab_without_env_credentials || fail "code de sortie $?" teardown } test_environment_wins_over_credentials_file () { setup "identifiants : l'environnement l'emporte sur le fichier" write_credentials "SCW_SECRET_KEY=mauvais-secret" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" run_lab_without_env_credentials SCW_SECRET_KEY="${SECRET}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement non retenu" teardown } test_environment_value_kept_when_file_completes_the_rest () { setup "identifiants : une valeur de l'environnement n'est pas écrasée quand le fichier complète" write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" run_lab_without_env_credentials SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement écrasé par le fichier" teardown } test_credentials_file_readable_by_others_is_refused () { setup "identifiants : fichier lisible par d'autres refusé, sans aucun appel" write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" chmod 644 "${WORK}/.config/two-lab/scaleway.env" run_lab_without_env_credentials && fail "plan a réussi" grep -q "chmod 600" "${WORK}/out.log" || fail "pas de consigne de droits" [[ -s "${WORK}/calls.log" ]] && fail "appel réseau avec un fichier exposé" teardown } test_credentials_file_is_never_executed () { setup "identifiants : le fichier est lu, jamais exécuté" write_credentials "SCW_SECRET_KEY=\$(touch ${WORK}/pwned)" "touch ${WORK}/pwned2" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" run_lab_without_env_credentials && fail "plan a réussi avec un secret invalide" [[ -e "${WORK}/pwned" || -e "${WORK}/pwned2" ]] && fail "contenu du fichier exécuté" teardown } signal_during_session () { local SIGNAL="${1}" local EXPECTED="${2}" local PID RC I setup "session : ${SIGNAL} pendant la commande distante, serveur supprimé, code ${EXPECTED}" mutate '.progress = [["ready","completed"]]' set -m FAKE_SSH_SLEEP=5 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & PID=$! set +m for I in $(seq 1 100); do grep -q "sleep-long" "${WORK}/ssh.log" && break sleep 0.1 done grep -q "sleep-long" "${WORK}/ssh.log" || fail "commande distante jamais lancée" kill "-${SIGNAL}" -- "-${PID}" wait "${PID}" RC=$? [[ "${RC}" -eq "${EXPECTED}" ]] || fail "code ${RC} au lieu de ${EXPECTED}" [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" teardown } test_session_handles_term () { signal_during_session TERM 143; } test_session_handles_hup () { signal_during_session HUP 129; } test_session_handles_int () { signal_during_session INT 130; } for T in $(declare -F | awk '{print $3}' | grep '^test_'); do "${T}" done echo "${PASS} réussi(s), ${FAIL} échec(s)" [[ "${FAIL}" -eq 0 ]]