f-50: lab: cycle de vie du serveur de lab Scaleway #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
01c67bd0f9
commit
28ce00cfa3
5 changed files with 1347 additions and 0 deletions
10
docs/developpement/index.rst
Normal file
10
docs/developpement/index.rst
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
Développement
|
||||
=============
|
||||
|
||||
Outillage réservé au développement de two : il ne s'installe sur aucun hyperviseur et ne sert
|
||||
pas à exploiter un cluster.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
lab
|
||||
247
docs/developpement/lab.rst
Normal file
247
docs/developpement/lab.rst
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
Lab de test multi-nœud
|
||||
======================
|
||||
|
||||
Ce qui fait l'intérêt de two ne se voit qu'à partir de **deux hyperviseurs** : sur un nœud isolé,
|
||||
le trafic reste sur le bridge local et l'absence de plan de contrôle passe inaperçue (voir
|
||||
:doc:`/deploiement/architecture-cluster`). Le lab reproduit la topologie du cluster —
|
||||
hyperviseurs, route reflector, switch L3 — sous forme de VM, sur un serveur physique loué à
|
||||
l'heure.
|
||||
|
||||
Le pourquoi des choix (serveur physique plutôt que VM cloud, câbles QEMU, MTU 9000, versions) est
|
||||
consigné sur le ticket `#50 <https://git.g3e.fr/syonad/two/issues/50>`_. Cette page décrit
|
||||
comment s'en servir.
|
||||
|
||||
.. note::
|
||||
|
||||
État actuel : seule l'étape **E0** est livrée — le cycle de vie du serveur qui portera le lab,
|
||||
avec ``scripts/lab-host.sh``. La description de la topologie et le lancement des VM viendront
|
||||
avec les étapes suivantes, et cette page avec elles.
|
||||
|
||||
Le serveur de lab
|
||||
-----------------
|
||||
|
||||
Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé.
|
||||
|
||||
.. list-table::
|
||||
:widths: 25 75
|
||||
|
||||
* - Offre
|
||||
- ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans
|
||||
frais de mise en service
|
||||
* - Matériel
|
||||
- 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD
|
||||
* - Système
|
||||
- Debian 12, installé par Scaleway à la création
|
||||
* - Pourquoi Intel
|
||||
- lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations
|
||||
distinctes (``kvm_intel``, ``kvm_amd``)
|
||||
|
||||
*Or equivalent* n'est pas une clause de style : le premier serveur livré était un
|
||||
**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever
|
||||
``lscpu`` au début de chaque campagne.
|
||||
|
||||
Prérequis côté Scaleway
|
||||
-----------------------
|
||||
|
||||
#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime
|
||||
tout serveur de lab du projet : il ne doit rien y avoir d'autre.
|
||||
#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH
|
||||
du projet. Rien d'autre.
|
||||
#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle,
|
||||
le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de
|
||||
continuer.
|
||||
#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et*
|
||||
l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation →
|
||||
Quotas → Elastic Metal. Voir `les quotas Scaleway
|
||||
<https://www.scaleway.com/en/docs/organizations-and-projects/organization/organization-quotas/>`_.
|
||||
|
||||
Fichiers locaux
|
||||
---------------
|
||||
|
||||
Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt.
|
||||
|
||||
``~/.config/two-lab/scaleway.env``
|
||||
Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
SCW_SECRET_KEY=<clé secrète>
|
||||
SCW_DEFAULT_PROJECT_ID=<identifiant du projet de lab>
|
||||
SCW_DEFAULT_ZONE=fr-par-1
|
||||
|
||||
* le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par
|
||||
d'autres que son propriétaire ;
|
||||
* il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ;
|
||||
* une variable d'environnement du même nom l'emporte sur le fichier ;
|
||||
* la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé
|
||||
secrète, dans l'en-tête ``X-Auth-Token``.
|
||||
|
||||
Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier.
|
||||
|
||||
``~/.config/two-lab/ssh/lab_ed25519``
|
||||
Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans
|
||||
intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le
|
||||
script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur
|
||||
l'agent SSH.
|
||||
|
||||
Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur
|
||||
lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une
|
||||
autre :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh
|
||||
ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519
|
||||
|
||||
``~/.cache/two-lab/``
|
||||
État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé
|
||||
par ``down``.
|
||||
|
||||
Commandes
|
||||
---------
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
usage: lab-host.sh <commande> [arguments]
|
||||
|
||||
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
|
||||
et le prix ; ne crée rien
|
||||
up crée le serveur de lab, attend la fin de son installation et son SSH
|
||||
status liste les serveurs de lab du projet
|
||||
ssh [commande] se connecte au serveur de lab
|
||||
down supprime tous les serveurs de lab du projet et attend leur disparition
|
||||
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
|
||||
|
||||
``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**.
|
||||
|
||||
Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH,
|
||||
et montre exactement ce qui serait commandé :
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
$ scripts/lab-host.sh plan
|
||||
== offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available
|
||||
== prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR
|
||||
== os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root
|
||||
== clés : 2 clé(s) SSH du projet
|
||||
== requête : POST /baremetal/v1/zones/fr-par-1/servers
|
||||
|
||||
``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande
|
||||
réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal).
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
$ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm'
|
||||
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
|
||||
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down'
|
||||
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install
|
||||
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing
|
||||
…
|
||||
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed
|
||||
== SSH pas encore joignable, nouvel essai dans 20s
|
||||
…
|
||||
== prêt : root@<adresse>
|
||||
Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux
|
||||
CPU(s): 32
|
||||
Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
|
||||
…
|
||||
Virtualization: VT-x
|
||||
total used free shared buff/cache available
|
||||
Mem: 251 1 250 0 0 249
|
||||
nested=Y
|
||||
crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm
|
||||
== session terminée (code 0), suppression du serveur
|
||||
== suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957
|
||||
== aucun serveur de lab ne reste dans le projet
|
||||
|
||||
Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première
|
||||
campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation —
|
||||
environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de
|
||||
``session`` est celui de la commande distante.
|
||||
|
||||
``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression
|
||||
à la charge de l'utilisateur.
|
||||
|
||||
Facturation
|
||||
-----------
|
||||
|
||||
.. warning::
|
||||
|
||||
Un serveur Elastic Metal est facturé **de sa création à sa suppression, éteint compris**.
|
||||
Éteindre ne suffit pas : il faut supprimer. La granularité n'est pas documentée par Scaleway —
|
||||
compter chaque heure entamée comme une heure pleine.
|
||||
|
||||
Ce que le script garantit :
|
||||
|
||||
* il ne commande **jamais** d'offre mensuelle : il exige une seule offre au nom demandé, en
|
||||
facturation horaire, en stock et sans frais de mise en service, sinon il refuse avant toute
|
||||
création. La CLI ``scw`` n'est pas utilisée pour cette raison : son ``server create type=…``
|
||||
choisit l'offre par son seul nom et peut tomber sur la mensuelle, qui engage un mois ;
|
||||
* il refuse de créer un second serveur si un serveur de lab existe déjà ;
|
||||
* ``down`` agit sur **tous** les serveurs portant le tag ``two-lab`` dans le projet, et
|
||||
``session`` y ajoute l'identifiant reçu à la création : un serveur créé juste avant une
|
||||
interruption est rattrapé ;
|
||||
* une suppression refusée pendant la livraison ou l'installation est réessayée tant qu'elle dure,
|
||||
dans la limite du délai d'installation augmenté du délai de suppression ;
|
||||
* le serveur n'est déclaré supprimé qu'au 404 de l'API, jamais sur une erreur passagère ;
|
||||
* un échec de suppression se termine par ``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` et un code
|
||||
d'erreur.
|
||||
|
||||
Ce qu'il ne peut pas garantir : un ``SIGKILL``, une coupure de courant ou une mise en veille du
|
||||
poste qui lance la session. En cas de doute, toujours :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
scripts/lab-host.sh status
|
||||
scripts/lab-host.sh down
|
||||
|
||||
Diagnostic
|
||||
----------
|
||||
|
||||
``aucune clé SSH active dans le projet``
|
||||
Aucune clé SSH n'est enregistrée dans le projet de lab. En ajouter une dans la console (projet
|
||||
→ Clés SSH).
|
||||
|
||||
``offre horaire EM-B212X-SSD : 0 correspondance(s)``
|
||||
L'offre n'existe pas dans la zone en facturation horaire. Vérifier ``SCW_DEFAULT_ZONE``.
|
||||
|
||||
``création incertaine``
|
||||
La création a échoué ou n'a pas rendu d'identifiant. Le serveur a pu être créé malgré tout —
|
||||
cas typique : le quota (identité non validée), ou une réponse perdue. Le script indique s'il
|
||||
voit un serveur de lab ; dans tous les cas, lancer ``status`` puis ``down``.
|
||||
|
||||
``SSH injoignable``
|
||||
L'installation est terminée mais SSH ne répond pas après 10 minutes. Avec une clé matérielle
|
||||
(Yubikey), chaque connexion demande un PIN ou un toucher : utiliser la clé dédiée du lab. Le
|
||||
serveur est toujours facturé : ``down``.
|
||||
|
||||
``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)``
|
||||
La suppression n'a pas abouti dans les délais. Relancer ``down`` ; si l'erreur persiste,
|
||||
supprimer depuis la console Scaleway.
|
||||
|
||||
``HTTP 403 insufficient permissions``
|
||||
La clé d'API est authentifiée mais n'a pas le droit demandé — en général la lecture des clés
|
||||
SSH du projet. Compléter la politique de la clé, limitée au projet.
|
||||
|
||||
Sécurité
|
||||
--------
|
||||
|
||||
* La clé secrète n'apparaît ni dans les arguments des processus (elle est passée à ``curl`` par
|
||||
un descripteur de fichier), ni dans les journaux, ni dans l'environnement de ``ssh``.
|
||||
* **Ne jamais lancer le script sous** ``bash -x`` : la trace afficherait la clé.
|
||||
* Le serveur n'expose que SSH, par clé. Le lab n'a aucune donnée personnelle ni secret de
|
||||
production.
|
||||
* Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal
|
||||
partagé, capture d'écran) se régénère.
|
||||
|
||||
Tests
|
||||
-----
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
bash scripts/lab-host_test.sh
|
||||
|
||||
Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway
|
||||
qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets,
|
||||
suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client.
|
||||
Elle tourne sous bash 5 comme sous le bash 3.2 de macOS.
|
||||
|
|
@ -40,6 +40,9 @@ Par où commencer
|
|||
:doc:`/exploitation/index`
|
||||
Configuration, services, API de l'agent, métriques et diagnostic sur un nœud en service.
|
||||
|
||||
:doc:`/developpement/index`
|
||||
Outillage de développement de two, dont le lab de test multi-nœud sur serveur loué à l'heure.
|
||||
|
||||
:doc:`/concepts/index`
|
||||
Comment les éléments fonctionnent entre eux : modèle de données, modes réseau, cycle de vie,
|
||||
metadata. À lire avant de diagnostiquer un comportement inattendu.
|
||||
|
|
@ -57,6 +60,12 @@ Par où commencer
|
|||
|
||||
exploitation/index
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:caption: Développement
|
||||
|
||||
developpement/index
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:caption: Interne
|
||||
|
|
|
|||
396
scripts/lab-host.sh
Normal file
396
scripts/lab-host.sh
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
API_URL="${SCW_API_URL:-https://api.scaleway.com}"
|
||||
CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}"
|
||||
OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}"
|
||||
OS_NAME="${LAB_OS_NAME:-Debian}"
|
||||
OS_VERSION="${LAB_OS_VERSION:-12}"
|
||||
LAB_NAME="${LAB_NAME:-two-lab}"
|
||||
LAB_TAG="${LAB_TAG:-two-lab}"
|
||||
INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}"
|
||||
DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}"
|
||||
SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}"
|
||||
POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}"
|
||||
HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}"
|
||||
HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}"
|
||||
STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}"
|
||||
SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}"
|
||||
CREATED_ID=""
|
||||
|
||||
info () { echo "== ${1}" >&2; }
|
||||
warn () { echo "!! ${1}" >&2; }
|
||||
die () { echo "!! ${1}" >&2; exit 1; }
|
||||
|
||||
usage () {
|
||||
cat >&2 <<EOF
|
||||
usage: ${0##*/} <commande> [arguments]
|
||||
|
||||
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
|
||||
et le prix ; ne crée rien
|
||||
up crée le serveur de lab, attend la fin de son installation et son SSH
|
||||
status liste les serveurs de lab du projet
|
||||
ssh [commande] se connecte au serveur de lab
|
||||
down supprime tous les serveurs de lab du projet et attend leur disparition
|
||||
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
|
||||
|
||||
environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE},
|
||||
fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}),
|
||||
LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}),
|
||||
LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH)
|
||||
|
||||
ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
load_credentials () {
|
||||
local KEY VALUE
|
||||
if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then
|
||||
[[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \
|
||||
|| die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600"
|
||||
while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do
|
||||
case "${KEY}" in
|
||||
SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;;
|
||||
SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;;
|
||||
SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;;
|
||||
esac
|
||||
done < "${CREDENTIALS_FILE}"
|
||||
fi
|
||||
ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}"
|
||||
PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}"
|
||||
}
|
||||
|
||||
require_env () {
|
||||
[[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement"
|
||||
[[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement"
|
||||
command -v jq >/dev/null 2>&1 || die "jq est requis"
|
||||
command -v curl >/dev/null 2>&1 || die "curl est requis"
|
||||
}
|
||||
|
||||
http_code () {
|
||||
cat "${STATE_DIR}/last_http_code" 2>/dev/null
|
||||
}
|
||||
|
||||
api () {
|
||||
local METHOD="${1}"
|
||||
local URL_PATH="${2}"
|
||||
local BODY="${3:-}"
|
||||
local OUT CODE
|
||||
local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}'
|
||||
--connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}")
|
||||
|
||||
: > "${STATE_DIR}/last_http_code"
|
||||
[[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}")
|
||||
OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \
|
||||
|| { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; }
|
||||
CODE="${OUT##*$'\n'}"
|
||||
OUT="${OUT%$'\n'*}"
|
||||
printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code"
|
||||
if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then
|
||||
warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}"
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "${OUT}"
|
||||
}
|
||||
|
||||
http_failure_is_final () {
|
||||
local CODE
|
||||
CODE=$(http_code)
|
||||
[[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]]
|
||||
}
|
||||
|
||||
money () {
|
||||
jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end"
|
||||
}
|
||||
|
||||
resolve_offer () {
|
||||
local JSON OFFER COUNT
|
||||
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \
|
||||
|| die "impossible de lister les offres"
|
||||
OFFER=$(jq -c --arg n "${OFFER_NAME}" \
|
||||
'[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}")
|
||||
COUNT=$(jq 'length' <<< "${OFFER}")
|
||||
[[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue"
|
||||
OFFER=$(jq -c '.[0]' <<< "${OFFER}")
|
||||
[[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée"
|
||||
[[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}"
|
||||
[[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \
|
||||
|| die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus"
|
||||
printf '%s\n' "${OFFER}"
|
||||
}
|
||||
|
||||
resolve_os () {
|
||||
local OFFER_ID="${1}"
|
||||
local JSON OS COUNT
|
||||
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \
|
||||
|| die "impossible de lister les OS"
|
||||
OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \
|
||||
'[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase))
|
||||
and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}")
|
||||
COUNT=$(jq 'length' <<< "${OS}")
|
||||
if [[ "${COUNT}" -ne 1 ]]; then
|
||||
warn "OS disponibles pour ${OFFER_NAME} :"
|
||||
jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2
|
||||
die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue"
|
||||
fi
|
||||
jq -c '.[0]' <<< "${OS}"
|
||||
}
|
||||
|
||||
resolve_ssh_keys () {
|
||||
local JSON
|
||||
JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \
|
||||
|| die "impossible de lister les clés SSH du projet"
|
||||
JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}")
|
||||
[[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}"
|
||||
printf '%s\n' "${JSON}"
|
||||
}
|
||||
|
||||
build_request () {
|
||||
local OFFER="${1}"
|
||||
local OS="${2}"
|
||||
local KEYS="${3}"
|
||||
jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \
|
||||
--arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \
|
||||
'{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)",
|
||||
tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}'
|
||||
}
|
||||
|
||||
lab_servers () {
|
||||
local JSON
|
||||
JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \
|
||||
|| return 1
|
||||
jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \
|
||||
'[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}"
|
||||
}
|
||||
|
||||
server_ip () {
|
||||
jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty'
|
||||
}
|
||||
|
||||
server_is_installing () {
|
||||
jq -e '.status == "delivering" or .status == "ordered"
|
||||
or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null
|
||||
}
|
||||
|
||||
cmd_plan () {
|
||||
local OFFER OS KEYS
|
||||
OFFER=$(resolve_offer) || exit 1
|
||||
OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
|
||||
KEYS=$(resolve_ssh_keys) || exit 1
|
||||
info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")"
|
||||
info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")"
|
||||
info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")"
|
||||
info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet"
|
||||
info "requête : POST /baremetal/v1/zones/${ZONE}/servers"
|
||||
build_request "${OFFER}" "${OS}" "${KEYS}" | jq .
|
||||
}
|
||||
|
||||
wait_installed () {
|
||||
local ID="${1}"
|
||||
local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT ))
|
||||
local JSON STATUS INSTALL
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then
|
||||
if http_failure_is_final; then
|
||||
warn "erreur définitive HTTP $(http_code) en attendant l'installation"
|
||||
return 1
|
||||
fi
|
||||
sleep "${POLL_INTERVAL}"
|
||||
continue
|
||||
fi
|
||||
STATUS=$(jq -r '.status' <<< "${JSON}")
|
||||
INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}")
|
||||
info "serveur ${ID} : ${STATUS}, installation ${INSTALL}"
|
||||
case "${STATUS}/${INSTALL}" in
|
||||
ready/completed) printf '%s\n' "${JSON}"; return 0 ;;
|
||||
error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*)
|
||||
warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;;
|
||||
esac
|
||||
sleep "${POLL_INTERVAL}"
|
||||
done
|
||||
warn "installation non terminée après ${INSTALL_TIMEOUT}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
ssh_run () {
|
||||
local -a OPTS=()
|
||||
while [[ $# -gt 0 && "${1}" != "--" ]]; do
|
||||
OPTS+=("${1}")
|
||||
shift
|
||||
done
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
[[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none)
|
||||
env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \
|
||||
"${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@"
|
||||
}
|
||||
|
||||
wait_ssh () {
|
||||
local DEADLINE=$(( SECONDS + SSH_TIMEOUT ))
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0
|
||||
info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s"
|
||||
sleep "${POLL_INTERVAL}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
require_no_lab_server () {
|
||||
local EXISTING
|
||||
EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab"
|
||||
[[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \
|
||||
|| die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'"
|
||||
}
|
||||
|
||||
report_uncertain_creation () {
|
||||
local FOUND
|
||||
if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then
|
||||
warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")"
|
||||
fi
|
||||
die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'"
|
||||
}
|
||||
|
||||
cmd_up () {
|
||||
local OFFER OS KEYS BODY JSON ID IP SSH_USER
|
||||
require_no_lab_server
|
||||
OFFER=$(resolve_offer) || exit 1
|
||||
OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
|
||||
KEYS=$(resolve_ssh_keys) || exit 1
|
||||
BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}")
|
||||
info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)"
|
||||
JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation
|
||||
ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null)
|
||||
[[ -n "${ID}" ]] || report_uncertain_creation
|
||||
CREATED_ID="${ID}"
|
||||
info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'"
|
||||
JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'"
|
||||
IP=$(server_ip <<< "${JSON}")
|
||||
[[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'"
|
||||
SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}")
|
||||
printf '%s\n' "${IP}" > "${STATE_DIR}/ip"
|
||||
printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user"
|
||||
: > "${STATE_DIR}/known_hosts"
|
||||
wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'"
|
||||
info "prêt : ${SSH_USER:-root}@${IP}"
|
||||
}
|
||||
|
||||
cmd_status () {
|
||||
local SERVERS
|
||||
SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab"
|
||||
if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then
|
||||
info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})"
|
||||
return 0
|
||||
fi
|
||||
jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}"
|
||||
}
|
||||
|
||||
cmd_ssh () {
|
||||
[[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord"
|
||||
ssh_run -- "$@"
|
||||
}
|
||||
|
||||
delete_server () {
|
||||
local ID="${1}"
|
||||
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
|
||||
local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT ))
|
||||
local JSON
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0
|
||||
[[ $(http_code) == "404" ]] && return 0
|
||||
if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then
|
||||
DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
|
||||
(( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}"
|
||||
warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s"
|
||||
else
|
||||
warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s"
|
||||
fi
|
||||
sleep "${POLL_INTERVAL}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_gone () {
|
||||
local ID="${1}"
|
||||
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then
|
||||
[[ $(http_code) == "404" ]] && return 0
|
||||
fi
|
||||
sleep "${POLL_INTERVAL}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
lab_server_ids () {
|
||||
local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
|
||||
local SERVERS
|
||||
while (( SECONDS < DEADLINE )); do
|
||||
if SERVERS=$(lab_servers); then
|
||||
jq -r '.[].id' <<< "${SERVERS}"
|
||||
return 0
|
||||
fi
|
||||
warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s"
|
||||
sleep "${POLL_INTERVAL}"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_down () {
|
||||
local IDS ID FAILED=0
|
||||
if ! IDS=$(lab_server_ids); then
|
||||
warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s"
|
||||
FAILED=1
|
||||
IDS=""
|
||||
fi
|
||||
IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u)
|
||||
for ID in ${IDS}; do
|
||||
info "suppression de ${ID}"
|
||||
if ! delete_server "${ID}"; then
|
||||
warn "suppression de ${ID} toujours refusée"
|
||||
FAILED=1
|
||||
continue
|
||||
fi
|
||||
if ! wait_gone "${ID}"; then
|
||||
warn "${ID} toujours présent après ${DELETE_TIMEOUT}s"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts"
|
||||
[[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway"
|
||||
info "aucun serveur de lab ne reste dans le projet"
|
||||
}
|
||||
|
||||
cmd_session () {
|
||||
local RC
|
||||
require_no_lab_server
|
||||
trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 129' HUP
|
||||
cmd_up
|
||||
cmd_ssh "$@"
|
||||
RC=$?
|
||||
info "session terminée (code ${RC}), suppression du serveur"
|
||||
return "${RC}"
|
||||
}
|
||||
|
||||
main () {
|
||||
local COMMAND="${1:-}"
|
||||
[[ $# -gt 0 ]] && shift
|
||||
load_credentials
|
||||
[[ -n "${COMMAND}" ]] || usage
|
||||
mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}"
|
||||
chmod 700 "${STATE_DIR}"
|
||||
case "${COMMAND}" in
|
||||
plan) require_env; cmd_plan ;;
|
||||
up) require_env; cmd_up ;;
|
||||
status) require_env; cmd_status ;;
|
||||
ssh) cmd_ssh "$@" ;;
|
||||
down) require_env; cmd_down ;;
|
||||
session) require_env; cmd_session "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
685
scripts/lab-host_test.sh
Normal file
685
scripts/lab-host_test.sh
Normal file
|
|
@ -0,0 +1,685 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lab-host.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SECRET="secret-de-test-a1b2c3"
|
||||
PROJECT="11111111-1111-1111-1111-111111111111"
|
||||
OTHER_PROJECT="22222222-2222-2222-2222-222222222222"
|
||||
HOURLY_ID="offer-hourly-0001"
|
||||
MONTHLY_ID="offer-monthly-0001"
|
||||
|
||||
fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; }
|
||||
|
||||
write_fakes () {
|
||||
local DIR="${1}"
|
||||
mkdir -p "${DIR}/bin"
|
||||
cat > "${DIR}/bin/curl" <<'PY'
|
||||
#!/usr/bin/env python3
|
||||
import json, os, sys, time, urllib.parse
|
||||
|
||||
d = os.environ["FAKE_DIR"]
|
||||
args = sys.argv[1:]
|
||||
with open(os.path.join(d, "argv.log"), "a") as f:
|
||||
f.write(json.dumps(args) + "\n")
|
||||
|
||||
method, body, headers, url = "GET", None, [], None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "-X":
|
||||
method = args[i + 1]; i += 2; continue
|
||||
if a == "-H":
|
||||
h = args[i + 1]
|
||||
if h.startswith("@"):
|
||||
with open(h[1:]) as f:
|
||||
headers += [l.strip() for l in f if l.strip()]
|
||||
else:
|
||||
headers.append(h)
|
||||
i += 2; continue
|
||||
if a == "--data":
|
||||
body = args[i + 1]; i += 2; continue
|
||||
if a in ("-w", "--max-time", "--connect-timeout"):
|
||||
i += 2; continue
|
||||
if a.startswith("-"):
|
||||
i += 1; continue
|
||||
url = a; i += 1
|
||||
|
||||
state_path = os.path.join(d, "state.json")
|
||||
with open(state_path) as f:
|
||||
st = json.load(f)
|
||||
|
||||
def reply(code, obj):
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(st, f)
|
||||
sys.stdout.write(json.dumps(obj) + "\n" + str(code))
|
||||
sys.exit(0)
|
||||
|
||||
u = urllib.parse.urlparse(url)
|
||||
path = u.path
|
||||
with open(os.path.join(d, "calls.log"), "a") as f:
|
||||
f.write(method + " " + path + ("?" + u.query if u.query else "") + "\n")
|
||||
|
||||
if "X-Auth-Token: " + os.environ["FAKE_SECRET"] not in headers:
|
||||
reply(401, {"message": "unauthorized"})
|
||||
|
||||
zone = "/baremetal/v1/zones/fr-par-1"
|
||||
if method == "GET" and path == zone + "/offers":
|
||||
reply(200, {"offers": st["offers"], "total_count": len(st["offers"])})
|
||||
if method == "GET" and path == zone + "/os":
|
||||
reply(200, {"os": st["os"], "total_count": len(st["os"])})
|
||||
if method == "GET" and path == "/iam/v1alpha1/ssh-keys":
|
||||
reply(200, {"ssh_keys": st["keys"], "total_count": len(st["keys"])})
|
||||
if method == "GET" and path == zone + "/servers":
|
||||
if st["list_errors"] > 0:
|
||||
st["list_errors"] -= 1
|
||||
reply(503, {"message": "service unavailable"})
|
||||
listed = [v for v in st["servers"].values() if not v.get("hidden")]
|
||||
reply(200, {"servers": listed, "total_count": len(listed)})
|
||||
if method == "POST" and path == zone + "/servers":
|
||||
req = json.loads(body)
|
||||
with open(os.path.join(d, "create.json"), "w") as f:
|
||||
json.dump(req, f)
|
||||
st["seq"] += 1
|
||||
sid = "srv-%d" % st["seq"]
|
||||
st["servers"][sid] = {"id": sid, "name": req["name"], "project_id": req["project_id"],
|
||||
"tags": req["tags"], "offer_name": "EM-B212X-SSD", "status": "delivering",
|
||||
"install": {"status": "to_install"}, "ips": [], "created_at": "now",
|
||||
"hidden": st["hide_from_list"]}
|
||||
if st["post_fail_after_create"]:
|
||||
reply(502, {"message": "bad gateway"})
|
||||
if st["post_no_id"]:
|
||||
reply(200, {})
|
||||
reply(200, st["servers"][sid])
|
||||
if path.startswith(zone + "/servers/"):
|
||||
sid = path.rsplit("/", 1)[1]
|
||||
if sid not in st["servers"]:
|
||||
reply(404, {"message": "not found"})
|
||||
srv = st["servers"][sid]
|
||||
if method == "GET" and st["get_errors"] > 0:
|
||||
st["get_errors"] -= 1
|
||||
reply(503, {"message": "service unavailable"})
|
||||
if method == "GET" and srv["status"] == "deleting":
|
||||
if st["linger"] > 0:
|
||||
st["linger"] -= 1
|
||||
reply(200, srv)
|
||||
del st["servers"][sid]
|
||||
reply(404, {"message": "not found"})
|
||||
if method == "GET" and st["vanish_after"] == 0:
|
||||
del st["servers"][sid]
|
||||
reply(404, {"message": "not found"})
|
||||
if method == "GET" and st["vanish_after"] > 0:
|
||||
st["vanish_after"] -= 1
|
||||
if method == "GET":
|
||||
if st["progress"]:
|
||||
status, install = st["progress"].pop(0)
|
||||
srv["status"] = status
|
||||
srv["install"]["status"] = install
|
||||
if status == "ready":
|
||||
srv["ips"] = [{"address": "2001:db8::1", "version": "IPv6"},
|
||||
{"address": "203.0.113.7", "version": "IPv4"}]
|
||||
reply(200, srv)
|
||||
if method == "DELETE":
|
||||
if time.time() < st["refuse_until"]:
|
||||
reply(409, {"message": "server is installing"})
|
||||
if st["delete_refusals"] > 0:
|
||||
st["delete_refusals"] -= 1
|
||||
reply(409, {"message": "server is installing"})
|
||||
if st["linger"] > 0:
|
||||
srv["status"] = "deleting"
|
||||
else:
|
||||
del st["servers"][sid]
|
||||
reply(200, srv)
|
||||
reply(500, {"message": "route inconnue du faux serveur : " + method + " " + path})
|
||||
PY
|
||||
cat > "${DIR}/bin/ssh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "${FAKE_DIR}/ssh.log"
|
||||
printf 'key=%s\n' "${SCW_SECRET_KEY:-}" >> "${FAKE_DIR}/ssh-env.log"
|
||||
case "$*" in
|
||||
*BatchMode=yes*)
|
||||
N=$(cat "${FAKE_DIR}/probe_fails" 2>/dev/null || echo 0)
|
||||
if [ "${N}" -gt 0 ]; then
|
||||
echo $(( N - 1 )) > "${FAKE_DIR}/probe_fails"
|
||||
exit 255
|
||||
fi
|
||||
exit 0 ;;
|
||||
esac
|
||||
[ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}"
|
||||
exit "${FAKE_SSH_RC:-0}"
|
||||
SH
|
||||
chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh"
|
||||
}
|
||||
|
||||
base_state () {
|
||||
jq -n --arg h "${HOURLY_ID}" --arg m "${MONTHLY_ID}" --arg p "${PROJECT}" --arg o "${OTHER_PROJECT}" '{
|
||||
seq: 0, progress: [], delete_refusals: 0, linger: 0, get_errors: 0, list_errors: 0,
|
||||
hide_from_list: false, refuse_until: 0, post_fail_after_create: false, post_no_id: false, vanish_after: -1,
|
||||
offers: [
|
||||
{id: $m, name: "EM-B212X-SSD", subscription_period: "monthly", enable: true, stock: "available",
|
||||
price_per_hour: null, price_per_month: {currency_code: "EUR", units: 115, nanos: 990000000},
|
||||
fee: {currency_code: "EUR", units: 115, nanos: 990000000}},
|
||||
{id: "offer-other", name: "EM-A610R-NVMe", subscription_period: "hourly", enable: true, stock: "available",
|
||||
price_per_hour: {currency_code: "EUR", units: 0, nanos: 110000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}},
|
||||
{id: $h, name: "EM-B212X-SSD", subscription_period: "hourly", enable: true, stock: "available",
|
||||
price_per_hour: {currency_code: "EUR", units: 0, nanos: 321000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}}
|
||||
],
|
||||
os: [
|
||||
{id: "os-deb11", name: "Debian", version: "11 (Bullseye)", enabled: true, allowed: true, user: {default_value: "debian"}},
|
||||
{id: "os-deb12", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true, user: {default_value: "debian"}},
|
||||
{id: "os-deb13", name: "Debian", version: "13 (Trixie)", enabled: true, allowed: true, user: {default_value: "debian"}},
|
||||
{id: "os-ubu", name: "Ubuntu", version: "24.04 LTS (Noble Numbat)", enabled: true, allowed: true, user: {default_value: "ubuntu"}}
|
||||
],
|
||||
keys: [{id: "key-1", disabled: false}, {id: "key-off", disabled: true}],
|
||||
servers: {
|
||||
"foreign-1": {id: "foreign-1", name: "prod-db", project_id: $o, tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []},
|
||||
"foreign-2": {id: "foreign-2", name: "autre", project_id: $p, tags: ["autre"], status: "ready", install: {status: "completed"}, ips: []}
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
setup () {
|
||||
CURRENT="${1}"
|
||||
CASE_OK=1
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/labhost.XXXXXX")
|
||||
write_fakes "${WORK}"
|
||||
base_state > "${WORK}/state.json"
|
||||
: > "${WORK}/calls.log"
|
||||
: > "${WORK}/argv.log"
|
||||
: > "${WORK}/ssh.log"
|
||||
: > "${WORK}/ssh-env.log"
|
||||
}
|
||||
|
||||
mutate () {
|
||||
local TMP
|
||||
TMP=$(jq "${1}" "${WORK}/state.json") && printf '%s\n' "${TMP}" > "${WORK}/state.json"
|
||||
}
|
||||
|
||||
lab_env () {
|
||||
local EXEC=""
|
||||
[[ "${1}" == "exec" ]] && { EXEC="exec"; shift; }
|
||||
${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \
|
||||
FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \
|
||||
FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" \
|
||||
SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \
|
||||
SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \
|
||||
LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \
|
||||
LAB_SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-60}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
run_lab () {
|
||||
lab_env bash "${SCRIPT}" "$@" > "${WORK}/out.log" 2>&1
|
||||
}
|
||||
|
||||
remaining () {
|
||||
jq -r '.servers | keys | sort | join(",")' "${WORK}/state.json"
|
||||
}
|
||||
|
||||
teardown () {
|
||||
[[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}"
|
||||
[[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; }
|
||||
rm -rf "${WORK}"
|
||||
}
|
||||
|
||||
test_plan_picks_hourly_offer_even_when_monthly_is_listed_first () {
|
||||
setup "plan : offre horaire choisie même si la mensuelle est listée d'abord"
|
||||
run_lab plan || fail "code de sortie $?"
|
||||
grep -q "\"offer_id\": \"${HOURLY_ID}\"" "${WORK}/out.log" || fail "offre horaire absente de la requête"
|
||||
grep -q "${MONTHLY_ID}" "${WORK}/out.log" && fail "offre mensuelle présente dans la sortie"
|
||||
grep -q "^POST" "${WORK}/calls.log" && fail "plan a créé quelque chose"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_plan_selects_debian_12_only () {
|
||||
setup "plan : Debian 12 et rien d'autre"
|
||||
run_lab plan || fail "code de sortie $?"
|
||||
grep -q '"os_id": "os-deb12"' "${WORK}/out.log" || fail "os-deb12 non retenu"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_plan_passes_only_enabled_ssh_keys () {
|
||||
setup "plan : seules les clés SSH actives sont transmises"
|
||||
run_lab plan || fail "code de sortie $?"
|
||||
grep -q '"key-1"' "${WORK}/out.log" || fail "key-1 absente"
|
||||
grep -q '"key-off"' "${WORK}/out.log" && fail "clé désactivée transmise"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_refuses_offer_with_setup_fee () {
|
||||
setup "refus d'une offre horaire avec frais de mise en service"
|
||||
mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .fee.units) = 30'
|
||||
run_lab up && fail "up a réussi malgré des frais"
|
||||
grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé malgré des frais"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_refuses_when_hourly_offer_missing () {
|
||||
setup "refus quand seule l'offre mensuelle existe"
|
||||
mutate 'del(.offers[] | select(.id == "'"${HOURLY_ID}"'"))'
|
||||
run_lab up && fail "up a réussi sans offre horaire"
|
||||
grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé sur une offre mensuelle"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_refuses_ambiguous_os () {
|
||||
setup "refus d'un OS ambigu"
|
||||
mutate '.os += [{id: "os-deb12b", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true}]'
|
||||
run_lab plan && fail "plan a réussi avec deux Debian 12"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_refuses_out_of_stock () {
|
||||
setup "refus d'une offre en rupture"
|
||||
mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .stock) = "empty"'
|
||||
run_lab up && fail "up a réussi en rupture de stock"
|
||||
grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé en rupture de stock"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_creates_and_waits_for_installation () {
|
||||
setup "up : création puis attente de la fin d'installation"
|
||||
mutate '.progress = [["delivering","to_install"],["ready","installing"],["ready","completed"]]'
|
||||
run_lab up || fail "code de sortie $?"
|
||||
[[ $(jq -r '.offer_id' "${WORK}/create.json") == "${HOURLY_ID}" ]] || fail "offre créée incorrecte"
|
||||
[[ $(jq -r '.tags | join(",")' "${WORK}/create.json") == "two-lab" ]] || fail "tag absent"
|
||||
[[ $(jq -r '.project_id' "${WORK}/create.json") == "${PROJECT}" ]] || fail "projet incorrect"
|
||||
[[ $(cat "${WORK}/.cache/two-lab/ip" 2>/dev/null) == "203.0.113.7" ]] || fail "IPv4 non retenue"
|
||||
[[ $(cat "${WORK}/.cache/two-lab/user" 2>/dev/null) == "debian" ]] || fail "utilisateur non retenu"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_refuses_when_lab_server_exists () {
|
||||
setup "up : refus si un serveur de lab existe déjà"
|
||||
mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
run_lab up && fail "up a réussi"
|
||||
grep -q "^POST" "${WORK}/calls.log" && fail "second serveur créé"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_secret_never_in_argv () {
|
||||
setup "la clé d'API n'apparaît jamais dans les arguments de curl"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
run_lab up || fail "code de sortie $?"
|
||||
grep -q "${SECRET}" "${WORK}/argv.log" && fail "secret visible dans argv"
|
||||
grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_deletes_only_lab_servers_of_project () {
|
||||
setup "down : ne supprime que les serveurs de lab du projet"
|
||||
mutate '.servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
run_lab down || fail "code de sortie $?"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_retries_refused_deletion () {
|
||||
setup "down : réessaie une suppression refusée"
|
||||
mutate '.delete_refusals = 2 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
|
||||
run_lab down || fail "code de sortie $?"
|
||||
[[ $(grep -c "^DELETE" "${WORK}/calls.log") -eq 3 ]] || fail "$(grep -c "^DELETE" "${WORK}/calls.log") DELETE au lieu de 3"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_fails_loudly_when_deletion_keeps_failing () {
|
||||
setup "down : échec bruyant si la suppression n'aboutit jamais"
|
||||
mutate '.delete_refusals = 1000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
|
||||
LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
|
||||
grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_waits_until_server_is_gone () {
|
||||
setup "down : attend que le serveur ait disparu"
|
||||
mutate '.linger = 3 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
run_lab down || fail "code de sortie $?"
|
||||
[[ $(grep -c "^GET /baremetal/v1/zones/fr-par-1/servers/srv-lab$" "${WORK}/calls.log") -ge 4 ]] || fail "disparition non attendue"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_fails_loudly_when_server_never_disappears () {
|
||||
setup "down : échec bruyant si le serveur ne disparaît jamais"
|
||||
mutate '.linger = 100000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
|
||||
grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_does_not_mistake_api_error_for_disappearance () {
|
||||
setup "down : une erreur de l'API n'est pas prise pour une disparition"
|
||||
mutate '.linger = 100000 | .get_errors = 1 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi alors que le serveur existe encore"
|
||||
grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_after_remote_command () {
|
||||
setup "session : serveur supprimé après la commande distante"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
run_lab session uname -a || fail "code de sortie $?"
|
||||
grep -q "uname -a" "${WORK}/ssh.log" || fail "commande distante non lancée"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_and_propagates_failure_of_remote_command () {
|
||||
setup "session : serveur supprimé et code d'échec propagé"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
FAKE_SSH_RC=7 run_lab session false
|
||||
[[ $? -eq 7 ]] || fail "code de sortie différent de 7"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_when_installation_fails () {
|
||||
setup "session : serveur supprimé quand l'installation échoue"
|
||||
mutate '.progress = [["ready","installing"],["ready","error"]]'
|
||||
run_lab session true && fail "session a réussi"
|
||||
grep -q "true" "${WORK}/ssh.log" && fail "commande lancée sur un serveur en échec"
|
||||
grep -q "état terminal en échec : ready/error" "${WORK}/out.log" || fail "échec d'installation non reconnu comme terminal"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_when_installation_times_out () {
|
||||
setup "session : serveur supprimé quand l'installation n'aboutit pas"
|
||||
mutate '.progress = [range(0; 100000) | ["delivering","to_install"]]'
|
||||
LAB_INSTALL_TIMEOUT=1 run_lab session true && fail "session a réussi"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_does_not_touch_existing_lab_server () {
|
||||
setup "session : ne supprime pas un serveur de lab lancé à côté"
|
||||
mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
run_lab session true && fail "session a réussi"
|
||||
grep -q "^DELETE" "${WORK}/calls.log" && fail "suppression lancée"
|
||||
[[ $(remaining) == "foreign-1,foreign-2,srv-old" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_missing_credentials_are_refused_before_any_call () {
|
||||
setup "refus sans clé d'API, avant tout appel"
|
||||
env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" FAKE_DIR="${WORK}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 \
|
||||
&& fail "plan a réussi sans clé"
|
||||
[[ -s "${WORK}/calls.log" ]] && fail "appel réseau sans clé"
|
||||
teardown
|
||||
}
|
||||
|
||||
LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
|
||||
INSTALLING_LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
|
||||
|
||||
test_down_keeps_retrying_while_server_is_installing () {
|
||||
setup "down : réessaie au-delà du délai tant que le serveur s'installe"
|
||||
mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 5)"
|
||||
LAB_DELETE_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 run_lab down || fail "code de sortie $?"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
grep -q "pendant la livraison ou l'installation" "${WORK}/out.log" || fail "installation non reconnue"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_gives_up_on_installing_server_after_hard_deadline () {
|
||||
local RC
|
||||
setup "down : abandonne quand même après délai d'installation + délai de suppression"
|
||||
mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 100000)"
|
||||
LAB_DELETE_TIMEOUT=2 LAB_INSTALL_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 lab_env timeout 30 bash "${SCRIPT}" down > "${WORK}/out.log" 2>&1
|
||||
RC=$?
|
||||
[[ "${RC}" -ne 124 ]] || fail "aucun plafond : down tourne encore après 30 s"
|
||||
[[ "${RC}" -ne 0 ]] || fail "down a réussi"
|
||||
grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_retries_listing_servers () {
|
||||
setup "down : réessaie la liste des serveurs"
|
||||
mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 2"
|
||||
run_lab down || fail "code de sortie $?"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_down_fails_loudly_when_listing_never_works () {
|
||||
setup "down : échec bruyant si la liste reste indisponible"
|
||||
mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 100000"
|
||||
LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
|
||||
grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_created_server_even_if_missing_from_list () {
|
||||
setup "session : supprime le serveur créé même absent de la liste"
|
||||
mutate '.hide_from_list = true | .progress = [["ready","completed"]]'
|
||||
run_lab session true || fail "code de sortie $?"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_every_api_call_has_timeouts () {
|
||||
setup "chaque appel à l'API a un délai de connexion et un délai total"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
run_lab up || fail "code de sortie $?"
|
||||
[[ -s "${WORK}/argv.log" ]] || fail "aucun appel"
|
||||
[[ $(jq -s '[.[] | select((index("--max-time") | not) or (index("--connect-timeout") | not))] | length' "${WORK}/argv.log") -eq 0 ]] \
|
||||
|| fail "appel sans délai"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_reports_uncertain_creation_when_post_fails () {
|
||||
setup "up : création incertaine signalée quand le POST échoue"
|
||||
mutate '.post_fail_after_create = true'
|
||||
run_lab up && fail "up a réussi"
|
||||
grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine"
|
||||
grep -q "un serveur de lab existe bien : srv-1" "${WORK}/out.log" || fail "serveur créé non signalé"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_when_post_fails_after_creation () {
|
||||
setup "session : serveur supprimé quand le POST échoue après création"
|
||||
mutate '.post_fail_after_create = true'
|
||||
run_lab session true && fail "session a réussi"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_refuses_response_without_id () {
|
||||
setup "up : réponse de création sans identifiant refusée"
|
||||
mutate '.post_no_id = true'
|
||||
run_lab up && fail "up a réussi"
|
||||
grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine"
|
||||
grep -q "servers/null" "${WORK}/calls.log" && fail "appel sur un identifiant null"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_stops_at_once_when_server_vanishes () {
|
||||
setup "up : arrêt immédiat si le serveur disparaît pendant l'installation"
|
||||
mutate '.vanish_after = 1 | .progress = [["delivering","to_install"]]'
|
||||
LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi"
|
||||
grep -q "erreur définitive HTTP 404" "${WORK}/out.log" || fail "404 non traité comme définitif"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_up_stops_at_once_when_server_is_stopped_during_installation () {
|
||||
setup "up : arrêt immédiat si le serveur passe à l'arrêt pendant l'installation"
|
||||
mutate '.progress = [["delivering","to_install"],["stopped","installing"]]'
|
||||
LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi"
|
||||
grep -q "état terminal en échec : stopped/installing" "${WORK}/out.log" || fail "arrêt non reconnu comme terminal"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_cleanup_survives_a_second_signal () {
|
||||
local PID RC I
|
||||
setup "session : un second signal n'interrompt pas le nettoyage"
|
||||
mutate '.progress = [["ready","completed"]] | .linger = 15'
|
||||
set -m
|
||||
FAKE_SSH_SLEEP=5 LAB_POLL_INTERVAL=0.2 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 &
|
||||
PID=$!
|
||||
set +m
|
||||
for I in $(seq 1 100); do
|
||||
grep -q "sleep-long" "${WORK}/ssh.log" && break
|
||||
sleep 0.1
|
||||
done
|
||||
kill -TERM -- "-${PID}"
|
||||
for I in $(seq 1 100); do
|
||||
grep -q "suppression de srv-1" "${WORK}/out.log" && break
|
||||
sleep 0.1
|
||||
done
|
||||
grep -q "suppression de srv-1" "${WORK}/out.log" || fail "nettoyage jamais commencé"
|
||||
kill -INT -- "-${PID}" 2>/dev/null
|
||||
kill -TERM -- "-${PID}" 2>/dev/null
|
||||
wait "${PID}"
|
||||
RC=$?
|
||||
[[ "${RC}" -eq 143 ]] || fail "code ${RC} au lieu de 143"
|
||||
grep -q "aucun serveur de lab ne reste" "${WORK}/out.log" || fail "nettoyage interrompu"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_ssh_never_receives_api_key () {
|
||||
setup "ssh ne reçoit jamais la clé d'API dans son environnement"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
run_lab session uname || fail "code de sortie $?"
|
||||
[[ -s "${WORK}/ssh-env.log" ]] || fail "ssh jamais lancé"
|
||||
grep -qv '^key=$' "${WORK}/ssh-env.log" && fail "clé transmise à ssh"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_waits_for_ssh_before_running_command () {
|
||||
setup "session : attend que SSH réponde avant la commande"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
echo 3 > "${WORK}/probe_fails"
|
||||
run_lab session uname || fail "code de sortie $?"
|
||||
[[ $(grep -c "BatchMode=yes" "${WORK}/ssh.log") -eq 4 ]] || fail "$(grep -c "BatchMode=yes" "${WORK}/ssh.log") sondes au lieu de 4"
|
||||
[[ $(tail -n 1 "${WORK}/ssh.log") == *" uname" ]] || fail "commande non lancée en dernier"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_deletes_server_when_ssh_never_answers () {
|
||||
setup "session : serveur supprimé si SSH ne répond jamais"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
echo 100000 > "${WORK}/probe_fails"
|
||||
LAB_SSH_TIMEOUT=1 LAB_POLL_INTERVAL=0.2 run_lab session uname && fail "session a réussi"
|
||||
grep -qv "BatchMode=yes" "${WORK}/ssh.log" && fail "commande lancée sans SSH joignable"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_ssh_uses_dedicated_lab_key_only_when_present () {
|
||||
setup "ssh : clé dédiée du lab utilisée seule quand elle existe"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
mkdir -p "${WORK}/.config/two-lab/ssh"
|
||||
: > "${WORK}/.config/two-lab/ssh/lab_ed25519"
|
||||
run_lab session uname || fail "code de sortie $?"
|
||||
grep -q " uname" "${WORK}/ssh.log" || fail "commande non lancée"
|
||||
[[ $(grep -c -- "-i ${WORK}/.config/two-lab/ssh/lab_ed25519 -o IdentitiesOnly=yes -o IdentityAgent=none" "${WORK}/ssh.log") -eq $(wc -l < "${WORK}/ssh.log") ]] \
|
||||
|| fail "une connexion n'a pas utilisé la clé dédiée seule"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_ssh_falls_back_to_agent_without_lab_key () {
|
||||
setup "ssh : agent SSH utilisé quand la clé dédiée est absente"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
run_lab session uname || fail "code de sortie $?"
|
||||
grep -q -- "-i " "${WORK}/ssh.log" && fail "clé imposée alors qu'elle n'existe pas"
|
||||
grep -q "IdentityAgent=none" "${WORK}/ssh.log" && fail "agent désactivé sans clé dédiée"
|
||||
teardown
|
||||
}
|
||||
|
||||
run_lab_without_env_credentials () {
|
||||
env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \
|
||||
FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \
|
||||
LAB_POLL_INTERVAL=0 "$@" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1
|
||||
}
|
||||
|
||||
write_credentials () {
|
||||
mkdir -p "${WORK}/.config/two-lab"
|
||||
printf '%s\n' "$@" > "${WORK}/.config/two-lab/scaleway.env"
|
||||
chmod 600 "${WORK}/.config/two-lab/scaleway.env"
|
||||
}
|
||||
|
||||
test_credentials_file_is_used_when_environment_is_empty () {
|
||||
setup "identifiants : fichier lu quand l'environnement est vide"
|
||||
write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" "SCW_DEFAULT_ZONE=fr-par-1"
|
||||
run_lab_without_env_credentials || fail "code de sortie $?"
|
||||
grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet du fichier non utilisé"
|
||||
grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_credentials_file_without_final_newline_is_read () {
|
||||
setup "identifiants : dernière ligne sans retour à la ligne lue quand même"
|
||||
mkdir -p "${WORK}/.config/two-lab"
|
||||
printf 'SCW_DEFAULT_PROJECT_ID=%s\nSCW_SECRET_KEY=%s' "${PROJECT}" "${SECRET}" > "${WORK}/.config/two-lab/scaleway.env"
|
||||
chmod 600 "${WORK}/.config/two-lab/scaleway.env"
|
||||
run_lab_without_env_credentials || fail "code de sortie $?"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_environment_wins_over_credentials_file () {
|
||||
setup "identifiants : l'environnement l'emporte sur le fichier"
|
||||
write_credentials "SCW_SECRET_KEY=mauvais-secret" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}"
|
||||
run_lab_without_env_credentials SCW_SECRET_KEY="${SECRET}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?"
|
||||
grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement non retenu"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_environment_value_kept_when_file_completes_the_rest () {
|
||||
setup "identifiants : une valeur de l'environnement n'est pas écrasée quand le fichier complète"
|
||||
write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}"
|
||||
run_lab_without_env_credentials SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?"
|
||||
grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement écrasé par le fichier"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_credentials_file_readable_by_others_is_refused () {
|
||||
setup "identifiants : fichier lisible par d'autres refusé, sans aucun appel"
|
||||
write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}"
|
||||
chmod 644 "${WORK}/.config/two-lab/scaleway.env"
|
||||
run_lab_without_env_credentials && fail "plan a réussi"
|
||||
grep -q "chmod 600" "${WORK}/out.log" || fail "pas de consigne de droits"
|
||||
[[ -s "${WORK}/calls.log" ]] && fail "appel réseau avec un fichier exposé"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_credentials_file_is_never_executed () {
|
||||
setup "identifiants : le fichier est lu, jamais exécuté"
|
||||
write_credentials "SCW_SECRET_KEY=\$(touch ${WORK}/pwned)" "touch ${WORK}/pwned2" "SCW_DEFAULT_PROJECT_ID=${PROJECT}"
|
||||
run_lab_without_env_credentials && fail "plan a réussi avec un secret invalide"
|
||||
[[ -e "${WORK}/pwned" || -e "${WORK}/pwned2" ]] && fail "contenu du fichier exécuté"
|
||||
teardown
|
||||
}
|
||||
|
||||
signal_during_session () {
|
||||
local SIGNAL="${1}"
|
||||
local EXPECTED="${2}"
|
||||
local PID RC I
|
||||
setup "session : ${SIGNAL} pendant la commande distante, serveur supprimé, code ${EXPECTED}"
|
||||
mutate '.progress = [["ready","completed"]]'
|
||||
set -m
|
||||
FAKE_SSH_SLEEP=5 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 &
|
||||
PID=$!
|
||||
set +m
|
||||
for I in $(seq 1 100); do
|
||||
grep -q "sleep-long" "${WORK}/ssh.log" && break
|
||||
sleep 0.1
|
||||
done
|
||||
grep -q "sleep-long" "${WORK}/ssh.log" || fail "commande distante jamais lancée"
|
||||
kill "-${SIGNAL}" -- "-${PID}"
|
||||
wait "${PID}"
|
||||
RC=$?
|
||||
[[ "${RC}" -eq "${EXPECTED}" ]] || fail "code ${RC} au lieu de ${EXPECTED}"
|
||||
[[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
|
||||
teardown
|
||||
}
|
||||
|
||||
test_session_handles_term () { signal_during_session TERM 143; }
|
||||
test_session_handles_hup () { signal_during_session HUP 129; }
|
||||
test_session_handles_int () { signal_during_session INT 130; }
|
||||
|
||||
for T in $(declare -F | awk '{print $3}' | grep '^test_'); do
|
||||
"${T}"
|
||||
done
|
||||
echo "${PASS} réussi(s), ${FAIL} échec(s)"
|
||||
[[ "${FAIL}" -eq 0 ]]
|
||||
Loading…
Add table
Add a link
Reference in a new issue