From 28ce00cfa30b6b792cefdaf06929626d7ac83204 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 00:16:20 +0200 Subject: [PATCH] f-50: lab: cycle de vie du serveur de lab Scaleway #50 Signed-off-by: GnomeZworc --- docs/developpement/index.rst | 10 + docs/developpement/lab.rst | 247 +++++++++++++ docs/index.rst | 9 + scripts/lab-host.sh | 396 ++++++++++++++++++++ scripts/lab-host_test.sh | 685 +++++++++++++++++++++++++++++++++++ 5 files changed, 1347 insertions(+) create mode 100644 docs/developpement/index.rst create mode 100644 docs/developpement/lab.rst create mode 100644 scripts/lab-host.sh create mode 100644 scripts/lab-host_test.sh diff --git a/docs/developpement/index.rst b/docs/developpement/index.rst new file mode 100644 index 0000000..2d78704 --- /dev/null +++ b/docs/developpement/index.rst @@ -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 diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst new file mode 100644 index 0000000..3da14eb --- /dev/null +++ b/docs/developpement/lab.rst @@ -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 `_. 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 + `_. + +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= + SCW_DEFAULT_PROJECT_ID= + 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 [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@ + 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. diff --git a/docs/index.rst b/docs/index.rst index b72be04..8eba581 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh new file mode 100644 index 0000000..56015d7 --- /dev/null +++ b/scripts/lab-host.sh @@ -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 < [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 diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh new file mode 100644 index 0000000..e2a93ed --- /dev/null +++ b/scripts/lab-host_test.sh @@ -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 ]]