f-50: lab: cycle de vie du serveur de lab Scaleway #50

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-10-04 00:16:20 +02:00
commit 28ce00cfa3
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
5 changed files with 1347 additions and 0 deletions

396
scripts/lab-host.sh Normal file
View file

@ -0,0 +1,396 @@
#!/usr/bin/env bash
API_URL="${SCW_API_URL:-https://api.scaleway.com}"
CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}"
OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}"
OS_NAME="${LAB_OS_NAME:-Debian}"
OS_VERSION="${LAB_OS_VERSION:-12}"
LAB_NAME="${LAB_NAME:-two-lab}"
LAB_TAG="${LAB_TAG:-two-lab}"
INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}"
DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}"
SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}"
POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}"
HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}"
HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}"
STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}"
SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}"
CREATED_ID=""
info () { echo "== ${1}" >&2; }
warn () { echo "!! ${1}" >&2; }
die () { echo "!! ${1}" >&2; exit 1; }
usage () {
cat >&2 <<EOF
usage: ${0##*/} <commande> [arguments]
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
et le prix ; ne crée rien
up crée le serveur de lab, attend la fin de son installation et son SSH
status liste les serveurs de lab du projet
ssh [commande] se connecte au serveur de lab
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE},
fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}),
LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}),
LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH)
ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY.
EOF
exit 2
}
load_credentials () {
local KEY VALUE
if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then
[[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \
|| die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600"
while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do
case "${KEY}" in
SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;;
SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;;
SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;;
esac
done < "${CREDENTIALS_FILE}"
fi
ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}"
PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}"
}
require_env () {
[[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement"
[[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement"
command -v jq >/dev/null 2>&1 || die "jq est requis"
command -v curl >/dev/null 2>&1 || die "curl est requis"
}
http_code () {
cat "${STATE_DIR}/last_http_code" 2>/dev/null
}
api () {
local METHOD="${1}"
local URL_PATH="${2}"
local BODY="${3:-}"
local OUT CODE
local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}'
--connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}")
: > "${STATE_DIR}/last_http_code"
[[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}")
OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \
|| { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; }
CODE="${OUT##*$'\n'}"
OUT="${OUT%$'\n'*}"
printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code"
if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then
warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}"
return 1
fi
printf '%s\n' "${OUT}"
}
http_failure_is_final () {
local CODE
CODE=$(http_code)
[[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]]
}
money () {
jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end"
}
resolve_offer () {
local JSON OFFER COUNT
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \
|| die "impossible de lister les offres"
OFFER=$(jq -c --arg n "${OFFER_NAME}" \
'[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}")
COUNT=$(jq 'length' <<< "${OFFER}")
[[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue"
OFFER=$(jq -c '.[0]' <<< "${OFFER}")
[[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée"
[[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}"
[[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \
|| die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus"
printf '%s\n' "${OFFER}"
}
resolve_os () {
local OFFER_ID="${1}"
local JSON OS COUNT
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \
|| die "impossible de lister les OS"
OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \
'[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase))
and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}")
COUNT=$(jq 'length' <<< "${OS}")
if [[ "${COUNT}" -ne 1 ]]; then
warn "OS disponibles pour ${OFFER_NAME} :"
jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2
die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue"
fi
jq -c '.[0]' <<< "${OS}"
}
resolve_ssh_keys () {
local JSON
JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \
|| die "impossible de lister les clés SSH du projet"
JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}")
[[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}"
printf '%s\n' "${JSON}"
}
build_request () {
local OFFER="${1}"
local OS="${2}"
local KEYS="${3}"
jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \
--arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \
'{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)",
tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}'
}
lab_servers () {
local JSON
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \
|| return 1
jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \
'[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}"
}
server_ip () {
jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty'
}
server_is_installing () {
jq -e '.status == "delivering" or .status == "ordered"
or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null
}
cmd_plan () {
local OFFER OS KEYS
OFFER=$(resolve_offer) || exit 1
OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
KEYS=$(resolve_ssh_keys) || exit 1
info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")"
info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")"
info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")"
info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet"
info "requête : POST /baremetal/v1/zones/${ZONE}/servers"
build_request "${OFFER}" "${OS}" "${KEYS}" | jq .
}
wait_installed () {
local ID="${1}"
local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT ))
local JSON STATUS INSTALL
while (( SECONDS < DEADLINE )); do
if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then
if http_failure_is_final; then
warn "erreur définitive HTTP $(http_code) en attendant l'installation"
return 1
fi
sleep "${POLL_INTERVAL}"
continue
fi
STATUS=$(jq -r '.status' <<< "${JSON}")
INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}")
info "serveur ${ID} : ${STATUS}, installation ${INSTALL}"
case "${STATUS}/${INSTALL}" in
ready/completed) printf '%s\n' "${JSON}"; return 0 ;;
error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*)
warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;;
esac
sleep "${POLL_INTERVAL}"
done
warn "installation non terminée après ${INSTALL_TIMEOUT}s"
return 1
}
ssh_run () {
local -a OPTS=()
while [[ $# -gt 0 && "${1}" != "--" ]]; do
OPTS+=("${1}")
shift
done
[[ "${1:-}" == "--" ]] && shift
[[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none)
env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \
"${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@"
}
wait_ssh () {
local DEADLINE=$(( SECONDS + SSH_TIMEOUT ))
while (( SECONDS < DEADLINE )); do
ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0
info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s"
sleep "${POLL_INTERVAL}"
done
return 1
}
require_no_lab_server () {
local EXISTING
EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab"
[[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \
|| die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'"
}
report_uncertain_creation () {
local FOUND
if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then
warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")"
fi
die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'"
}
cmd_up () {
local OFFER OS KEYS BODY JSON ID IP SSH_USER
require_no_lab_server
OFFER=$(resolve_offer) || exit 1
OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
KEYS=$(resolve_ssh_keys) || exit 1
BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}")
info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)"
JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation
ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null)
[[ -n "${ID}" ]] || report_uncertain_creation
CREATED_ID="${ID}"
info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'"
JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'"
IP=$(server_ip <<< "${JSON}")
[[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'"
SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}")
printf '%s\n' "${IP}" > "${STATE_DIR}/ip"
printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user"
: > "${STATE_DIR}/known_hosts"
wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'"
info "prêt : ${SSH_USER:-root}@${IP}"
}
cmd_status () {
local SERVERS
SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab"
if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then
info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})"
return 0
fi
jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}"
}
cmd_ssh () {
[[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord"
ssh_run -- "$@"
}
delete_server () {
local ID="${1}"
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT ))
local JSON
while (( SECONDS < DEADLINE )); do
api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0
[[ $(http_code) == "404" ]] && return 0
if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then
DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
(( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}"
warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s"
else
warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s"
fi
sleep "${POLL_INTERVAL}"
done
return 1
}
wait_gone () {
local ID="${1}"
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
while (( SECONDS < DEADLINE )); do
if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then
[[ $(http_code) == "404" ]] && return 0
fi
sleep "${POLL_INTERVAL}"
done
return 1
}
lab_server_ids () {
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
local SERVERS
while (( SECONDS < DEADLINE )); do
if SERVERS=$(lab_servers); then
jq -r '.[].id' <<< "${SERVERS}"
return 0
fi
warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s"
sleep "${POLL_INTERVAL}"
done
return 1
}
cmd_down () {
local IDS ID FAILED=0
if ! IDS=$(lab_server_ids); then
warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s"
FAILED=1
IDS=""
fi
IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u)
for ID in ${IDS}; do
info "suppression de ${ID}"
if ! delete_server "${ID}"; then
warn "suppression de ${ID} toujours refusée"
FAILED=1
continue
fi
if ! wait_gone "${ID}"; then
warn "${ID} toujours présent après ${DELETE_TIMEOUT}s"
FAILED=1
fi
done
rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts"
[[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway"
info "aucun serveur de lab ne reste dans le projet"
}
cmd_session () {
local RC
require_no_lab_server
trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
cmd_up
cmd_ssh "$@"
RC=$?
info "session terminée (code ${RC}), suppression du serveur"
return "${RC}"
}
main () {
local COMMAND="${1:-}"
[[ $# -gt 0 ]] && shift
load_credentials
[[ -n "${COMMAND}" ]] || usage
mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}"
chmod 700 "${STATE_DIR}"
case "${COMMAND}" in
plan) require_env; cmd_plan ;;
up) require_env; cmd_up ;;
status) require_env; cmd_status ;;
ssh) cmd_ssh "$@" ;;
down) require_env; cmd_down ;;
session) require_env; cmd_session "$@" ;;
*) usage ;;
esac
}
if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
main "$@"
fi