f-50: lab: préparation du serveur, dépôt de lab et validation sur Scaleway #50

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-10-04 16:31:14 +02:00
commit f28a8c2211
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
3 changed files with 310 additions and 13 deletions

View file

@ -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 <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et
~/<topologie> ; ensuite : ssh './lab up <topologie>'
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/<pid>/cmdline``) contient ``-name <nœud>``.
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@<adresse>
$ 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
-----------

68
scripts/lab-host.sh Normal file → Executable file
View file

@ -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##*/} <commande> [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 <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et
~/<topologie> ; ensuite : ssh './lab up <topologie>'
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 ;;

135
scripts/lab-host_test.sh Normal file → Executable file
View file

@ -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" \