From 3f0b565e1e90a9479ab7c3cf957b40d92dabdcf1 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Fri, 9 Oct 2026 17:28:50 +0200 Subject: [PATCH] sauvegarde la base du portail et teste sa restauration chaque semaine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sauvegarde ne couvrait que /etc, /home et /root : la base MariaDB du portail et son .env (APP_KEY) en étaient absents. Ajoute aussi le réglage du quota et du gabarit du projet des labs dans Ansible, la reprise des VM au démarrage de l'hôte (Nova), et le script du test de capacité. Co-Authored-By: Claude Opus 5.5 --- ansible/openstack-projet.yml | 8 + ansible/roles/kolla/tasks/main.yml | 25 +++ .../roles/openstack-projet/defaults/main.yml | 12 ++ ansible/roles/openstack-projet/tasks/main.yml | 39 ++++ ansible/roles/sauvegardes/defaults/main.yml | 11 ++ ansible/roles/sauvegardes/tasks/main.yml | 42 ++++ .../templates/sauvegarde-dedibackup.sh.j2 | 19 +- .../syllabus-test-restauration.sh.j2 | 101 ++++++++++ kolla/config/nova/nova-compute.conf | 7 + kolla/config/prometheus/alertes.rules | 9 + openstack/tests/capacite.py | 186 ++++++++++++++++++ 11 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 ansible/openstack-projet.yml create mode 100644 ansible/roles/openstack-projet/defaults/main.yml create mode 100644 ansible/roles/openstack-projet/tasks/main.yml create mode 100644 ansible/roles/sauvegardes/templates/syllabus-test-restauration.sh.j2 create mode 100644 kolla/config/nova/nova-compute.conf create mode 100644 openstack/tests/capacite.py diff --git a/ansible/openstack-projet.yml b/ansible/openstack-projet.yml new file mode 100644 index 0000000..b46d932 --- /dev/null +++ b/ansible/openstack-projet.yml @@ -0,0 +1,8 @@ +--- +# Étape 13 : gabarits et quotas du projet des labs. +- name: Projet OpenStack des labs + hosts: syllabus + become: true + gather_facts: true + roles: + - openstack-projet diff --git a/ansible/roles/kolla/tasks/main.yml b/ansible/roles/kolla/tasks/main.yml index 7aa2349..aeca8e2 100644 --- a/ansible/roles/kolla/tasks/main.yml +++ b/ansible/roles/kolla/tasks/main.yml @@ -111,6 +111,31 @@ group: root mode: "0600" +- name: créer le dossier des surcharges nova + ansible.builtin.file: + path: "{{ kolla_config_dir }}/config/nova" + state: directory + owner: root + group: root + mode: "0750" + +- name: déployer la surcharge de nova-compute (versionnée, sans secret) + ansible.builtin.copy: + src: "{{ playbook_dir }}/../kolla/config/nova/nova-compute.conf" + dest: "{{ kolla_config_dir }}/config/nova/nova-compute.conf" + owner: root + group: root + mode: "0640" + register: kolla_surcharge_nova + +- name: rappeler de reconfigurer nova si la surcharge a changé + ansible.builtin.debug: + msg: > + Surcharge nova-compute modifiée : lancer + `kolla-ansible reconfigure -i {{ kolla_config_dir }}/all-in-one --tags nova` + sur le serveur (voir docs/runbooks/openstack.md). + when: kolla_surcharge_nova.changed + - name: installer les dépendances Ansible de Kolla (collections Galaxy) ansible.builtin.command: cmd: "{{ kolla_venv }}/bin/kolla-ansible install-deps" diff --git a/ansible/roles/openstack-projet/defaults/main.yml b/ansible/roles/openstack-projet/defaults/main.yml new file mode 100644 index 0000000..01d01a8 --- /dev/null +++ b/ansible/roles/openstack-projet/defaults/main.yml @@ -0,0 +1,12 @@ +--- +# Projet des labs (créé à l'étape 6) : ce rôle garantit son gabarit et ses +# quotas. Le quota est un filet de sécurité ; la vraie limite de capacité est +# la réserve mémoire de Nova (kolla/config/nova/nova-compute.conf), voir +# docs/capacite.md. +openstack_projet: paillasse +openstack_projet_quota: + instances: 40 + cores: 40 + ram: 30720 +openstack_gabarits: + - { nom: eleve.petit, ram: 768, vcpus: 1, disque: 5 } diff --git a/ansible/roles/openstack-projet/tasks/main.yml b/ansible/roles/openstack-projet/tasks/main.yml new file mode 100644 index 0000000..a4d583d --- /dev/null +++ b/ansible/roles/openstack-projet/tasks/main.yml @@ -0,0 +1,39 @@ +--- +# Client openstack de Kolla, compte admin (clouds.yaml généré par post-deploy). +- name: lire les gabarits existants + ansible.builtin.command: openstack flavor list -f json + environment: &os_admin + PATH: "/opt/kolla-venv/bin:{{ ansible_env.PATH }}" + OS_CLIENT_CONFIG_FILE: /etc/kolla/clouds.yaml + OS_CLOUD: kolla-admin + register: openstack_gabarits_existants + changed_when: false + +- name: créer les gabarits manquants + ansible.builtin.command: >- + openstack flavor create --public --ram {{ item.ram }} --vcpus {{ item.vcpus }} + --disk {{ item.disque }} {{ item.nom }} + environment: *os_admin + loop: "{{ openstack_gabarits }}" + when: item.nom not in (openstack_gabarits_existants.stdout | from_json | map(attribute='Name') | list) + +- name: lire le quota de calcul du projet + ansible.builtin.command: openstack quota show --compute {{ openstack_projet }} -f json + environment: *os_admin + register: openstack_quota_brut + changed_when: false + +- name: régler le quota de calcul du projet + ansible.builtin.command: >- + openstack quota set --instances {{ openstack_projet_quota.instances }} + --cores {{ openstack_projet_quota.cores }} --ram {{ openstack_projet_quota.ram }} + {{ openstack_projet }} + environment: *os_admin + vars: + openstack_quota_actuel: >- + {{ dict(openstack_quota_brut.stdout | from_json | map(attribute='Resource') | + zip(openstack_quota_brut.stdout | from_json | map(attribute='Limit'))) }} + when: >- + openstack_quota_actuel.instances != openstack_projet_quota.instances or + openstack_quota_actuel.cores != openstack_projet_quota.cores or + openstack_quota_actuel.ram != openstack_projet_quota.ram diff --git a/ansible/roles/sauvegardes/defaults/main.yml b/ansible/roles/sauvegardes/defaults/main.yml index 793d81c..16f03dd 100644 --- a/ansible/roles/sauvegardes/defaults/main.yml +++ b/ansible/roles/sauvegardes/defaults/main.yml @@ -11,3 +11,14 @@ sauvegardes_repertoires: - /root sauvegardes_age_dir: /root/.age sauvegardes_age_cle: /root/.age/cle-sauvegardes.txt +# Portail (ajouté à l'étape 13) : base exportée par mariadb-dump, et fichiers +# d'état hors de /etc (APP_KEY dans .env, futurs travaux d'élèves). +sauvegardes_portail_conteneur: portail_mariadb +sauvegardes_portail_base: portail +sauvegardes_portail_fichiers: + - /var/www/portail/.env + - /var/www/portail/storage/app +sauvegardes_image_mariadb: "mariadb:11" +sauvegardes_php_version: "8.3" +# Test de restauration hebdomadaire (CLAUDE.md : restauration testée chaque semaine). +sauvegardes_test_restauration_quand: "Mon *-*-* 04:30:00" diff --git a/ansible/roles/sauvegardes/tasks/main.yml b/ansible/roles/sauvegardes/tasks/main.yml index 939d062..c8ee01c 100644 --- a/ansible/roles/sauvegardes/tasks/main.yml +++ b/ansible/roles/sauvegardes/tasks/main.yml @@ -56,6 +56,42 @@ mode: "0644" notify: recharger systemd +- name: déployer le test de restauration du portail (étape 13) + ansible.builtin.template: + src: syllabus-test-restauration.sh.j2 + dest: /usr/local/sbin/syllabus-test-restauration + owner: root + group: root + mode: "0700" + +- name: déployer le service et le minuteur du test de restauration + ansible.builtin.copy: + dest: "/etc/systemd/system/syllabus-test-restauration.{{ item.type }}" + mode: "0644" + content: "{{ item.contenu }}" + loop: + - type: service + contenu: | + # Géré par Ansible (rôle sauvegardes). + [Unit] + Description=Test de restauration de la dernière sauvegarde du portail + After=docker.service network-online.target + Wants=network-online.target + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/syllabus-test-restauration + - type: timer + contenu: | + # Géré par Ansible (rôle sauvegardes). + [Unit] + Description=Test de restauration hebdomadaire du portail + [Timer] + OnCalendar={{ sauvegardes_test_restauration_quand }} + Persistent=true + [Install] + WantedBy=timers.target + notify: recharger systemd + - name: appliquer les changements systemd ansible.builtin.meta: flush_handlers @@ -65,6 +101,12 @@ enabled: true state: started +- name: activer le minuteur du test de restauration + ansible.builtin.systemd: + name: syllabus-test-restauration.timer + enabled: true + state: started + - name: afficher la clé publique age (à connaître, jamais la privée) ansible.builtin.debug: msg: "Clé publique age du serveur : {{ sauvegardes_age_pub.stdout }}" diff --git a/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 index 632038b..c4da74f 100644 --- a/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 +++ b/ansible/roles/sauvegardes/templates/sauvegarde-dedibackup.sh.j2 @@ -25,8 +25,25 @@ trap 'rm -rf "$WORKDIR"' EXIT ARCHIVE_QUOT="quotidienne-${DATE}.tar.zst.age" +# Portail (étape 13) : export cohérent de sa base (--single-transaction, sans +# bloquer le site) et ses fichiers d'état. Le mot de passe root est lu dans +# le conteneur : il n'apparaît jamais sur la ligne de commande de l'hôte. +# Absent sur un serveur neuf (la sauvegarde arrive avant le portail) : ignoré. +EN_PLUS=() +if [ "$(docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ sauvegardes_portail_conteneur }} 2>/dev/null)" = "true" ]; then + docker exec {{ sauvegardes_portail_conteneur }} sh -c \ + 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb-dump -uroot --single-transaction --routines --triggers --databases {{ sauvegardes_portail_base }}' \ + > "$WORKDIR/portail.sql" + EN_PLUS+=(-C "$WORKDIR" portail.sql) +fi +for chemin in {{ sauvegardes_portail_fichiers | join(' ') }}; do + if [ -e "$chemin" ]; then + EN_PLUS+=("$chemin") + fi +done + tar --exclude="{{ sauvegardes_age_dir }}" --exclude='/root/.dedibackup' \ - -I zstd -cf - {{ sauvegardes_repertoires | join(' ') }} \ + -I zstd -cf - {{ sauvegardes_repertoires | join(' ') }} "${EN_PLUS[@]}" \ | age -r "$AGE_PUB" -o "$WORKDIR/$ARCHIVE_QUOT" lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e " diff --git a/ansible/roles/sauvegardes/templates/syllabus-test-restauration.sh.j2 b/ansible/roles/sauvegardes/templates/syllabus-test-restauration.sh.j2 new file mode 100644 index 0000000..0ccf010 --- /dev/null +++ b/ansible/roles/sauvegardes/templates/syllabus-test-restauration.sh.j2 @@ -0,0 +1,101 @@ +#!/bin/bash +# Géré par Ansible (rôle sauvegardes). Test de restauration du portail +# (étape 13, puis chaque semaine) : +# 1. télécharge la dernière sauvegarde quotidienne de Dedibackup ; +# 2. la déchiffre (clé age du serveur) et en extrait la base et le .env ; +# 3. restaure la base dans un conteneur MariaDB jetable, isolé ; +# 4. fait tourner le code du portail contre cette copie (migrations, +# comptages), avec l'APP_KEY restaurée ; +# 5. compare les tables à la production et publie une métrique Prometheus. +# Ne modifie jamais la production. N'affiche aucun secret. +set -euo pipefail + +set +x +source /root/.dedibackup + +CLE="{{ sauvegardes_age_cle }}" +APP="/var/www/portail" +CONTENEUR="syllabus-restauration" +DOSSIER=$(mktemp -d /var/tmp/restauration-XXXXXX) +nettoyer() { + docker rm -f "$CONTENEUR" >/dev/null 2>&1 || true + rm -rf "$DOSSIER" +} +trap nettoyer EXIT + +echo "== 1. Dernière sauvegarde quotidienne" +DERNIERE=$(lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ + -e "set ftp:ssl-allow no; cls -1 quotidienne-*.tar.zst.age; quit" | sort | tail -1) +[ -n "$DERNIERE" ] || { echo "aucune sauvegarde trouvée"; exit 1; } +lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ + -e "set ftp:ssl-allow no; get $DERNIERE -o $DOSSIER/archive.age; quit" +echo "$DERNIERE ($(du -h "$DOSSIER/archive.age" | cut -f1))" + +echo "== 2. Déchiffrement et extraction" +mkdir "$DOSSIER/extrait" +age -d -i "$CLE" "$DOSSIER/archive.age" \ + | tar -I zstd -xf - -C "$DOSSIER/extrait" portail.sql var/www/portail/.env +[ -s "$DOSSIER/extrait/portail.sql" ] || { echo "portail.sql absent de l'archive"; exit 1; } +APP_KEY_RESTAUREE=$(grep -oP '^APP_KEY=\K.*' "$DOSSIER/extrait/var/www/portail/.env") +if [ "$APP_KEY_RESTAUREE" = "$(grep -oP '^APP_KEY=\K.*' "$APP/.env")" ]; then + echo "APP_KEY restaurée identique à la production (valeur non affichée)" +else + echo "APP_KEY restaurée DIFFÉRENTE de la production"; exit 1 +fi + +echo "== 3. Restauration dans un conteneur jetable" +MDP=$(openssl rand -hex 16) +# Réseau du portail (Docker tourne sans pont par défaut), aucun port publié : +# seul l'hôte joint ce conteneur, par son adresse interne. +docker run -d --name "$CONTENEUR" --network portail_default \ + -e MARIADB_ROOT_PASSWORD="$MDP" {{ sauvegardes_image_mariadb }} >/dev/null +for _ in $(seq 1 60); do + docker exec "$CONTENEUR" sh -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -uroot -e "SELECT 1"' >/dev/null 2>&1 && break + sleep 1 +done +docker exec -i "$CONTENEUR" sh -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb -uroot' < "$DOSSIER/extrait/portail.sql" +IP=$(docker inspect -f '{% raw %}{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}{% endraw %}' "$CONTENEUR") +echo "base restaurée dans $CONTENEUR" + +echo "== 4. Le portail tourne contre la copie" +# APP_CONFIG_CACHE vers un fichier inexistant : Laravel ignore la config mise +# en cache (qui pointe vers la production) et lit ces variables en priorité. +artisan_copie() { + env APP_CONFIG_CACHE="$DOSSIER/config-absente.php" APP_KEY="$APP_KEY_RESTAUREE" \ + DB_HOST="$IP" DB_PORT=3306 DB_USERNAME=root DB_PASSWORD="$MDP" DB_DATABASE={{ sauvegardes_portail_base }} \ + CACHE_STORE=array SESSION_DRIVER=array QUEUE_CONNECTION=sync LOG_CHANNEL=stderr \ + php{{ sauvegardes_php_version }} "$APP/artisan" "$@" +} +# Échec de connexion = échec du test (set -e), pas un « 0 en attente » trompeur. +STATUT=$(artisan_copie migrate:status) +APPLIQUEES=$(grep -c " Ran" <<<"$STATUT" || true) +EN_ATTENTE=$(grep -c "Pending" <<<"$STATUT" || true) +echo "migrations dans la copie : $APPLIQUEES appliquées, $EN_ATTENTE en attente" +[ "$APPLIQUEES" -gt 0 ] && [ "$EN_ATTENTE" = "0" ] || exit 1 + +echo "== 5. Comparaison avec la production (lignes par table)" +compter() { + docker exec "$1" sh -c 'export MYSQL_PWD="$MARIADB_ROOT_PASSWORD" + for t in $(mariadb -uroot -N -e "SHOW TABLES" {{ sauvegardes_portail_base }}); do + echo "$t $(mariadb -uroot -N -e "SELECT COUNT(*) FROM \`$t\`" {{ sauvegardes_portail_base }})" + done' +} +compter "$CONTENEUR" | sort > "$DOSSIER/copie.txt" +compter {{ sauvegardes_portail_conteneur }} | sort > "$DOSSIER/production.txt" +printf "%-28s %10s %10s\n" table copie production +join -a1 -a2 -e "absente" -o 0,1.2,2.2 "$DOSSIER/copie.txt" "$DOSSIER/production.txt" | while read -r t c p; do + printf "%-28s %10s %10s%s\n" "$t" "$c" "$p" "$([ "$c" = "$p" ] || echo ' ≠ (écart depuis la sauvegarde ?)')" +done +TABLES_MANQUANTES=$(join -v2 "$DOSSIER/copie.txt" "$DOSSIER/production.txt" | wc -l) +[ "$TABLES_MANQUANTES" = "0" ] || { echo "$TABLES_MANQUANTES table(s) de production absente(s) de la copie"; exit 1; } +echo "lab_sessions vues par le portail dans la copie : $(artisan_copie tinker --execute='echo App\Models\LabSession::count();' 2>/dev/null | tail -1)" + +DOSSIER_TEXTFILE="{{ sauvegardes_dossier_textfile | default('/var/lib/node_exporter/textfile') }}" +cat > "$DOSSIER_TEXTFILE/restauration.prom.tmp" < 8 * 86400 + for: 1h + labels: + severite: avertissement + annotations: + summary: "Aucun test de restauration du portail reussi depuis plus de 8 jours" + description: "Voir journalctl -u syllabus-test-restauration et docs/runbooks/sauvegardes.md." + - alert: SauvegardeAncienne expr: (time() - syllabus_derniere_sauvegarde_reussie_timestamp) > 26 * 3600 for: 10m diff --git a/openstack/tests/capacite.py b/openstack/tests/capacite.py new file mode 100644 index 0000000..0642579 --- /dev/null +++ b/openstack/tests/capacite.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Test de capacité (étape 13) : N VM eleve.petit lancées en même temps. + +À lancer en root sur le serveur (client openstack de Kolla, virsh dans le +conteneur nova_libvirt) : + + sudo python3 capacite.py --vm 20 --sortie /root/capacite-AAAA-MM-JJ + +Phases : démarrage simultané, repos, charge CPU, charge mémoire (arrêtée +d'elle-même si la mémoire disponible de l'hôte passe sous --plancher-go). +Crée un réseau « capacite-test » et des VM « capacite-N » dans le projet +paillasse ; ne supprime rien (nettoyage séparé, après accord). +""" +import argparse +import base64 +import csv +import json +import os +import subprocess +import threading +import time + +ENV = dict(os.environ, PATH="/opt/kolla-venv/bin:" + os.environ["PATH"], + OS_CLIENT_CONFIG_FILE="/etc/kolla/clouds-paillasse.yaml", OS_CLOUD="paillasse-service") + + +def openstack(*args): + return subprocess.run(["openstack", *args], env=ENV, check=True, capture_output=True, text=True).stdout + + +def agent(uuid, commande, delai=5): + r = subprocess.run(["docker", "exec", "nova_libvirt", "virsh", "qemu-agent-command", uuid, + json.dumps(commande), "--timeout", str(delai)], capture_output=True, text=True) + return json.loads(r.stdout) if r.returncode == 0 else None + + +def executer(uuid, script, attendre=True, delai=30): + r = agent(uuid, {"execute": "guest-exec", "arguments": {"path": "/bin/sh", "arg": ["-c", script], "capture-output": True}}) + if not r or not attendre: + return r is not None, "" + fin = time.time() + delai + while time.time() < fin: + s = agent(uuid, {"execute": "guest-exec-status", "arguments": {"pid": r["return"]["pid"]}}) + if s and s["return"].get("exited"): + return s["return"].get("exitcode") == 0, base64.b64decode(s["return"].get("out-data", "")).decode() + time.sleep(0.3) + return False, "délai" + + +def meminfo(): + m = {} + for ligne in open("/proc/meminfo"): + cle, val = ligne.split(":") + m[cle] = int(val.split()[0]) // 1024 + return m + + +def cpu_brut(): + v = list(map(int, open("/proc/stat").readline().split()[1:])) + return sum(v), v[3] + v[4] + + +def rss_qemu(): + r = subprocess.run(["ps", "-C", "qemu-system-x86_64,qemu-kvm", "-o", "rss=,args="], capture_output=True, text=True) + total, par_vm = 0, {} + for ligne in r.stdout.splitlines(): + rss, args = ligne.strip().split(None, 1) + mo = int(rss) // 1024 + total += mo + for mot in args.split(): + if mot.startswith("uuid="): + par_vm[mot[5:]] = mo + if "-uuid" in args: + par_vm[args.split("-uuid")[1].split()[0]] = mo + return total, par_vm + + +class Echantillonneur(threading.Thread): + """Relève l'hôte toutes les 2 s : mémoire, CPU, charge, RSS des VM.""" + + def __init__(self, chemin): + super().__init__(daemon=True) + self.chemin, self.phase, self.arret = chemin, "base", threading.Event() + + def run(self): + with open(self.chemin, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["t", "phase", "mem_disponible_mo", "mem_utilisee_mo", "cpu_pct", "charge_1min", "rss_qemu_total_mo"]) + t0, (tot0, idle0) = time.time(), cpu_brut() + while not self.arret.is_set(): + time.sleep(2) + tot, idle = cpu_brut() + cpu = 100 * (1 - (idle - idle0) / max(1, tot - tot0)) + tot0, idle0 = tot, idle + m = meminfo() + w.writerow([round(time.time() - t0, 1), self.phase, m["MemAvailable"], m["MemTotal"] - m["MemAvailable"], + round(cpu, 1), open("/proc/loadavg").read().split()[0], rss_qemu()[0]]) + f.flush() + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--vm", type=int, default=20) + p.add_argument("--sortie", required=True) + p.add_argument("--plancher-go", type=float, default=3.0) + a = p.parse_args() + os.makedirs(a.sortie, exist_ok=True) + resume = {"vm": a.vm, "base": meminfo()} + + ech = Echantillonneur(os.path.join(a.sortie, "hote.csv")) + ech.start() + time.sleep(10) + + if "capacite-test" not in openstack("network", "list", "-f", "value", "-c", "Name").split(): + openstack("network", "create", "--disable-port-security", "capacite-test") + openstack("subnet", "create", "--network", "capacite-test", "--subnet-range", "10.250.0.0/24", "--no-dhcp", "capacite-test") + + # 1. Démarrage simultané + ech.phase = "demarrage" + t0 = time.time() + openstack("server", "create", "--image", "paillasse-debian", "--flavor", "eleve.petit", "--network", "capacite-test", + "--min", str(a.vm), "--max", str(a.vm), "capacite") + vms, actives, pretes = {}, {}, {} + while time.time() - t0 < 600 and len(pretes) < a.vm: + for s in json.loads(openstack("server", "list", "--name", "^capacite-", "-f", "json", "-c", "ID", "-c", "Name", "-c", "Status")): + vms[s["ID"]] = s["Name"] + if s["Status"] == "ACTIVE" and s["ID"] not in actives: + actives[s["ID"]] = round(time.time() - t0, 1) + if s["Status"] == "ERROR": + actives.setdefault(s["ID"], "ERROR") + for uuid in list(actives): + if uuid not in pretes and actives[uuid] != "ERROR" and agent(uuid, {"execute": "guest-ping"}, 2): + pretes[uuid] = round(time.time() - t0, 1) + time.sleep(1) + with open(os.path.join(a.sortie, "demarrage.csv"), "w", newline="") as f: + w = csv.writer(f) + w.writerow(["vm", "uuid", "active_s", "agent_pret_s"]) + for uuid, nom in sorted(vms.items(), key=lambda x: x[1]): + w.writerow([nom, uuid, actives.get(uuid, ""), pretes.get(uuid, "")]) + resume["demarrage"] = {"pretes": len(pretes), "erreurs": sum(1 for v in actives.values() if v == "ERROR"), + "premiere_s": min(pretes.values(), default=None), "derniere_s": max(pretes.values(), default=None)} + + # 2. Repos + ech.phase = "repos" + time.sleep(120) + total, par_vm = rss_qemu() + nos = {u: par_vm.get(u) for u in pretes} + resume["repos"] = {"mem": meminfo(), "rss_par_vm_mo": nos, "rss_moyen_mo": round(sum(v for v in nos.values() if v) / max(1, len(nos)))} + + # 3. Charge CPU : un cœur à 100 % dans chaque VM pendant 60 s + ech.phase = "cpu" + for uuid in pretes: + executer(uuid, "timeout 60 sh -c 'while :; do :; done'", attendre=False) + time.sleep(20) + latences = [] + for uuid in list(pretes)[:5]: + t = time.time() + executer(uuid, "true") + latences.append(round(time.time() - t, 2)) + resume["cpu"] = {"latence_commande_s": latences, "charge_1min": open("/proc/loadavg").read().split()[0]} + time.sleep(45) + + # 4. Charge mémoire : 350 Mo touchés dans chaque VM, une à une + ech.phase = "memoire" + paliers = [] + for i, uuid in enumerate(pretes, 1): + dispo = meminfo()["MemAvailable"] + if dispo < a.plancher_go * 1024: + resume["memoire_arret"] = f"plancher atteint avant la VM {i} ({dispo} Mo disponibles)" + break + ok, _ = executer(uuid, "head -c 350M /dev/zero > /dev/shm/charge && sync", delai=60) + time.sleep(3) + paliers.append({"vm": i, "ok": ok, "mem_disponible_mo": meminfo()["MemAvailable"], "rss_vm_mo": rss_qemu()[1].get(uuid)}) + resume["memoire"] = paliers + if len(paliers) >= 2: + resume["memoire_cout_par_vm_mo"] = round((paliers[0]["mem_disponible_mo"] - paliers[-1]["mem_disponible_mo"]) / (len(paliers) - 1)) + + ech.phase = "fin" + time.sleep(6) + ech.arret.set() + json.dump(resume, open(os.path.join(a.sortie, "resume.json"), "w"), indent=2, ensure_ascii=False) + print(json.dumps({k: v for k, v in resume.items() if k not in ("memoire",)}, indent=1, ensure_ascii=False)[:3000]) + + +if __name__ == "__main__": + main()