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 81eb0ebe3b
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
5 changed files with 1347 additions and 0 deletions

View file

@ -0,0 +1,10 @@
Développement
=============
Outillage réservé au développement de two : il ne s'installe sur aucun hyperviseur et ne sert
pas à exploiter un cluster.
.. toctree::
:maxdepth: 1
lab

247
docs/developpement/lab.rst Normal file
View file

@ -0,0 +1,247 @@
Lab de test multi-nœud
======================
Ce qui fait l'intérêt de two ne se voit qu'à partir de **deux hyperviseurs** : sur un nœud isolé,
le trafic reste sur le bridge local et l'absence de plan de contrôle passe inaperçue (voir
:doc:`/deploiement/architecture-cluster`). Le lab reproduit la topologie du cluster —
hyperviseurs, route reflector, switch L3 — sous forme de VM, sur un serveur physique loué à
l'heure.
Le pourquoi des choix (serveur physique plutôt que VM cloud, câbles QEMU, MTU 9000, versions) est
consigné sur le ticket `#50 <https://git.g3e.fr/syonad/two/issues/50>`_. Cette page décrit
comment s'en servir.
.. note::
État actuel : seule l'étape **E0** est livrée — le cycle de vie du serveur qui portera le lab,
avec ``scripts/lab-host.sh``. La description de la topologie et le lancement des VM viendront
avec les étapes suivantes, et cette page avec elles.
Le serveur de lab
-----------------
Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé.
.. list-table::
:widths: 25 75
* - Offre
- ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans
frais de mise en service
* - Matériel
- 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD
* - Système
- Debian 12, installé par Scaleway à la création
* - Pourquoi Intel
- lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations
distinctes (``kvm_intel``, ``kvm_amd``)
*Or equivalent* n'est pas une clause de style : le premier serveur livré était un
**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever
``lscpu`` au début de chaque campagne.
Prérequis côté Scaleway
-----------------------
#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime
tout serveur de lab du projet : il ne doit rien y avoir d'autre.
#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH
du projet. Rien d'autre.
#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle,
le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de
continuer.
#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et*
l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation →
Quotas → Elastic Metal. Voir `les quotas Scaleway
<https://www.scaleway.com/en/docs/organizations-and-projects/organization/organization-quotas/>`_.
Fichiers locaux
---------------
Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt.
``~/.config/two-lab/scaleway.env``
Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun :
.. code-block:: bash
SCW_SECRET_KEY=<clé secrète>
SCW_DEFAULT_PROJECT_ID=<identifiant du projet de lab>
SCW_DEFAULT_ZONE=fr-par-1
* le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par
d'autres que son propriétaire ;
* il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ;
* une variable d'environnement du même nom l'emporte sur le fichier ;
* la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé
secrète, dans l'en-tête ``X-Auth-Token``.
Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier.
``~/.config/two-lab/ssh/lab_ed25519``
Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans
intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le
script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur
l'agent SSH.
Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur
lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une
autre :
.. code-block:: bash
mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh
ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519
``~/.cache/two-lab/``
État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé
par ``down``.
Commandes
---------
.. code-block:: text
usage: lab-host.sh <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
``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**.
Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH,
et montre exactement ce qui serait commandé :
.. code-block:: text
$ scripts/lab-host.sh plan
== offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available
== prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR
== os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root
== clés : 2 clé(s) SSH du projet
== requête : POST /baremetal/v1/zones/fr-par-1/servers
``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande
réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal).
.. code-block:: text
$ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm'
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down'
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing
…
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed
== SSH pas encore joignable, nouvel essai dans 20s
…
== prêt : root@<adresse>
Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux
CPU(s): 32
Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
…
Virtualization: VT-x
total used free shared buff/cache available
Mem: 251 1 250 0 0 249
nested=Y
crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm
== session terminée (code 0), suppression du serveur
== suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957
== aucun serveur de lab ne reste dans le projet
Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première
campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation —
environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de
``session`` est celui de la commande distante.
``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression
à la charge de l'utilisateur.
Facturation
-----------
.. warning::
Un serveur Elastic Metal est facturé **de sa création à sa suppression, éteint compris**.
Éteindre ne suffit pas : il faut supprimer. La granularité n'est pas documentée par Scaleway —
compter chaque heure entamée comme une heure pleine.
Ce que le script garantit :
* il ne commande **jamais** d'offre mensuelle : il exige une seule offre au nom demandé, en
facturation horaire, en stock et sans frais de mise en service, sinon il refuse avant toute
création. La CLI ``scw`` n'est pas utilisée pour cette raison : son ``server create type=…``
choisit l'offre par son seul nom et peut tomber sur la mensuelle, qui engage un mois ;
* il refuse de créer un second serveur si un serveur de lab existe déjà ;
* ``down`` agit sur **tous** les serveurs portant le tag ``two-lab`` dans le projet, et
``session`` y ajoute l'identifiant reçu à la création : un serveur créé juste avant une
interruption est rattrapé ;
* une suppression refusée pendant la livraison ou l'installation est réessayée tant qu'elle dure,
dans la limite du délai d'installation augmenté du délai de suppression ;
* le serveur n'est déclaré supprimé qu'au 404 de l'API, jamais sur une erreur passagère ;
* un échec de suppression se termine par ``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` et un code
d'erreur.
Ce qu'il ne peut pas garantir : un ``SIGKILL``, une coupure de courant ou une mise en veille du
poste qui lance la session. En cas de doute, toujours :
.. code-block:: bash
scripts/lab-host.sh status
scripts/lab-host.sh down
Diagnostic
----------
``aucune clé SSH active dans le projet``
Aucune clé SSH n'est enregistrée dans le projet de lab. En ajouter une dans la console (projet
→ Clés SSH).
``offre horaire EM-B212X-SSD : 0 correspondance(s)``
L'offre n'existe pas dans la zone en facturation horaire. Vérifier ``SCW_DEFAULT_ZONE``.
``création incertaine``
La création a échoué ou n'a pas rendu d'identifiant. Le serveur a pu être créé malgré tout —
cas typique : le quota (identité non validée), ou une réponse perdue. Le script indique s'il
voit un serveur de lab ; dans tous les cas, lancer ``status`` puis ``down``.
``SSH injoignable``
L'installation est terminée mais SSH ne répond pas après 10 minutes. Avec une clé matérielle
(Yubikey), chaque connexion demande un PIN ou un toucher : utiliser la clé dédiée du lab. Le
serveur est toujours facturé : ``down``.
``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)``
La suppression n'a pas abouti dans les délais. Relancer ``down`` ; si l'erreur persiste,
supprimer depuis la console Scaleway.
``HTTP 403 insufficient permissions``
La clé d'API est authentifiée mais n'a pas le droit demandé — en général la lecture des clés
SSH du projet. Compléter la politique de la clé, limitée au projet.
Sécurité
--------
* La clé secrète n'apparaît ni dans les arguments des processus (elle est passée à ``curl`` par
un descripteur de fichier), ni dans les journaux, ni dans l'environnement de ``ssh``.
* **Ne jamais lancer le script sous** ``bash -x`` : la trace afficherait la clé.
* Le serveur n'expose que SSH, par clé. Le lab n'a aucune donnée personnelle ni secret de
production.
* Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal
partagé, capture d'écran) se régénère.
Tests
-----
.. code-block:: bash
bash scripts/lab-host_test.sh
Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway
qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets,
suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client.
Elle tourne sous bash 5 comme sous le bash 3.2 de macOS.

View file

@ -40,6 +40,9 @@ Par où commencer
:doc:`/exploitation/index`
Configuration, services, API de l'agent, métriques et diagnostic sur un nœud en service.
:doc:`/developpement/index`
Outillage de développement de two, dont le lab de test multi-nœud sur serveur loué à l'heure.
:doc:`/concepts/index`
Comment les éléments fonctionnent entre eux : modèle de données, modes réseau, cycle de vie,
metadata. À lire avant de diagnostiquer un comportement inattendu.
@ -57,6 +60,12 @@ Par où commencer
exploitation/index
.. toctree::
:hidden:
:caption: Développement
developpement/index
.. toctree::
:hidden:
:caption: Interne

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

685
scripts/lab-host_test.sh Normal file
View file

@ -0,0 +1,685 @@
#!/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 ]]