From 90d8128b6c64d6e6eec113ce508c88524d379153 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 16:31:14 +0200 Subject: [PATCH] =?UTF-8?q?f-50:=20lab:=20pr=C3=A9paration=20du=20serveur,?= =?UTF-8?q?=20d=C3=A9p=C3=B4t=20de=20lab=20et=20validation=20sur=20Scalewa?= =?UTF-8?q?y=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/developpement/lab.rst | 120 ++++++++++++++++++++++++++++++--- scripts/lab-host.sh | 68 ++++++++++++++++++- scripts/lab-host_test.sh | 135 ++++++++++++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 13 deletions(-) mode change 100644 => 100755 scripts/lab-host.sh mode change 100644 => 100755 scripts/lab-host_test.sh diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index c81cdd5..8f56789 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,12 +13,12 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** à **E2** livrées, **E3** en cours — le cycle de vie du serveur qui - portera le lab (``scripts/lab-host.sh``), la description de la topologie et le calcul de son - plan (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM - (``lab render``), puis leur lancement (``lab up`` / ``status`` / ``down`` / ``ssh``). Reste à - apprendre à ``lab-host.sh`` à préparer le serveur et à y déposer ``lab``, puis à valider le - tout sur le serveur loué. + État actuel : étapes **E0** à **E3** livrées — le cycle de vie du serveur qui porte le lab + (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan + (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM + (``lab render``), puis leur lancement sur le serveur (``lab up`` / ``status`` / ``down`` / + ``ssh``). Les rôles — FRR sur le switch et le route reflector, two sur les hyperviseurs — + viendront avec l'étape suivante. Le serveur de lab ----------------- @@ -109,9 +109,15 @@ Commandes 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 + up crée le serveur de lab, attend la fin de son installation et son SSH, + puis le prépare (voir prepare) status liste les serveurs de lab du projet - ssh [commande] se connecte au serveur de lab + ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé + que si l'entrée standard en est un + prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie + /dev/kvm et la virtualisation imbriquée ; lancé aussi par up + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et + ~/ ; ensuite : ssh './lab up ' 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 @@ -165,6 +171,21 @@ environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. ``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression à la charge de l'utilisateur. +Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécute sur le serveur +(voir `Lancement des VM`_) : + +.. code-block:: text + + scripts/lab-host.sh up + scripts/lab-host.sh push conf/lab/evpn-2hv.yml + scripts/lab-host.sh ssh './lab up evpn-2hv.yml' + scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1 + scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé + scripts/lab-host.sh down + +``push`` transfère par la connexion SSH du script (``cat`` côté serveur, fichier renommé une fois +complet) : mêmes options, même clé, même ``known_hosts`` que ``ssh``. + Topologie --------- @@ -404,6 +425,21 @@ topologie. n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni pseudo-terminal ni ``\r\n`` dans la sortie. + Comme ``ssh``, ``lab ssh`` recolle ses arguments par des espaces et les confie à un shell + distant — et depuis le Mac, il y en a **deux** : celui du serveur, puis celui de la VM. + Une commande qui contient elle-même des guillemets se passe en une seule chaîne : + + .. code-block:: text + + $ echo | scripts/lab-host.sh ssh './lab ssh hv1 sh -c "exit 42"'; echo "rc 42=$?" + rc 42=0 + $ echo | scripts/lab-host.sh ssh "./lab ssh hv1 'sh -c \"exit 42\"'"; echo "rc 42=$?" + rc 42=42 + + Dans le premier cas, la VM reçoit ``sh -c exit 42`` : ``exit`` sans argument, ``42`` en + ``$0``. Pour plus d'une commande, passer un script sur l'entrée standard : + ``scripts/lab-host.sh ssh "./lab ssh hv1 'sudo bash -s'" < script.sh``. + Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un processus vivant dont la ligne de commande (``/proc//cmdline``) contient ``-name ``. Un PID réutilisé par un autre programme n'est donc jamais signalé. @@ -415,6 +451,74 @@ Un PID réutilisé par un autre programme n'est donc jamais signalé. base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ; **ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur. +Une campagne réelle, de la création du serveur à la première commande sur une VM — sorties du +2026-10-04 : + +.. code-block:: text + + $ scripts/lab-host.sh up + == création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT) + … + == préparation du serveur : qemu, genisoimage, KVM imbriqué + … + qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y + == prêt : root@ + $ scripts/lab-host.sh push conf/lab/evpn-2hv.yml + == compilation de lab (linux/amd64) + == déposés sur le serveur : ~/lab, ~/evpn-2hv.yml — ensuite : lab-host.sh ssh './lab up evpn-2hv.yml' + $ scripts/lab-host.sh ssh './lab up evpn-2hv.yml' + sw1: started + rr1: started + hv1: started + hv2: started + sw1: ready + rr1: ready + hv1: ready + hv2: ready + $ scripts/lab-host.sh ssh './lab status' + node role state pid ssh + sw1 switch running 5158 127.0.0.1:2200 + rr1 rr running 5171 127.0.0.1:2201 + hv1 hypervisor running 5182 127.0.0.1:2202 + hv2 hypervisor running 5196 127.0.0.1:2203 + +``lab up`` a pris **49 secondes**, téléchargement et vérification de l'image (427 Mio) compris ; +l'essentiel du temps d'une campagne est la livraison du serveur (environ 25 minutes avec +``prepare``). + +Vérifié sur le serveur de lab +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, topologie +``evpn-2hv`` : + +.. list-table:: + :header-rows: 1 + :widths: 60 40 + + * - Vérification + - Résultat + * - ``ping -M do -s 8972`` de hv1 à hv2 à travers le switch + - passe + * - ``ping -M do -s 8973`` + - refusé : ``message too long, mtu=9000`` + * - sortie Internet de hv1 + - par ``10.250.0.1`` (le switch), HTTPS 200 ; aucune route IPv6 globale + * - hv1 vers un service TCP du serveur par ``mgmt0`` — sw1, témoin, y parvient (200) + - refusé + * - hv1 vers Internet par ``mgmt0``, route forcée via ``10.0.2.2`` — sw1 y parvient + - refusé + * - ``/dev/kvm`` et ``nested`` dans hv1 + - présent, ``Y`` + * - racine de hv1 (overlay de 20 Gio) + - 20 Go : ``growpart`` agrandit la partition au premier démarrage + * - code de retour à travers ``lab-host.sh ssh`` et ``lab ssh``, sans terminal + - propagé jusqu'au Mac + * - ``scripts/lab-host.sh ssh './lab ssh hv1'`` depuis un terminal + - shell interactif + * - ``lab down`` puis ``lab up`` d'une autre topologie + - conforme + Facturation ----------- diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh old mode 100644 new mode 100755 index 56015d7..412fd6a --- a/scripts/lab-host.sh +++ b/scripts/lab-host.sh @@ -15,6 +15,7 @@ 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}" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CREATED_ID="" info () { echo "== ${1}" >&2; } @@ -29,7 +30,12 @@ usage: ${0##*/} [arguments] 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 + ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé + que si l'entrée standard en est un + prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie + /dev/kvm et la virtualisation imbriquée ; lancé aussi par up + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et + ~/ ; ensuite : ssh './lab up ' 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 @@ -270,6 +276,7 @@ cmd_up () { 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'" + cmd_prepare || die "préparation du serveur échouée — il est toujours facturé : '${0##*/} down'" info "prêt : ${SSH_USER:-root}@${IP}" } @@ -283,9 +290,62 @@ cmd_status () { jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}" } -cmd_ssh () { +require_known_server () { [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord" - ssh_run -- "$@" +} + +cmd_ssh () { + require_known_server + if [[ $# -gt 0 && -t 0 ]]; then + ssh_run -t -- "$@" + else + ssh_run -- "$@" + fi +} + +prepare_script () { + cat <<'EOF' +set -eu +SUDO= +[ "$(id -u)" -eq 0 ] || SUDO=sudo +$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq +$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage +[ "$(id -u)" -eq 0 ] || $SUDO usermod -aG kvm "$(id -un)" +[ -c /dev/kvm ] || { echo "/dev/kvm absent" >&2; exit 1; } +NESTED=$(cat /sys/module/kvm_intel/parameters/nested 2>/dev/null || cat /sys/module/kvm_amd/parameters/nested 2>/dev/null || true) +case "${NESTED}" in + Y|1) ;; + *) echo "virtualisation imbriquée désactivée (nested=${NESTED:-absent})" >&2; exit 1 ;; +esac +echo "qemu $(qemu-system-x86_64 --version | head -n 1), nested=${NESTED}" +EOF +} + +cmd_prepare () { + require_known_server + info "préparation du serveur : qemu, genisoimage, KVM imbriqué" + prepare_script | ssh_run -- bash -s +} + +push_file () { + local SOURCE="${1}" TARGET="${2}" MODE="${3}" + ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}" +} + +cmd_push () { + local TOPOLOGY="${1:-}" + local NAME="${TOPOLOGY##*/}" + local BINARY="${STATE_DIR}/lab" + [[ $# -eq 1 && -n "${TOPOLOGY}" ]] || usage + [[ -f "${TOPOLOGY}" ]] || die "topologie introuvable : ${TOPOLOGY}" + [[ "${NAME}" =~ ^[A-Za-z0-9._-]+$ ]] || die "nom de topologie refusé : ${NAME} (lettres, chiffres, '.', '_', '-')" + require_known_server + info "compilation de lab (linux/amd64)" + (cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \ + || die "compilation de lab échouée" + push_file "${BINARY}" lab 755 || die "envoi de lab échoué" + push_file "${TOPOLOGY}" "${NAME}" 644 || die "envoi de ${NAME} échoué" + info "déposés sur le serveur : ~/lab, ~/${NAME} — ensuite : ${0##*/} ssh './lab up ${NAME}'" } delete_server () { @@ -385,6 +445,8 @@ main () { up) require_env; cmd_up ;; status) require_env; cmd_status ;; ssh) cmd_ssh "$@" ;; + prepare) cmd_prepare || die "préparation du serveur échouée" ;; + push) cmd_push "$@" ;; down) require_env; cmd_down ;; session) require_env; cmd_session "$@" ;; *) usage ;; diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh old mode 100644 new mode 100755 index e2a93ed..9db9776 --- a/scripts/lab-host_test.sh +++ b/scripts/lab-host_test.sh @@ -144,11 +144,28 @@ case "$*" in exit 255 fi exit 0 ;; + *"bash -s"*) + cat > "${FAKE_DIR}/prepare.sh" + exit "${FAKE_PREPARE_RC:-0}" ;; + *"cat > "*) + N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.') + cat > "${FAKE_DIR}/pushed.$(( N + 1 ))" + exit "${FAKE_PUSH_RC:-0}" ;; esac [ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}" exit "${FAKE_SSH_RC:-0}" SH - chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" + cat > "${DIR}/bin/go" <<'SH' +#!/usr/bin/env bash +printf '%s|%s|CGO_ENABLED=%s GOOS=%s GOARCH=%s\n' "${PWD}" "$*" "${CGO_ENABLED:-}" "${GOOS:-}" "${GOARCH:-}" >> "${FAKE_DIR}/go.log" +[ -n "${FAKE_GO_FAIL:-}" ] && exit 1 +while [ $# -gt 0 ]; do + [ "${1}" = "-o" ] && printf 'binaire-lab\n' > "${2}" + shift +done +exit 0 +SH + chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" "${DIR}/bin/go" } base_state () { @@ -200,7 +217,8 @@ lab_env () { [[ "${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:-}" \ + FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" FAKE_PREPARE_RC="${FAKE_PREPARE_RC:-0}" \ + FAKE_PUSH_RC="${FAKE_PUSH_RC:-0}" FAKE_GO_FAIL="${FAKE_GO_FAIL:-}" \ 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}" \ @@ -587,6 +605,119 @@ test_ssh_falls_back_to_agent_without_lab_key () { teardown } +known_server () { + mkdir -p "${WORK}/.cache/two-lab" + echo 203.0.113.7 > "${WORK}/.cache/two-lab/ip" + echo debian > "${WORK}/.cache/two-lab/user" + : > "${WORK}/.cache/two-lab/known_hosts" +} + +test_up_prepares_the_server_after_ssh () { + setup "up : prépare le serveur (qemu, genisoimage, KVM imbriqué) une fois SSH joignable" + mutate '.progress = [["ready","completed"]]' + run_lab up || fail "code de sortie $?" + [[ $(tail -n 1 "${WORK}/ssh.log") == *"debian@203.0.113.7 bash -s" ]] || fail "préparation non lancée en dernier : $(tail -n 1 "${WORK}/ssh.log")" + grep -q "apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage" "${WORK}/prepare.sh" || fail "paquets absents du script" + grep -q 'usermod -aG kvm' "${WORK}/prepare.sh" || fail "groupe kvm absent du script" + grep -q -- '-c /dev/kvm' "${WORK}/prepare.sh" || fail "contrôle de /dev/kvm absent" + grep -q 'kvm_intel/parameters/nested' "${WORK}/prepare.sh" || fail "contrôle de nested absent" + teardown +} + +test_up_reports_a_billed_server_when_preparation_fails () { + setup "up : préparation échouée signalée, serveur toujours facturé" + mutate '.progress = [["ready","completed"]]' + FAKE_PREPARE_RC=1 run_lab up && fail "up a réussi" + grep -q "préparation du serveur échouée — il est toujours facturé" "${WORK}/out.log" || fail "message absent" + grep -q "prêt :" "${WORK}/out.log" && fail "serveur annoncé prêt" + teardown +} + +test_session_deletes_server_when_preparation_fails () { + setup "session : serveur supprimé si la préparation échoue, commande jamais lancée" + mutate '.progress = [["ready","completed"]]' + FAKE_PREPARE_RC=1 run_lab session uname && fail "session a réussi" + grep -q " uname" "${WORK}/ssh.log" && fail "commande lancée malgré la préparation échouée" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_prepare_script_is_valid_shell () { + setup "prepare : le script distant est du shell valide" + known_server + run_lab prepare || fail "code de sortie $?" + bash -n "${WORK}/prepare.sh" || fail "bash -n refuse le script" + sh -n "${WORK}/prepare.sh" || fail "sh -n refuse le script" + teardown +} + +test_prepare_without_known_server_is_refused () { + setup "prepare : refusé sans serveur connu" + run_lab prepare && fail "prepare a réussi" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + teardown +} + +test_push_builds_for_linux_and_sends_binary_and_topology () { + setup "push : compile lab pour linux/amd64, envoie le binaire et la topologie" + known_server + printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml" + run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?" + local REPO + REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)" + [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \ + || fail "compilation : $(cat "${WORK}/go.log")" + grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent" + grep -q "debian@203.0.113.7 cat > 'evpn-2hv.yml.part' && chmod 644 'evpn-2hv.yml.part' && mv 'evpn-2hv.yml.part' 'evpn-2hv.yml'" "${WORK}/ssh.log" || fail "envoi de la topologie absent" + [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")" + [[ $(cat "${WORK}/pushed.2") == "name: evpn-2hv" ]] || fail "contenu de la topologie : $(cat "${WORK}/pushed.2")" + teardown +} + +test_push_stops_when_the_build_fails () { + setup "push : rien n'est envoyé si la compilation échoue" + known_server + printf 'name: x\n' > "${WORK}/x.yml" + FAKE_GO_FAIL=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + grep -q "compilation de lab échouée" "${WORK}/out.log" || fail "message absent" + teardown +} + +test_push_fails_when_a_transfer_fails () { + setup "push : un envoi échoué fait échouer push" + known_server + printf 'name: x\n' > "${WORK}/x.yml" + FAKE_PUSH_RC=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" + grep -q "envoi de lab échoué" "${WORK}/out.log" || fail "message absent" + teardown +} + +test_push_refusals () { + local CASE + for CASE in absent quote nothing; do + setup "push : refus (${CASE})" + known_server + case "${CASE}" in + absent) run_lab push "${WORK}/absent.yml" && fail "push a réussi" ;; + quote) printf 'x\n' > "${WORK}/a'b.yml"; run_lab push "${WORK}/a'b.yml" && fail "push a réussi" ;; + nothing) run_lab push && fail "push a réussi" ;; + esac + [[ -s "${WORK}/go.log" ]] && fail "compilation lancée" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + teardown + done +} + +test_ssh_without_terminal_does_not_ask_for_one () { + setup "ssh : pas de -t quand l'entrée n'est pas un terminal" + known_server + run_lab ssh './lab status' < /dev/null || fail "code de sortie $?" + [[ $(cat "${WORK}/ssh.log") == *"debian@203.0.113.7 ./lab status" ]] || fail "commande : $(cat "${WORK}/ssh.log")" + grep -q -- " -t " "${WORK}/ssh.log" && fail "-t demandé sans terminal" + 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" \