diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df220bd..082f6f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,6 +63,30 @@ repos: pass_filenames: false always_run: true + # Un setup et son cleanup doivent se répondre : ce que le setup garde + # (marqueur `creates:`) ou crée (comptes), le cleanup doit le rendre. + # Sinon le lab ne se réinitialise qu'une fois, ou occupe un nom pour tous + # les suivants — et c'est le lab d'APRÈS qui échoue, jamais le fautif. + # Lecture de fichiers seulement : ni VM ni réseau. + - id: marqueurs-setup-cleanup + name: un cleanup rend ce que son setup garde ou crée + entry: uv run --no-project --with pytest --with pyyaml pytest tests/test_marqueurs_setup_cleanup.py -q + language: system + pass_filenames: false + always_run: true + + # Un YAML valide ne prouve pas qu'Ansible charge les tâches : une + # apostrophe dans un bloc `shell` casse le découpage des arguments, et le + # playbook ne se voit refusé qu'à l'exécution, en rc=4 sans une seule + # tâche jouée. On ne le lance que si un playbook change : `--syntax-check` + # sur les 129 fichiers prend une quarantaine de secondes. + - id: playbooks-syntaxe + name: les setup/cleanup se chargent (ansible-playbook --syntax-check) + entry: uv run --no-project --with pytest --with pyyaml pytest tests/test_playbooks_syntaxe.py -q + language: system + pass_filenames: false + files: 'labs/.*/(setup|cleanup)\.yaml$' + # Garde-fou d'outillage. Dans le dépôt Ansible jumeau, deux fois pendant # une même session, une modification a disparu sans trace git : un test de # catalogue, puis un hook. Plusieurs processus écrivent dans ces dépôts, diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index dc36b1a..a8645a0 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -8,8 +8,103 @@ suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +### Corrigé — la première campagne de validation complète + +Les 84 labs ont été rejoués sur KVM avec contrôle négatif (rouge sans la +solution, vert avec). Neuf défauts sont remontés, tous invisibles jusque-là : +les tests d'un lab ne tournent que si quelqu'un le joue, et personne ne les +enchaînait. + +- **`. /root/xxx.env 2>/dev/null || exit 0` ne protégeait rien.** En `sh` POSIX + non interactif, un `.` sur un fichier absent tue le shell **avant** le `||`. + Six `cleanup.yaml` en dépendaient (`l2-autofs-ondemand`, + `l2-disk-space-troubleshoot`, `l2-filesystem-create-xfs`, `l2-partition-gpt`, + `l2-storage-performance`, `l3-fs-readonly-recover`) : leur `dsoxlab reset` + échouait, donc l'apprenant restait bloqué. Le test est désormais fait avant + le source. +- **Trois labs laissaient une partition sur le disque partagé** en n'effaçant + que la signature de la partition, jamais la table du disque. Ce n'était pas + eux qui échouaient, mais le **lab suivant**. +- **`l2-lvm-extend-persist` échouait sur un `rmdir`** : `set +e` n'empêche pas + le module `shell` de rendre le code de la dernière commande. Un `exit 0` + explicite ferme le sujet. +- **`l2-filesystem-create-xfs` n'était rejouable qu'une fois.** Son `setup` se + garde avec `creates: /root/.xfs-lab-ready`, que son `cleanup` n'effaçait pas : + disque nettoyé, marqueur conservé, donc partition jamais recréée et solution + en échec sur une partition inexistante. +- **`l2-luks-encryption` : le test était faux**, pas la solution. Il cherchait + le « 2 » de `Version:` dans une tranche de 8 caractères, alors que + l'alignement de `cryptsetup` le place en 9ᵉ position. Le volume était en LUKS2 + depuis le début. On lit maintenant la valeur du champ. +- **`l3-journald-persist` : la solution écrivait dans un répertoire absent.** + Le module `copy` ne crée pas les parents, et `cleanup` supprimait + `/etc/systemd/journald.conf.d`. La solution le crée désormais. +- **`l4-reverse-proxy-lb` et `l4-ldap-integration` étaient infaisables** : leur + `setup` démarrait le service sur le second nœud sans **ouvrir le port dans + son pare-feu**. HAProxy rendait un `503 No server is available`, et SSSD ne + résolvait rien (`No route to host` sur le port 389). L'ouverture fait partie + de l'état de départ : le sujet de ces labs est le proxy et SSSD, pas le + pare-feu du serveur. +- **`drill-firewall` laissait `firewalld` désactivé** après son nettoyage, ce + qui cassait `rhcsa-mock-exam` (« FirewallD is not running »). Un `cleanup` + rend le système neutre, il ne laisse pas derrière lui l'état de départ du + drill. +- **`rhcsa-mock-exam` gardait son VG sur le disque partagé.** Son `umount` + échouait parce que l'export NFS tenait encore `/data/share` à ce moment, + `exportfs -ua` venant après. + +### Ajouté — les garde-fous + +Chacun est né d'un défaut réel de cette campagne, et chacun a été vérifié en le +faisant échouer : + +- **`tests/test_playbooks_syntaxe.py`** : `ansible-playbook --syntax-check` sur + les 129 `setup.yaml`/`cleanup.yaml`. Un YAML valide ne prouve pas qu'Ansible + charge les tâches : une apostrophe française dans un bloc `shell` suffit à + casser le découpage des arguments, et le lab ne se voyait qu'à l'exécution, + sous la forme d'un `reset` en `rc=4` sans une seule tâche jouée. +- **`tests/test_marqueurs_setup_cleanup.py`** : tout marqueur `creates:` d'un + `setup` doit être effacé par son `cleanup`, sinon le lab ne se réinitialise + qu'une fois. Jouable sans VM, donc en CI. Il a trouvé le défaut de + `l2-filesystem-create-xfs` et un second cas, `l4-ldap-integration`, qui s'est + avéré délibéré : il est exempté explicitement, avec son motif. +- **Contrôle du disque partagé** dans `verify-solutions.py` : après chaque lab + `vm`, le `cleanup` est joué et l'état du disque **comparé à celui d'avant**. + Un lab n'est accusé que de ce qu'il ajoute, et il est nommé au moment où il + salit plutôt que de laisser échouer le suivant. + +### Corrigé + +- **La séquence l1 exigeait d'éditer un fichier sans jamais l'avoir enseigné.** + Les trois premiers labs demandaient de compléter un fichier de réponses, et le + challenge de `l1-first-terminal` interdit même de le recréer par redirection, + alors qu'aucun cours l1 ne montrait comment ouvrir, écrire, sauvegarder et + quitter un éditeur. La seule occurrence de `vim` dans toute la section était + la **valeur** de la variable `EDITOR` au lab 19. Un débutant devait donc + sortir de la formation dès le premier challenge, ce qu'un retour d'apprenant a + confirmé. + - `l1-first-terminal` gagne une section « Écrire dans un fichier sans quitter + le terminal » (FR et EN) : `nano` (Ctrl+O, Ctrl+X), la survie sous `vi` + (Échap, `:wq`, `:q!`), la variable `EDITOR`, et les entrées de dépannage + correspondantes. Les exemples portent des noms étrangers au challenge, pour + ne pas en donner la réponse. + - `l1-first-terminal` passe **en tête** de la section l1, devant + `l1-discover-linux-map`, `l1-choose-distro` et `l1-prepare-vm`. + ### Ajouté +- **`scripts/verify-solutions.py`** : rejoue les solutions de référence + chiffrées et prouve qu'elles passent encore les tests. Une montée de version + peut casser une solution sans que personne ne s'en aperçoive, puisque les + tests d'un lab ne tournent que si quelqu'un le joue. Le verdict est consigné + dans `solution/verified-with.json`. + - `--negative` ajoute le contrôle qui manque le plus : après un + `dsoxlab reset`, les tests doivent **échouer** sans la solution. Un test qui + passe dans les deux sens ne prouve rien. + - Les labs `vm` sans infrastructure provisionnée sont comptés **ignorés**, + jamais en échec : un harnais absent n'est pas une régression de contenu. + - Le script refuse de tourner avec un interpréteur sans `pytest`, plutôt que + de faire échouer les 84 labs d'un coup et de faire croire à une régression. - Catalogue de labs initial pour la formation sécurité Linux / DevSecOps (RHCSA + LFCS) : - 9 labs **L1** de fondamentaux (shell), chacun validé contre l'**état réel** diff --git a/CHANGELOG.md b/CHANGELOG.md index aea1765..44a765a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,98 @@ based on [Keep a Changelog](https://keepachangelog.com/), and the project follow ## [Unreleased] +### Fixed — the first full validation campaign + +All 84 labs were replayed on KVM with a negative control (red without the +solution, green with it). Nine defects surfaced, all previously invisible: a +lab's tests only run when someone plays it, and nobody was chaining them. + +- **`. /root/xxx.env 2>/dev/null || exit 0` protected nothing.** In a + non-interactive POSIX `sh`, sourcing a missing file kills the shell **before** + the `||`. Six `cleanup.yaml` relied on it (`l2-autofs-ondemand`, + `l2-disk-space-troubleshoot`, `l2-filesystem-create-xfs`, `l2-partition-gpt`, + `l2-storage-performance`, `l3-fs-readonly-recover`): their `dsoxlab reset` + failed, leaving the learner stuck. The file is now tested before being + sourced. +- **Three labs left a partition on the shared disk**, wiping only the + partition's signature and never the disk's table. They did not fail + themselves: the **next** lab did. +- **`l2-lvm-extend-persist` failed on an `rmdir`**: `set +e` does not stop the + `shell` module from returning the last command's exit code. An explicit + `exit 0` settles it. +- **`l2-filesystem-create-xfs` was replayable only once.** Its `setup` guards + itself with `creates: /root/.xfs-lab-ready`, which its `cleanup` never + removed: disk wiped, marker kept, so the partition was never recreated and + the solution failed on a partition that did not exist. +- **`l2-luks-encryption`: the test was wrong**, not the solution. It looked for + the `2` of `Version:` within an 8-character slice, while `cryptsetup`'s + alignment puts it at position 9. The volume had been LUKS2 all along. The + field's value is now read properly. +- **`l3-journald-persist`: the solution wrote into a missing directory.** The + `copy` module does not create parents, and `cleanup` removed + `/etc/systemd/journald.conf.d`. The solution now creates it. +- **`l4-reverse-proxy-lb` and `l4-ldap-integration` were impossible**: their + `setup` started the service on the second node without **opening the port in + its firewall**. HAProxy returned `503 No server is available`, and SSSD + resolved nothing (`No route to host` on port 389). Opening it belongs to the + starting state: these labs are about the proxy and SSSD, not the server's + firewall. +- **`drill-firewall` left `firewalld` disabled** after cleaning up, which broke + `rhcsa-mock-exam` ("FirewallD is not running"). A `cleanup` returns the system + to neutral; it does not leave the drill's starting state behind. +- **`rhcsa-mock-exam` kept its VG on the shared disk.** Its `umount` failed + because the NFS export still held `/data/share` at that point, `exportfs -ua` + coming afterwards. + +### Added — the guards + +Each one came from a real defect in this campaign, and each was verified by +making it fail: + +- **`tests/test_playbooks_syntaxe.py`**: `ansible-playbook --syntax-check` over + the 129 `setup.yaml`/`cleanup.yaml`. Valid YAML does not prove Ansible can + load the tasks: a French apostrophe inside a `shell` block is enough to break + argument splitting, and the breakage only showed at run time, as a `reset` + exiting `rc=4` without running a single task. +- **`tests/test_marqueurs_setup_cleanup.py`**: every `creates:` marker in a + `setup` must be removed by its `cleanup`, otherwise the lab resets only once. + Runs without a VM, so it runs in CI. It found the `l2-filesystem-create-xfs` + defect and a second case, `l4-ldap-integration`, which turned out to be + deliberate: it is explicitly exempted, with its rationale. +- **Shared-disk check** in `verify-solutions.py`: after each `vm` lab the + `cleanup` is played and the disk state **compared against the state before**. + A lab is only blamed for what it adds, and it is named the moment it dirties + the disk instead of letting the next one fail. + +### Fixed + +- **The l1 sequence required editing a file it never taught how to edit.** The + first three labs asked learners to fill in an answer file, and the + `l1-first-terminal` challenge even forbids recreating it with a redirection, + while no l1 course ever showed how to open, write, save and quit an editor. + The only occurrence of `vim` in the whole section was the **value** of the + `EDITOR` variable in lab 19. A beginner therefore had to leave the course at + the very first challenge, as a learner report confirmed. + - `l1-first-terminal` gains a "Writing to a file without leaving the terminal" + section (FR and EN): `nano` (Ctrl+O, Ctrl+X), `vi` survival (Esc, `:wq`, + `:q!`), the `EDITOR` variable, and the matching troubleshooting rows. Its + examples use names foreign to the challenge, so they do not give away the + answer. + - `l1-first-terminal` moves to the **front** of the l1 section, ahead of + `l1-discover-linux-map`, `l1-choose-distro` and `l1-prepare-vm`. + ### Added +- **`scripts/verify-solutions.py`**: replays the encrypted reference solutions + and proves they still pass the labs' tests. A version bump can break a + solution unnoticed, since a lab's tests only run when someone plays it. The + verdict is recorded in `solution/verified-with.json`. + - `--negative` adds the missing check: after a `dsoxlab reset`, the tests must + **fail** without the solution. A test that passes both ways proves nothing. + - `vm` labs with no provisioned infrastructure are counted as **skipped**, + never as failures: a missing harness is not a content regression. + - The script refuses to run under an interpreter without `pytest`, rather than + failing all 84 labs at once and looking like a massive regression. - Initial lab catalog for the Linux security / DevSecOps track (RHCSA + LFCS): - 9 **L1** fundamentals labs (shell), each validated against the **real state** of the machine (no fill-in-the-blank worksheets). diff --git a/README.fr.md b/README.fr.md index 7119e52..63d289f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -64,17 +64,17 @@ peux les faire sur ton poste, tout de suite. ```bash dsoxlab use l1 # section de départ -dsoxlab next # → l1-discover-linux-map +dsoxlab next # → l1-first-terminal ``` Puis, pour ce lab comme pour tous les autres, le même cycle en quatre temps : ```bash -dsoxlab course l1-discover-linux-map # 1. le contexte, puis le cours -dsoxlab challenge l1-discover-linux-map # 2. ce qui t'est demandé -dsoxlab run l1-discover-linux-map # 3. prépare ton espace de travail +dsoxlab course l1-first-terminal # 1. le contexte, puis le cours +dsoxlab challenge l1-first-terminal # 2. ce qui t'est demandé +dsoxlab run l1-first-terminal # 3. prépare ton espace de travail # (challenge/work/) et t'y place -dsoxlab check l1-discover-linux-map # 4. valide et note +dsoxlab check l1-first-terminal # 4. valide et note ``` `run` est l'étape que l'on oublie : c'est elle qui crée les fichiers sur @@ -235,10 +235,10 @@ ci-dessous est générée à partir des vrais `lab.yaml` : lance | Lab (id) | Titre | Niveau | Certif | Runtime | Guide compagnon | |---|---|---|---|---|---| +| `l1-first-terminal` | Premiers pas dans le terminal | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/prompt-terminal/) | | `l1-discover-linux-map` | Cartographier Linux : noyau, distribution et répertoires clés | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/notions/) | | `l1-choose-distro` | Choisir sa distribution Linux de référence | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/distributions-serveur/) | | `l1-prepare-vm` | Identifier sa machine Linux | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/installer-vm/) | -| `l1-first-terminal` | Premiers pas dans le terminal | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/prompt-terminal/) | | `l1-read-a-command` | Lire et décoder une commande | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/anatomie-commande/) | | `l1-get-help` | Obtenir de l'aide en ligne de commande | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/obtenir-aide/) | | `l1-linux-filesystem` | Hiérarchie du système de fichiers Linux (FHS) | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/se-reperer-fichiers/arborescence-fhs/) | @@ -350,7 +350,7 @@ ci-dessous est générée à partir des vrais `lab.yaml` : lance | `rhcsa-mock-exam` | Examen blanc RHCSA EX200 — 20 tâches sur 2 VMs | l2 | RHCSA | vm | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/certifications/rhcsa/) | | `lfcs-mock-exam` | Examen blanc LFCS — 17 tâches sur Ubuntu 24.04 | l2 | LFCS | vm | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/certifications/lfcs/) | -_84 labs — table générée par `scripts/gen_catalog.py`._ +_84 labs, table générée par `scripts/gen_catalog.py`._ ## Contribuer et licence diff --git a/README.md b/README.md index 74b1877..8484b31 100644 --- a/README.md +++ b/README.md @@ -64,17 +64,17 @@ right now. ```bash dsoxlab use l1 # starting section -dsoxlab next # → l1-discover-linux-map +dsoxlab next # → l1-first-terminal ``` Then, for this lab and every other one, the same four-step cycle: ```bash -dsoxlab course l1-discover-linux-map # 1. the context, then the course -dsoxlab challenge l1-discover-linux-map # 2. what is asked of you -dsoxlab run l1-discover-linux-map # 3. prepares your workspace +dsoxlab course l1-first-terminal # 1. the context, then the course +dsoxlab challenge l1-first-terminal # 2. what is asked of you +dsoxlab run l1-first-terminal # 3. prepares your workspace # (challenge/work/) and puts you in it -dsoxlab check l1-discover-linux-map # 4. validate and score +dsoxlab check l1-first-terminal # 4. validate and score ``` `run` is the step people skip: it creates the files you work on, and for a VM @@ -230,10 +230,10 @@ to refresh it. | Lab (id) | Title | Level | Certif | Runtime | Companion guide | |---|---|---|---|---|---| +| `l1-first-terminal` | First steps in the terminal | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/prompt-terminal/) | | `l1-discover-linux-map` | Map Linux: kernel, distribution and key directories | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/notions/) | | `l1-choose-distro` | Choose your reference Linux distribution | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/distributions-serveur/) | | `l1-prepare-vm` | Identify your Linux machine | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/installer-vm/) | -| `l1-first-terminal` | First steps in the terminal | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/prompt-terminal/) | | `l1-read-a-command` | Read and decode a command | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/anatomie-commande/) | | `l1-get-help` | Get help from the command line | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/decouvrir-linux/obtenir-aide/) | | `l1-linux-filesystem` | Linux filesystem hierarchy (FHS) | l1 | RHCSA · LFCS | shell | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/fondamentaux/se-reperer-fichiers/arborescence-fhs/) | @@ -345,7 +345,7 @@ to refresh it. | `rhcsa-mock-exam` | RHCSA EX200 mock exam — 20 tasks across 2 VMs | l2 | RHCSA | vm | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/certifications/rhcsa/) | | `lfcs-mock-exam` | LFCS mock exam — 17 tasks on Ubuntu 24.04 | l2 | LFCS | vm | [guide](https://blog.stephane-robert.info/docs/admin-serveurs/linux/certifications/lfcs/) | -_84 labs — table générée par `scripts/gen_catalog.py`._ +_84 labs, table generated by `scripts/gen_catalog.py`._ ## Contributing & license diff --git a/conftest.py b/conftest.py index bd861c2..6b6aabb 100644 --- a/conftest.py +++ b/conftest.py @@ -27,8 +27,8 @@ def test_demo(host): import os import subprocess import sys -from typing import TYPE_CHECKING from pathlib import Path +from typing import TYPE_CHECKING import pytest import yaml @@ -223,16 +223,15 @@ def _find_lab_root(test_path: Path) -> Path | None: continue # rel doit être de la forme /
/ (3 composants) # ou plus profond pour les labs avec sous-sous-section - if len(rel.parts) >= 3: - # Le lab root est le plus profond qui contient un lab.yaml - if (parent / "lab.yaml").is_file(): - return parent + # Le lab root est le plus profond qui contient un lab.yaml + if len(rel.parts) >= 3 and (parent / "lab.yaml").is_file(): + return parent return None def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: """Exécute une commande, lève une RuntimeError lisible en cas d'échec.""" - result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + result = subprocess.run(cmd, capture_output=True, text=True, check=False, **kwargs) if result.returncode != 0: raise RuntimeError( f"Commande échouée (exit {result.returncode}) : {' '.join(cmd)}\n" diff --git a/labs/linux/capstones/rhcsa-mock-exam/cleanup.yaml b/labs/linux/capstones/rhcsa-mock-exam/cleanup.yaml index 07a94a2..614182f 100644 --- a/labs/linux/capstones/rhcsa-mock-exam/cleanup.yaml +++ b/labs/linux/capstones/rhcsa-mock-exam/cleanup.yaml @@ -23,6 +23,29 @@ /etc/systemd/system/weekly-backup.service \ /etc/systemd/system/weekly-backup.timer systemctl daemon-reload 2>/dev/null + # Rendre le disque partage : les taches 1 a 3 posent une table GPT, un + # VG et un LV dessus. Les laisser en place occupe /dev/vdb pour tous les + # labs suivants, qui echouent alors a la place de celui-ci. + # + # Le umount du debut de ce bloc ne suffit pas : l export NFS tient + # encore /data/share a ce moment-la (exportfs -ua vient apres), donc le + # demontage echoue et le VG reste actif. On redemonte ici, apres la + # desexportation, avec un demontage paresseux en dernier recours. + exportfs -ua 2>/dev/null + umount -R /data/share 2>/dev/null + umount -R /data 2>/dev/null || umount -l /data 2>/dev/null + # Les taches 6 et 7 creent appuser, devuser et le groupe developers. + # Les laisser en place occupe des UID/GID que d autres labs posent a + # leur tour, et fait echouer leur useradd a la place de celui-ci. + userdel -r appuser 2>/dev/null + userdel -r devuser 2>/dev/null + groupdel developers 2>/dev/null + vgchange -an vgapp 2>/dev/null + vgremove -f vgapp 2>/dev/null + pvremove -f -y /dev/vdb1 2>/dev/null + wipefs -a /dev/vdb1 /dev/vdb2 /dev/vdb 2>/dev/null + partprobe /dev/vdb 2>/dev/null + exit 0 changed_when: false - name: Retirer les entrees fstab swap/data diff --git a/labs/linux/capstones/rhcsa-mock-exam/setup.yaml b/labs/linux/capstones/rhcsa-mock-exam/setup.yaml index 61c94b9..be0cbe7 100644 --- a/labs/linux/capstones/rhcsa-mock-exam/setup.yaml +++ b/labs/linux/capstones/rhcsa-mock-exam/setup.yaml @@ -33,6 +33,17 @@ - parted state: present + # Installer firewalld ne suffit pas : un lab joue avant celui-ci peut + # l avoir laisse desactive, et plusieurs taches de l examen appellent + # firewall-cmd, qui rend alors « FirewallD is not running ». Sur un examen + # RHCSA le pare-feu tourne : c est donc un donne de l epreuve, pas une + # tache du candidat. + - name: Donnee de l epreuve — firewalld tourne, comme sur une machine RHEL + ansible.builtin.systemd_service: + name: firewalld + enabled: true + state: started + - name: Tache 8 — fournir /var/log/myapp.log (root:root, 0600) ansible.builtin.copy: dest: /var/log/myapp.log diff --git a/labs/linux/drills/drill-firewall/cleanup.yaml b/labs/linux/drills/drill-firewall/cleanup.yaml index 9b4dc47..644dec2 100644 --- a/labs/linux/drills/drill-firewall/cleanup.yaml +++ b/labs/linux/drills/drill-firewall/cleanup.yaml @@ -16,7 +16,13 @@ when: ansible_os_family == "Debian" changed_when: false - - name: RHEL — drop the lab rules and stop firewalld + # Le cleanup retire les regles du drill, puis REND firewalld actif : c est + # l etat par defaut d une machine RHEL, et celui que les autres labs + # supposent. Le laisser desactive faisait echouer rhcsa-mock-exam, dont la + # solution appelle firewall-cmd et recoit « FirewallD is not running ». + # Un cleanup rend le systeme neutre, il ne laisse pas l etat de depart du + # drill derriere lui. + - name: RHEL — drop the lab rules, leave firewalld running as the system default ansible.builtin.shell: cmd: | set +e @@ -24,7 +30,7 @@ firewall-cmd --permanent --remove-port=8443/tcp 2>/dev/null firewall-cmd --permanent --remove-rich-rule='rule port port=23 protocol=tcp reject' 2>/dev/null firewall-cmd --reload 2>/dev/null - systemctl disable --now firewalld 2>/dev/null + systemctl enable --now firewalld 2>/dev/null exit 0 chdir: / when: ansible_os_family == "RedHat" diff --git a/labs/linux/l1/l1-bash-script/challenge/tests/test_functional.py b/labs/linux/l1/l1-bash-script/challenge/tests/test_functional.py index d0d46af..64d8f0b 100644 --- a/labs/linux/l1/l1-bash-script/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-bash-script/challenge/tests/test_functional.py @@ -24,6 +24,7 @@ def _run(arg: str) -> subprocess.CompletedProcess[str]: capture_output=True, text=True, timeout=15, + check=False, ) diff --git a/labs/linux/l1/l1-choose-distro/challenge/tests/conftest.py b/labs/linux/l1/l1-choose-distro/challenge/tests/conftest.py index 760b845..3bc9b70 100644 --- a/labs/linux/l1/l1-choose-distro/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-choose-distro/challenge/tests/conftest.py @@ -1,8 +1,8 @@ """conftest.py — Anchors the working directory to challenge/work/ for all tests.""" import os +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/labs/linux/l1/l1-discover-linux-map/challenge/tests/conftest.py b/labs/linux/l1/l1-discover-linux-map/challenge/tests/conftest.py index 8652e25..eb143a4 100644 --- a/labs/linux/l1/l1-discover-linux-map/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-discover-linux-map/challenge/tests/conftest.py @@ -7,8 +7,8 @@ """ import os +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest diff --git a/labs/linux/l1/l1-env-profiles/challenge/tests/test_functional.py b/labs/linux/l1/l1-env-profiles/challenge/tests/test_functional.py index f345d22..e531c7a 100644 --- a/labs/linux/l1/l1-env-profiles/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-env-profiles/challenge/tests/test_functional.py @@ -24,6 +24,7 @@ def _source_and_read(expr: str) -> subprocess.CompletedProcess[str]: capture_output=True, text=True, timeout=15, + check=False, ) diff --git a/labs/linux/l1/l1-first-terminal/README.fr.md b/labs/linux/l1/l1-first-terminal/README.fr.md index f866011..c24b49e 100644 --- a/labs/linux/l1/l1-first-terminal/README.fr.md +++ b/labs/linux/l1/l1-first-terminal/README.fr.md @@ -329,6 +329,97 @@ code retour = 130 Le `130` est la signature d'une commande tuée par Ctrl+C. Enfin, **Ctrl+D** sur une ligne **vide** ferme la session : c'est l'équivalent clavier de `exit`. +### Écrire dans un fichier sans quitter le terminal + +Jusqu'ici vous avez lu. Un administrateur passe pourtant sa journée à +**modifier** des fichiers, et la plupart des serveurs n'ont aucune interface +graphique : l'éditeur est donc un outil de base, au même titre que `cd`. + +Deux éditeurs sont présents à peu près partout. Le premier s'apprend en une +minute, le second est celui sur lequel on tombe quand le premier manque. + +#### `nano` : celui qui affiche ses commandes + +Fabriquez un fichier de démonstration, sans rapport avec quoi que ce soit : + +```bash +nano /tmp/demo-carnet.txt +``` + +L'écran se remplit, et le bandeau du bas liste les raccourcis : + +```text + GNU nano 7.2 /tmp/demo-carnet.txt +lundi : sauvegarde des bases +mardi : rotation des journaux + +^G Aide ^O Écrire ^W Chercher ^K Couper +^X Quitter ^R Lire fich. ^\ Remplacer ^U Coller +``` + +Le curseur écrit directement : tapez votre texte comme dans n'importe quel +éditeur. Le `^` du bandeau désigne la touche **Ctrl**, donc `^O` se lit +« Ctrl+O ». La séquence complète tient en trois gestes : + +1. **Ctrl+O** pour écrire le fichier. nano affiche le nom proposé en bas, + confirmez par **Entrée**. +2. **Ctrl+X** pour quitter. +3. Si vous quittez sans avoir écrit, nano demande `Sauver le fichier modifié ?` + et attend `O`, `N` ou Ctrl+C pour annuler. + +Vérifiez le résultat sans rouvrir l'éditeur : + +```text +bash-5.2$ cat /tmp/demo-carnet.txt +lundi : sauvegarde des bases +mardi : rotation des journaux +``` + +#### `vi` : celui dont on n'arrive pas à sortir + +`nano` n'est pas toujours installé, `vi` l'est quasiment toujours. Sa +particularité déroute : il démarre en **mode normal**, où les touches sont des +**commandes** et non du texte. Taper « bonjour » ne l'écrit pas, ça exécute sept +commandes. + +```bash +vi /tmp/demo-carnet.txt +``` + +| Vous voulez | Tapez | Remarque | +|---|---|---| +| écrire du texte | `i` | passe en mode insertion, `-- INSERTION --` s'affiche en bas | +| revenir aux commandes | **Échap** | à faire avant toute commande `:` | +| enregistrer et quitter | `:wq` puis Entrée | `w` pour write, `q` pour quit | +| quitter **sans** enregistrer | `:q!` puis Entrée | le `!` force l'abandon des modifications | + +La question « comment je sors de vim » a sa réponse : **Échap**, puis `:q!`, +puis Entrée. Le réflexe à prendre est d'appuyer sur Échap dès qu'on ne sait plus +où l'on en est, ce qui ramène toujours au mode normal. + +#### Quel éditeur se lance tout seul + +Certaines commandes (`crontab -e`, `visudo`, `systemctl edit`) ouvrent un +éditeur sans vous demander lequel. Elles suivent la variable `EDITOR` : + +```text +bash-5.2$ echo "$EDITOR" + +bash-5.2$ EDITOR=nano +bash-5.2$ echo "$EDITOR" +nano +``` + +Vide, le système retombe sur son défaut, souvent `vi`. C'est la raison pour +laquelle il faut connaître les quatre gestes de survie ci-dessus, même si vous +préférez `nano`. + +Effacez la démonstration : + +```bash +rm /tmp/demo-carnet.txt +``` + ### Dépannage | Symptôme | Cause probable | Vérification | @@ -341,6 +432,9 @@ une ligne **vide** ferme la session : c'est l'équivalent clavier de `exit`. | affichage sans couleur, ou caractères parasites | `TERM` absent ou incorrect | `echo "$TERM"` puis `tput colors` | | les raccourcis Ctrl+quelque chose ne répondent pas | vous n'êtes pas dans une saisie interactive du shell | `tty` doit répondre un `/dev/pts/...` | | Ctrl+D ne ferme pas le shell | la variable `IGNOREEOF` est définie | utiliser `exit` | +| `nano : commande introuvable` | l'éditeur n'est pas installé (fréquent sur une image minimale) | utiliser `vi`, présent partout | +| impossible de sortir de `vi` | vous êtes en mode insertion | **Échap**, puis `:q!`, puis Entrée | +| dans `vi`, le texte tapé déclenche des actions | vous êtes en mode normal, pas en insertion | **Échap** puis `i` pour insérer | Pour finir, quittez le bash jetable et effacez le décor : diff --git a/labs/linux/l1/l1-first-terminal/README.md b/labs/linux/l1/l1-first-terminal/README.md index 904bef1..7928c03 100644 --- a/labs/linux/l1/l1-first-terminal/README.md +++ b/labs/linux/l1/l1-first-terminal/README.md @@ -325,6 +325,95 @@ code retour = 130 The `130` is the signature of a command killed by Ctrl+C. Finally, **Ctrl+D** on an **empty** line closes the session: it is the keyboard equivalent of `exit`. +### Writing to a file without leaving the terminal + +So far you have been reading. An administrator, however, spends the day +**modifying** files, and most servers have no graphical interface: the editor is +a basic tool, just like `cd`. + +Two editors are available almost everywhere. The first takes a minute to learn, +the second is the one you land on when the first is missing. + +#### `nano`: the one that shows its commands + +Create a demonstration file, unrelated to anything: + +```bash +nano /tmp/demo-carnet.txt +``` + +The screen fills up, and the bottom bar lists the shortcuts: + +```text + GNU nano 7.2 /tmp/demo-carnet.txt +monday: database backup +tuesday: log rotation + +^G Help ^O Write Out ^W Where Is ^K Cut +^X Exit ^R Read File ^\ Replace ^U Paste +``` + +The cursor types directly: enter your text as in any editor. The `^` in the bar +means the **Ctrl** key, so `^O` reads "Ctrl+O". The whole sequence is three +gestures: + +1. **Ctrl+O** to write the file. nano shows the proposed name at the bottom, + confirm with **Enter**. +2. **Ctrl+X** to quit. +3. If you quit without writing, nano asks `Save modified buffer?` and waits for + `Y`, `N` or Ctrl+C to cancel. + +Check the result without reopening the editor: + +```text +bash-5.2$ cat /tmp/demo-carnet.txt +monday: database backup +tuesday: log rotation +``` + +#### `vi`: the one you cannot get out of + +`nano` is not always installed, `vi` almost always is. Its quirk is +disconcerting: it starts in **normal mode**, where keys are **commands**, not +text. Typing "hello" does not write it, it runs five commands. + +```bash +vi /tmp/demo-carnet.txt +``` + +| You want to | Type | Note | +|---|---|---| +| write text | `i` | enters insert mode, `-- INSERT --` shows at the bottom | +| go back to commands | **Esc** | required before any `:` command | +| save and quit | `:wq` then Enter | `w` for write, `q` for quit | +| quit **without** saving | `:q!` then Enter | the `!` forces changes to be dropped | + +The famous "how do I exit vim" has an answer: **Esc**, then `:q!`, then Enter. +The habit to build is to press Esc whenever you are lost, which always brings +you back to normal mode. + +#### Which editor starts on its own + +Some commands (`crontab -e`, `visudo`, `systemctl edit`) open an editor without +asking which one. They follow the `EDITOR` variable: + +```text +bash-5.2$ echo "$EDITOR" + +bash-5.2$ EDITOR=nano +bash-5.2$ echo "$EDITOR" +nano +``` + +When empty, the system falls back to its default, often `vi`. That is why you +need the four survival gestures above, even if you prefer `nano`. + +Clean up the demonstration: + +```bash +rm /tmp/demo-carnet.txt +``` + ### Troubleshooting | Symptom | Likely cause | Check | @@ -337,6 +426,9 @@ an **empty** line closes the session: it is the keyboard equivalent of `exit`. | display with no colour, or stray characters | `TERM` missing or incorrect | `echo "$TERM"` then `tput colors` | | the Ctrl+something shortcuts do not respond | you are not in an interactive shell input | `tty` must answer a `/dev/pts/...` | | Ctrl+D does not close the shell | the `IGNOREEOF` variable is set | use `exit` | +| `nano: command not found` | the editor is not installed (common on a minimal image) | use `vi`, present everywhere | +| cannot get out of `vi` | you are in insert mode | **Esc**, then `:q!`, then Enter | +| in `vi`, typed text triggers actions | you are in normal mode, not insert mode | **Esc** then `i` to insert | To finish, leave the disposable bash and erase the setup: diff --git a/labs/linux/l1/l1-first-terminal/challenge/tests/conftest.py b/labs/linux/l1/l1-first-terminal/challenge/tests/conftest.py index 4f2c315..ab9b5d1 100644 --- a/labs/linux/l1/l1-first-terminal/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-first-terminal/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-first-terminal""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-first-terminal/challenge/tests/test_functional.py b/labs/linux/l1/l1-first-terminal/challenge/tests/test_functional.py index d72a2d4..d818724 100644 --- a/labs/linux/l1/l1-first-terminal/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-first-terminal/challenge/tests/test_functional.py @@ -89,7 +89,10 @@ def test_home_matches_real() -> None: def test_date_is_real() -> None: """DATE doit être une vraie sortie de `date` (contient l'année courante).""" value = _field(_read(), "DATE") - year = str(datetime.date.today().year) + # `date.today()` lit l'horloge locale sans fuseau explicite ; on veut + # l'année telle que la machine du lab la voit, donc son heure locale + # assumée, pas une conversion implicite. + year = str(datetime.datetime.now().astimezone().year) assert value, "Champ DATE vide. Lance : date — puis reporte la sortie." assert year in value, ( f"DATE ('{value}') ne contient pas l'année courante ({year}). " diff --git a/labs/linux/l1/l1-get-help/challenge/tests/conftest.py b/labs/linux/l1/l1-get-help/challenge/tests/conftest.py index 400d940..b82dbe6 100644 --- a/labs/linux/l1/l1-get-help/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-get-help/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-get-help""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-git-basics/challenge/tests/test_functional.py b/labs/linux/l1/l1-git-basics/challenge/tests/test_functional.py index 559007f..0fe1e8f 100644 --- a/labs/linux/l1/l1-git-basics/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-git-basics/challenge/tests/test_functional.py @@ -22,6 +22,7 @@ def _git(*args: str) -> subprocess.CompletedProcess[str]: capture_output=True, text=True, timeout=15, + check=False, ) diff --git a/labs/linux/l1/l1-linux-filesystem/challenge/tests/conftest.py b/labs/linux/l1/l1-linux-filesystem/challenge/tests/conftest.py index a528f60..9f8c8bb 100644 --- a/labs/linux/l1/l1-linux-filesystem/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-linux-filesystem/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-linux-filesystem""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-navigate-filesystem/challenge/tests/conftest.py b/labs/linux/l1/l1-navigate-filesystem/challenge/tests/conftest.py index bc9c978..3d1563a 100644 --- a/labs/linux/l1/l1-navigate-filesystem/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-navigate-filesystem/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-navigate-filesystem""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-navigate-filesystem/challenge/tests/test_functional.py b/labs/linux/l1/l1-navigate-filesystem/challenge/tests/test_functional.py index cf43439..5f964c3 100644 --- a/labs/linux/l1/l1-navigate-filesystem/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-navigate-filesystem/challenge/tests/test_functional.py @@ -3,6 +3,7 @@ Total: 100 points (9 tests). """ import pathlib + import pytest WORK = pathlib.Path(".") diff --git a/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/conftest.py b/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/conftest.py index 2c4bb17..33063be 100644 --- a/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-paths-absolute-relative""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/test_functional.py b/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/test_functional.py index 055da27..5a6e4e6 100644 --- a/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-paths-absolute-relative/challenge/tests/test_functional.py @@ -4,6 +4,7 @@ """ import pathlib import re + import pytest WORK = pathlib.Path(".") diff --git a/labs/linux/l1/l1-prepare-vm/challenge/tests/conftest.py b/labs/linux/l1/l1-prepare-vm/challenge/tests/conftest.py index 536c082..94b36ef 100644 --- a/labs/linux/l1/l1-prepare-vm/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-prepare-vm/challenge/tests/conftest.py @@ -3,6 +3,7 @@ """ import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-read-a-command/challenge/tests/conftest.py b/labs/linux/l1/l1-read-a-command/challenge/tests/conftest.py index 32f9315..33b5ed9 100644 --- a/labs/linux/l1/l1-read-a-command/challenge/tests/conftest.py +++ b/labs/linux/l1/l1-read-a-command/challenge/tests/conftest.py @@ -1,6 +1,7 @@ """conftest.py — l1-read-a-command""" import os import pathlib + import pytest diff --git a/labs/linux/l1/l1-ssl-certificates/challenge/tests/test_functional.py b/labs/linux/l1/l1-ssl-certificates/challenge/tests/test_functional.py index 59f32ec..db6db8b 100644 --- a/labs/linux/l1/l1-ssl-certificates/challenge/tests/test_functional.py +++ b/labs/linux/l1/l1-ssl-certificates/challenge/tests/test_functional.py @@ -23,6 +23,7 @@ def _openssl(*args: str) -> str: capture_output=True, text=True, timeout=15, + check=False, ) assert res.returncode == 0, f"openssl a échoué : {res.stderr}" return res.stdout diff --git a/labs/linux/l2/l2-autofs-ondemand/cleanup.yaml b/labs/linux/l2/l2-autofs-ondemand/cleanup.yaml index b180ba6..5d4f810 100644 --- a/labs/linux/l2/l2-autofs-ondemand/cleanup.yaml +++ b/labs/linux/l2/l2-autofs-ondemand/cleanup.yaml @@ -15,8 +15,11 @@ - name: Wipe the filesystem + markers ansible.builtin.shell: | - . /root/autofs-disk.env 2>/dev/null || exit 0 + [ -f /root/autofs-disk.env ] || exit 0 + . /root/autofs-disk.env part="${DISK}1"; [ -b "$part" ] || part="${DISK}p1" wipefs -a "$part" 2>/dev/null || true + wipefs -a "$DISK" 2>/dev/null || true + partprobe "$DISK" 2>/dev/null || true rm -f /root/.autofs-lab-ready changed_when: false diff --git a/labs/linux/l2/l2-collaborative-setgid/cleanup.yaml b/labs/linux/l2/l2-collaborative-setgid/cleanup.yaml index 916ffda..8c538fa 100644 --- a/labs/linux/l2/l2-collaborative-setgid/cleanup.yaml +++ b/labs/linux/l2/l2-collaborative-setgid/cleanup.yaml @@ -13,3 +13,17 @@ chmod 0755 /srv/partage 2>/dev/null || true chdir: / changed_when: false + + # Le setup cree alice, bob et devteam : les laisser derriere soi casse + # l2-user-lifecycle, dont la solution fait un useradd alice et echoue sur + # un compte deja pris. Un cleanup rend la machine comme il l a trouvee. + - name: Remove the accounts this lab created + ansible.builtin.shell: + cmd: | + set +e + userdel -r alice 2>/dev/null + userdel -r bob 2>/dev/null + groupdel devteam 2>/dev/null + exit 0 + chdir: / + changed_when: false diff --git a/labs/linux/l2/l2-disk-space-troubleshoot/cleanup.yaml b/labs/linux/l2/l2-disk-space-troubleshoot/cleanup.yaml index 47a46ec..c155c0c 100644 --- a/labs/linux/l2/l2-disk-space-troubleshoot/cleanup.yaml +++ b/labs/linux/l2/l2-disk-space-troubleshoot/cleanup.yaml @@ -12,9 +12,12 @@ - name: Wipe the filesystem on the target partition ansible.builtin.shell: | - . /root/dsk-disk.env 2>/dev/null || exit 0 + [ -f /root/dsk-disk.env ] || exit 0 + . /root/dsk-disk.env part="${DISK}1"; [ -b "$part" ] || part="${DISK}p1" wipefs -a "$part" 2>/dev/null || true + wipefs -a "$DISK" 2>/dev/null || true + partprobe "$DISK" 2>/dev/null || true changed_when: false - name: Remove the mount point and ready marker diff --git a/labs/linux/l2/l2-filesystem-create-xfs/cleanup.yaml b/labs/linux/l2/l2-filesystem-create-xfs/cleanup.yaml index 839465d..fcb7e3d 100644 --- a/labs/linux/l2/l2-filesystem-create-xfs/cleanup.yaml +++ b/labs/linux/l2/l2-filesystem-create-xfs/cleanup.yaml @@ -13,8 +13,13 @@ - name: Wipe the filesystem on the target partition ansible.builtin.shell: | - . /root/xfs-disk.env 2>/dev/null || exit 0 + [ -f /root/xfs-disk.env ] || exit 0 + . /root/xfs-disk.env [ -n "$PART" ] && wipefs -a "$PART" 2>/dev/null || true + wipefs -a "$DISK" 2>/dev/null || true + partprobe "$DISK" 2>/dev/null || true + rm -f /root/xfs-disk.env /root/.xfs-lab-ready + exit 0 changed_when: false - name: Remove the mount point diff --git a/labs/linux/l2/l2-luks-encryption/challenge/tests/test_functional.py b/labs/linux/l2/l2-luks-encryption/challenge/tests/test_functional.py index a2f5ec4..e6d6ffe 100644 --- a/labs/linux/l2/l2-luks-encryption/challenge/tests/test_functional.py +++ b/labs/linux/l2/l2-luks-encryption/challenge/tests/test_functional.py @@ -24,8 +24,16 @@ def test_disk_is_luks2(host): assert disk, "/root/luks-disk.env introuvable (lab non prepare ?)." out = host.run(f"cryptsetup luksDump {disk} 2>/dev/null") assert out.rc == 0, f"{disk} n'est pas un volume LUKS (cryptsetup luksFormat manquant ?)." - assert "Version:" in out.stdout and "2" in out.stdout.split("Version:")[1][:8], ( - "Le volume doit etre en LUKS2 (--type luks2)." + assert "Version:" in out.stdout, ( + f"{disk} ne presente pas d'en-tete LUKS lisible." + ) + # On lit la VALEUR du champ, pas une fenetre de caracteres : cryptsetup + # aligne la colonne avec des espaces puis une tabulation, si bien que le + # chiffre tombait en 9e position et echappait a une tranche [:8]. Le + # volume etait bien en LUKS2, seul le test se trompait. + version = out.stdout.split("Version:", 1)[1].splitlines()[0].strip() + assert version == "2", ( + f"Le volume doit etre en LUKS2 (--type luks2), version lue : {version!r}." ) diff --git a/labs/linux/l2/l2-lvm-extend-persist/cleanup.yaml b/labs/linux/l2/l2-lvm-extend-persist/cleanup.yaml index a711a6c..c3d9e72 100644 --- a/labs/linux/l2/l2-lvm-extend-persist/cleanup.yaml +++ b/labs/linux/l2/l2-lvm-extend-persist/cleanup.yaml @@ -6,6 +6,11 @@ become: true gather_facts: false tasks: + # Le module shell rend le code de la DERNIERE commande, que `set +e` ne + # neutralise pas : un rmdir sur un /data absent suffisait a faire echouer + # tout le cleanup, donc tout `dsoxlab reset`. D ou le `exit 0` final. + # Ce commentaire reste HORS du bloc shell : une apostrophe a l interieur + # casse le parsing des arguments par Ansible (unbalanced quotes). - name: Tear down the LVM stack (best-effort) ansible.builtin.shell: | set +e @@ -15,6 +20,7 @@ if [ -f /root/lvm-disk.env ]; then . /root/lvm-disk.env; pvremove -y "$DISK" 2>/dev/null; fi rm -f /root/lvm-disk.env /root/.lvm-lab-ready rmdir /data 2>/dev/null + exit 0 changed_when: false - name: Remove the /data fstab entry diff --git a/labs/linux/l2/l2-partition-gpt/cleanup.yaml b/labs/linux/l2/l2-partition-gpt/cleanup.yaml index 0af76f0..30b138a 100644 --- a/labs/linux/l2/l2-partition-gpt/cleanup.yaml +++ b/labs/linux/l2/l2-partition-gpt/cleanup.yaml @@ -14,7 +14,8 @@ - name: Wipe the partition table ansible.builtin.shell: | - . /root/part-disk.env 2>/dev/null || exit 0 + [ -f /root/part-disk.env ] || exit 0 + . /root/part-disk.env umount "${DISK}"* 2>/dev/null || true wipefs -a "$DISK" 2>/dev/null || true sgdisk --zap-all "$DISK" 2>/dev/null || true diff --git a/labs/linux/l2/l2-password-policy/challenge/tests/test_functional.py b/labs/linux/l2/l2-password-policy/challenge/tests/test_functional.py index 5e6acca..06e4d29 100644 --- a/labs/linux/l2/l2-password-policy/challenge/tests/test_functional.py +++ b/labs/linux/l2/l2-password-policy/challenge/tests/test_functional.py @@ -52,7 +52,7 @@ def test_warn_7(chage): def test_login_defs_max_days(host): """Le défaut système PASS_MAX_DAYS doit être 60.""" content = host.file("/etc/login.defs").content_string - m = re.search(r"^\s*PASS_MAX_DAYS\s+(\d+)", content, re.M) + m = re.search(r"^\s*PASS_MAX_DAYS\s+(\d+)", content, re.MULTILINE) assert m and m.group(1) == "60", ( "PASS_MAX_DAYS doit valoir 60 dans /etc/login.defs " f"(vu : {m.group(1) if m else 'absent'})." @@ -62,7 +62,7 @@ def test_login_defs_max_days(host): def test_pwquality_minlen_12(host): """La longueur minimale doit être 12 dans pwquality.conf.""" content = host.file("/etc/security/pwquality.conf").content_string - m = re.search(r"^\s*minlen\s*=\s*(\d+)", content, re.M) + m = re.search(r"^\s*minlen\s*=\s*(\d+)", content, re.MULTILINE) assert m and int(m.group(1)) >= 12, ( "minlen doit être >= 12 dans /etc/security/pwquality.conf " f"(vu : {m.group(1) if m else 'absent/commenté'})." diff --git a/labs/linux/l2/l2-raid-mdadm/challenge/tests/test_functional.py b/labs/linux/l2/l2-raid-mdadm/challenge/tests/test_functional.py index f877e87..8c98c44 100644 --- a/labs/linux/l2/l2-raid-mdadm/challenge/tests/test_functional.py +++ b/labs/linux/l2/l2-raid-mdadm/challenge/tests/test_functional.py @@ -40,7 +40,7 @@ def test_array_persistent(host): assert conf.exists, ( "Ajoutez l'array a /etc/mdadm.conf : mdadm --detail --scan >> /etc/mdadm.conf" ) - assert re.search(r"^ARRAY\s+/dev/md/?\d+", conf.content_string, re.M), ( + assert re.search(r"^ARRAY\s+/dev/md/?\d+", conf.content_string, re.MULTILINE), ( "Aucune ligne ARRAY dans /etc/mdadm.conf. " "Ajoutez-la : mdadm --detail --scan >> /etc/mdadm.conf" ) diff --git a/labs/linux/l2/l2-storage-performance/cleanup.yaml b/labs/linux/l2/l2-storage-performance/cleanup.yaml index d6444c2..e1e7dc7 100644 --- a/labs/linux/l2/l2-storage-performance/cleanup.yaml +++ b/labs/linux/l2/l2-storage-performance/cleanup.yaml @@ -19,8 +19,11 @@ - name: Wipe the filesystem + remove mount point and marker ansible.builtin.shell: | - . /root/perf-disk.env 2>/dev/null || exit 0 + [ -f /root/perf-disk.env ] || exit 0 + . /root/perf-disk.env part="${DISK}1"; [ -b "$part" ] || part="${DISK}p1" wipefs -a "$part" 2>/dev/null || true + wipefs -a "$DISK" 2>/dev/null || true + partprobe "$DISK" 2>/dev/null || true rm -rf /srv/data /root/.perf-lab-ready changed_when: false diff --git a/labs/linux/l3/l3-fs-readonly-recover/cleanup.yaml b/labs/linux/l3/l3-fs-readonly-recover/cleanup.yaml index 544edb8..f75995d 100644 --- a/labs/linux/l3/l3-fs-readonly-recover/cleanup.yaml +++ b/labs/linux/l3/l3-fs-readonly-recover/cleanup.yaml @@ -11,7 +11,8 @@ cmd: | umount /srv/data 2>/dev/null || true sed -i '\#[[:space:]]/srv/data[[:space:]]#d' /etc/fstab - . /root/fsro-disk.env 2>/dev/null || exit 0 + [ -f /root/fsro-disk.env ] || exit 0 + . /root/fsro-disk.env part="${DISK}1"; [ -b "$part" ] || part="${DISK}p1" wipefs -a "$part" 2>/dev/null || true rm -f /root/.fsro-ready diff --git a/labs/linux/l4/l4-ldap-integration/setup.yaml b/labs/linux/l4/l4-ldap-integration/setup.yaml index 844c174..fad13c7 100644 --- a/labs/linux/l4/l4-ldap-integration/setup.yaml +++ b/labs/linux/l4/l4-ldap-integration/setup.yaml @@ -54,6 +54,20 @@ chdir: / changed_when: false + # Sans cette regle, l annuaire repond en local mais le client recoit un + # « No route to host » sur le port 389 : SSSD ne resout rien et le lab est + # infaisable. Le sujet est l integration SSSD cote client, pas le pare-feu + # du serveur : cette ouverture appartient donc a l etat de depart. + - name: Allow LDAP through the server firewall (state the lab starts from) + ansible.builtin.shell: + cmd: | + systemctl is-active --quiet firewalld || exit 0 + firewall-cmd --permanent --add-service=ldap >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true + exit 0 + chdir: / + changed_when: false + - name: LDAP client — prepare SSSD tooling (lab_target) hosts: lab_target become: true diff --git a/labs/linux/l4/l4-reverse-proxy-lb/setup.yaml b/labs/linux/l4/l4-reverse-proxy-lb/setup.yaml index 893d315..28ad8bc 100644 --- a/labs/linux/l4/l4-reverse-proxy-lb/setup.yaml +++ b/labs/linux/l4/l4-reverse-proxy-lb/setup.yaml @@ -24,6 +24,21 @@ enabled: true state: started + # Sans cette regle, le backend repond en local mais reste injoignable + # depuis le front : HAProxy rend un 503 « No server is available » et le + # lab est infaisable. Le sujet du lab est le reverse proxy, pas le + # pare-feu du backend : cette ouverture fait donc partie de l etat de + # depart, pas du travail demande a l apprenant. + - name: Allow HTTP through the backend firewall (state the lab starts from) + ansible.builtin.shell: + cmd: | + systemctl is-active --quiet firewalld || exit 0 + firewall-cmd --permanent --add-service=http >/dev/null 2>&1 || true + firewall-cmd --reload >/dev/null 2>&1 || true + exit 0 + chdir: / + changed_when: false + - name: Front host — prepare HAProxy (lab_target) hosts: lab_target become: true diff --git a/labs/linux/lfcs/lfcs-mount-cifs/cleanup.yaml b/labs/linux/lfcs/lfcs-mount-cifs/cleanup.yaml index 895e365..7fe3bdb 100644 --- a/labs/linux/lfcs/lfcs-mount-cifs/cleanup.yaml +++ b/labs/linux/lfcs/lfcs-mount-cifs/cleanup.yaml @@ -42,3 +42,16 @@ ansible.builtin.file: path: /srv/share state: absent + + # Le setup cree smbuser (compte Samba + compte local). Le laisser derriere + # soi occupe son nom et son UID pour les labs suivants, qui echouent alors + # a la place de celui-ci. + - name: Remove the account this lab created + ansible.builtin.shell: + cmd: | + set +e + smbpasswd -x smbuser 2>/dev/null + userdel -r smbuser 2>/dev/null + exit 0 + chdir: / + changed_when: false diff --git a/meta.yml b/meta.yml index e25cd95..d6d653e 100644 --- a/meta.yml +++ b/meta.yml @@ -105,10 +105,16 @@ sections: title: "Fondamentaux (l1)" description: "Découvrir Linux, le shell, les fichiers — runtime: shell." labs: + # l1-first-terminal ouvre la formation : c'est le seul lab qui enseigne + # l'invite, le shell et l'édition d'un fichier. Les trois labs suivants + # demandent tous de compléter un fichier de réponses, et le challenge de + # l1-first-terminal interdit même la redirection. Le placer en quatrième + # position obligeait donc le débutant à sortir de la formation pour + # apprendre nano ou vi. + - linux/l1/l1-first-terminal - linux/l1/l1-discover-linux-map - linux/l1/l1-choose-distro - linux/l1/l1-prepare-vm - - linux/l1/l1-first-terminal - linux/l1/l1-read-a-command - linux/l1/l1-get-help - linux/l1/l1-linux-filesystem diff --git a/pyproject.toml b/pyproject.toml index c13cf67..ae78154 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,28 @@ dependencies = [] # lance un lab à la fois et ne voit pas le problème). addopts = "--import-mode=importlib" +# `ansible-runner` appelle encore `codecs.open()`, que Python 3.14 déprécie : +# chaque playbook joué émet une poignée de DeprecationWarning, ce qui donne des +# lignes « 232 warnings » sur les labs vm. Ce n'est ni notre code ni notre +# décision, et ce bruit avait un coût réel : il masquait un ResourceWarning +# venant de dsoxlab lui-même (un descripteur d'artefact jamais refermé). +# +# On fait donc taire ce message précis, de cette bibliothèque précise. Tout +# AUTRE warning reste visible, ce qui est bien l'intérêt de l'opération : un +# warning qui parle de notre code ne doit pas se noyer dans celui d'une +# dépendance. À retirer quand ansible-runner aura migré vers open(). +# Le message est traité comme une EXPRESSION RÉGULIÈRE : les parenthèses de +# `codecs.open()` doivent être échappées, sinon elles forment un groupe vide et +# le motif ne correspond plus à rien (constaté : le filtre restait sans effet). +# Guillemets SIMPLES : en TOML, une chaîne littérale n'interprète pas les +# échappements, ce qu'il faut pour écrire une regex. Entre guillemets doubles, +# `\.` et `\(` sont des séquences d'échappement invalides et le fichier ne +# parse plus. +filterwarnings = [ + "default", + 'ignore:codecs\.open\(\) is deprecated:DeprecationWarning', +] + # Ne collecter QUE les tests des challenges. Le contrat dsoxlab impose leur # emplacement (challenge/tests/test_functional.py) : tout ce qui porte # `test_*.py` ailleurs est autre chose. diff --git a/scripts/gen_catalog.py b/scripts/gen_catalog.py old mode 100644 new mode 100755 index 592e34f..1297627 --- a/scripts/gen_catalog.py +++ b/scripts/gen_catalog.py @@ -68,8 +68,8 @@ def _guide_cell(lab: dict, lang: str) -> str: url = lab.get("doc_url", "") if not url: return "—" - label = "guide" if lang == "en" else "guide" - return f"[{label}]({url})" + # Même mot dans les deux langues : la condition ne servait à rien. + return f"[guide]({url})" def _table(lang: str) -> str: @@ -94,10 +94,12 @@ def _table(lang: str) -> str: ) lines.append("") total = sum(len(labs) for _s, _t, labs in _rows_by_section()) + # Les deux branches étaient identiques : le README anglais affichait donc + # sa légende en français. La condition existait, la traduction non. caption = ( - f"_{total} labs — table générée par `scripts/gen_catalog.py`._" + f"_{total} labs, table generated by `scripts/gen_catalog.py`._" if lang == "en" - else f"_{total} labs — table générée par `scripts/gen_catalog.py`._" + else f"_{total} labs, table générée par `scripts/gen_catalog.py`._" ) lines.append(caption) return "\n".join(lines) diff --git a/scripts/verify-solutions.py b/scripts/verify-solutions.py new file mode 100755 index 0000000..ae6cc0f --- /dev/null +++ b/scripts/verify-solutions.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Rejoue les solutions de référence et prouve qu'elles marchent encore. + +Raison d'être : une montée de version (noyau, systemd, distribution, provider) +peut casser une solution sans que personne ne s'en aperçoive, parce que les +tests d'un lab ne tournent que si quelqu'un le joue. Ce script les joue tous et +enregistre le verdict dans ``solution/verified-with.json``. + +Le rejeu lui-même n'est pas réimplémenté ici : il vit déjà dans la fixture +autouse ``_apply_lab_state`` du ``conftest.py`` racine, qui déchiffre la +solution (``.vault-pass``) et l'applique avant les tests. Ce script se contente +de lancer pytest **sans** ``LAB_NO_REPLAY``, lab par lab. + + python3 scripts/verify-solutions.py # tout ce qui est jouable + python3 scripts/verify-solutions.py --lab l1-first-terminal + python3 scripts/verify-solutions.py --runtime shell + python3 scripts/verify-solutions.py --check # sort en 1 si une solution casse + python3 scripts/verify-solutions.py --negative # + contrôle négatif + +Le contrôle négatif (``--negative``) rejoue les mêmes tests avec +``LAB_NO_REPLAY=1``, sans appliquer la solution : ils doivent alors **échouer**. +Un test qui passe dans les deux sens ne prouve rien, c'est le défaut que ce +mode attrape. + +Les labs ``vm`` exigent une infrastructure provisionnée (``dsoxlab provision``). +Sans elle, ils sont comptés **ignorés**, jamais en échec : un harnais absent +n'est pas une régression de contenu. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +LABS = REPO / "labs" +SOLUTIONS = REPO / "solution" +RECORD = SOLUTIONS / "verified-with.json" +VAULT_PASS = REPO / ".vault-pass" + +VM_RUNTIMES = {"vm", "kvm", "incus"} + + +# ── découverte ──────────────────────────────────────────────────────────────── + +def _charger_yaml(chemin: Path) -> dict: + import yaml # dépendance déjà présente via dsoxlab / ansible + + with chemin.open(encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +class Lab: + """Un lab et sa solution de référence, tels qu'ils sont sur le disque.""" + + def __init__(self, lab_yaml: Path) -> None: + data = _charger_yaml(lab_yaml) + self.chemin = lab_yaml.parent + self.relatif = self.chemin.relative_to(LABS) + self.id = str(data.get("id") or self.chemin.name) + runtime = data.get("runtime") or {} + self.runtime = str(runtime.get("type", "shell")) + self.tests = self.chemin / "challenge" / "tests" + + @property + def est_vm(self) -> bool: + return self.runtime in VM_RUNTIMES + + @property + def solution(self) -> Path | None: + """`solution.yaml` pour un lab vm, `solution.sh` pour un lab shell.""" + base = SOLUTIONS / self.relatif + candidat = base / ("solution.yaml" if self.est_vm else "solution.sh") + return candidat if candidat.is_file() else None + + +def decouvrir( + filtre_id: str | None, filtre_runtime: str, filtre_section: str | None = None +) -> list[Lab]: + labs = [Lab(y) for y in sorted(LABS.rglob("lab.yaml"))] + if filtre_id: + labs = [lab for lab in labs if lab.id == filtre_id] + if not labs: + sys.exit(f"Lab inconnu : {filtre_id}") + if filtre_section: + # `labs/linux/l2/l2-swap-management` → section « l2 ». + labs = [lab for lab in labs + if len(lab.relatif.parts) > 1 and lab.relatif.parts[1] == filtre_section] + if not labs: + sys.exit(f"Section sans lab : {filtre_section}") + if filtre_runtime == "shell": + labs = [lab for lab in labs if not lab.est_vm] + elif filtre_runtime == "vm": + labs = [lab for lab in labs if lab.est_vm] + return labs + + +# ── pré-conditions ──────────────────────────────────────────────────────────── + +def _interpreteur_ok() -> bool: + """pytest est-il importable par l'interpréteur courant ? + + Le piège classique : lancé par `/usr/bin/python3`, ce script verrait + **toutes** les solutions échouer d'un coup, ce qui ressemble à une + régression massive alors que c'est le harnais qui manque. + """ + try: + import pytest # noqa: F401 + return True + except ImportError: + return False + + +def _infra_disponible() -> bool: + """Les VM du meta.yml sont-elles provisionnées et joignables ?""" + try: + from dsoxlab.discovery.repo import read_repo_metadata + from dsoxlab.infra.inventory import read_terraform_outputs + except ImportError: + return False + try: + meta = read_repo_metadata(REPO) + if meta is None: + return False + sorties = read_terraform_outputs(meta) + return bool(sorties) + except Exception: # noqa: BLE001 — sonde best-effort : tout echec = pas d infra + return False + + +# ── exécution ───────────────────────────────────────────────────────────────── + +def reinitialiser(lab: Lab) -> tuple[bool, str]: + """Remet le lab dans son état de départ, via `dsoxlab reset`. + + Indispensable avant un contrôle négatif : sans elle, la passe précédente + a laissé le travail fait (le workdir d'un lab shell est gitignoré, donc + rien ne le remet en place tout seul), et les tests passent « sans la + solution » pour une raison qui n'a rien à voir avec leur qualité. C'est + le faux rouge que ce script produisait avant cette étape. + """ + proc = subprocess.run( + ["dsoxlab", "reset", lab.id], + cwd=REPO, capture_output=True, text=True, check=False, + ) + if proc.returncode == 0: + return True, "" + # La cause vit dans la sortie de dsoxlab : sans elle, un « reset + # impossible » ne dit pas si le playbook est cassé, si l'hôte est + # injoignable, ou si le lab précédent a laissé le disque occupé. + lignes = [ligne.strip() for ligne in (proc.stdout + proc.stderr).splitlines() + if ligne.strip()] + interessantes = [ligne for ligne in lignes + if "✘" in ligne or "failed" in ligne.lower()] + return False, (interessantes[-1] if interessantes else (lignes[-1] if lignes else "cause inconnue")) + + +def _hotes_avec_disque_additionnel() -> list[str]: + """Les hôtes du meta.yml qui portent un second disque.""" + meta = _charger_yaml(REPO / "meta.yml") + hosts = ((meta.get("infra") or {}).get("hosts") or []) + return [h["name"] for h in hosts if h.get("extra_disk_gb")] + + +def etat_disque_additionnel() -> str: + """Le disque additionnel est-il rendu vierge après le lab ? + + Les labs partagent les mêmes VM, donc le même second disque. Un lab qui + laisse une partition derrière lui ne casse pas ses propres tests : il + casse le **suivant**, avec un « reset impossible » qui accuse un innocent. + Ce contrôle nomme le vrai coupable au moment où il salit. + + Rend une description de ce qui occupe le disque, ou une chaîne vide. + """ + ssh_config = Path.home() / ".cache" / "dsoxlab" / REPO.name / "ssh_config" + if not ssh_config.is_file(): + return "" # pas d'inventaire généré : rien à contrôler + for hote in _hotes_avec_disque_additionnel(): + proc = subprocess.run( + ["ssh", "-F", str(ssh_config), "-o", "LogLevel=ERROR", hote, + "sudo lsblk -no NAME /dev/vdb 2>/dev/null | tail -n +2"], + capture_output=True, text=True, timeout=60, check=False, + ) + restes = " ".join(proc.stdout.split()) + if restes: + return f"{hote} : {restes}" + return "" + + +def jouer(lab: Lab, *, rejeu: bool) -> tuple[bool, str]: + """Lance les tests du lab. `rejeu=False` pose LAB_NO_REPLAY=1.""" + env = dict(os.environ) + if rejeu: + env.pop("LAB_NO_REPLAY", None) + else: + env["LAB_NO_REPLAY"] = "1" + + proc = subprocess.run( + [sys.executable, "-m", "pytest", str(lab.tests), "-q", "--no-header"], + cwd=REPO, + env=env, + capture_output=True, + text=True, + check=False, + ) + lignes = [ligne for ligne in proc.stdout.strip().splitlines() if ligne.strip()] + resume = lignes[-1] if lignes else "aucune sortie" + return proc.returncode == 0, resume + + +def main() -> int: + parseur = argparse.ArgumentParser(description=__doc__) + parseur.add_argument("--lab", help="ne traiter que ce lab (son id)") + parseur.add_argument("--section", help="ne traiter que cette section (l1, l2, lfcs…)") + parseur.add_argument("--runtime", choices=("all", "shell", "vm"), default="all") + parseur.add_argument("--check", action="store_true", + help="sortir en 1 si une solution casse") + parseur.add_argument("--negative", action="store_true", + help="vérifier aussi que les tests échouent sans la solution") + args = parseur.parse_args() + + if not _interpreteur_ok(): + print( + "pytest n'est pas importable par cet interpréteur " + f"({sys.executable}).\n" + "Relance avec le python qui porte pytest, par exemple :\n" + " ~/Projets/dsoxlab/.venv/bin/python scripts/verify-solutions.py", + file=sys.stderr, + ) + return 2 + + labs = decouvrir(args.lab, args.runtime, args.section) + infra = _infra_disponible() + if not infra and any(lab.est_vm for lab in labs): + print("Infra non provisionnée : les labs vm seront ignorés " + "(dsoxlab provision pour les jouer).\n") + + verdicts: dict[str, dict] = {} + verts = rouges = ignores = 0 + + for lab in labs: + if not lab.tests.is_dir(): + verdicts[lab.id] = {"etat": "ignoré", "motif": "aucun test"} + ignores += 1 + continue + if lab.solution is None: + verdicts[lab.id] = {"etat": "ignoré", "motif": "aucune solution"} + ignores += 1 + print(f" ⊘ {lab.id:<34} aucune solution de référence") + continue + if lab.est_vm and not infra: + verdicts[lab.id] = {"etat": "ignoré", "motif": "infra absente"} + ignores += 1 + continue + if not VAULT_PASS.is_file(): + verdicts[lab.id] = {"etat": "ignoré", "motif": ".vault-pass absent"} + ignores += 1 + continue + + # Réinitialisation systématique, pas seulement pour le contrôle négatif. + # Rejouer une solution, c'est la rejouer depuis l'état de départ du lab : + # sur un état déjà résolu, une solution non idempotente échoue pour une + # raison qui ne dit rien de sa validité. Cas vécu : `l1-git-basics` sort + # en rc=1 sur « nothing to commit, working tree clean », et `l1-links- + # hard-sym` de même, alors que les deux sont sains après un reset. + reset_ok, cause = reinitialiser(lab) + if not reset_ok: + verdicts[lab.id] = {"etat": "ignoré", "motif": f"reset impossible : {cause}"} + ignores += 1 + print(f" ⊘ {lab.id:<34} reset impossible : {cause[:70]}") + continue + + # Etat du disque partage AVANT que le lab travaille : ce qui est deja + # la vient du lab precedent, pas de celui-ci. + avant = etat_disque_additionnel() if lab.est_vm else "" + + # Contrôle négatif avant le rejeu : l'inverse mesurerait ce que le + # rejeu vient de poser. + faux_positif = False + if args.negative: + passe_sans_solution, _ = jouer(lab, rejeu=False) + faux_positif = passe_sans_solution + + ok, resume = jouer(lab, rejeu=True) + entree = {"etat": "vert" if ok else "ROUGE", "runtime": lab.runtime, + "resume": resume} + + if faux_positif: + entree["etat"] = "ROUGE" + entree["motif"] = ("les tests passent SANS la solution : " + "ils ne prouvent rien") + ok = False + + # Le lab a fini : on joue son cleanup, puis on verifie qu'il rend bien + # le disque partage. Nettoyer ici sert deux fois : ca prouve que le + # cleanup fait son travail, et ca laisse la VM propre pour le lab + # suivant, au lieu de lui faire porter le chapeau. + # Nettoyer MEME quand les tests echouent : un lab en echec qu'on laisse + # en place pollue tous les suivants (haproxy garde le port 80, un VG + # garde le disque…) et transforme un rouge isole en cascade de rouges + # qui accusent des innocents. + if lab.est_vm: + subprocess.run( + ["dsoxlab", "clean", lab.id, "--yes"], + cwd=REPO, capture_output=True, text=True, check=False, + ) + apres = etat_disque_additionnel() + if ok and apres and apres != avant: + entree["etat"] = "ROUGE" + entree["motif"] = ( + f"cleanup incomplet : le disque etait « {avant or 'vierge'} » " + f"avant le lab et « {apres} » apres. Le lab suivant heritera " + "de cet etat et echouera a sa place." + ) + ok = False + + verdicts[lab.id] = entree + if ok: + verts += 1 + print(f" ✔ {lab.id:<34} {resume}") + else: + rouges += 1 + print(f" ✘ {lab.id:<34} {entree.get('motif', resume)}") + + print(f"\n{verts} vert(s), {rouges} rouge(s), {ignores} ignoré(s) " + f"sur {len(labs)} lab(s).") + + if args.lab is None and args.section is None and args.runtime == "all": + SOLUTIONS.mkdir(exist_ok=True) + RECORD.write_text( + json.dumps( + { + "verifie_le": datetime.now(UTC).isoformat(timespec="seconds"), + "python": sys.version.split()[0], + "labs": verdicts, + }, + indent=2, + ensure_ascii=False, + ) + "\n", + encoding="utf-8", + ) + print(f"Verdict enregistré dans {RECORD.relative_to(REPO)}") + + return 1 if (args.check and rouges) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/solution/linux/l3/l3-journald-persist/solution.yaml b/solution/linux/l3/l3-journald-persist/solution.yaml index 1cee1b4..71808ce 100644 --- a/solution/linux/l3/l3-journald-persist/solution.yaml +++ b/solution/linux/l3/l3-journald-persist/solution.yaml @@ -1,63 +1,71 @@ $ANSIBLE_VAULT;1.1;AES256 -31346137346132376633303164633966356339393135333461343334646461623561636230613933 -6461353365613466353063356430396635386266653338350a663862646138613961646637323134 -39303561386136363631323735313132613436336234353131313462636639356533613432346634 -6639316263316337300a633933356266663762653161663836393036623839346433666438353439 -66653438626137373762613666366637316435346139666630336233346638336362666261636635 -64346262656339393961646133623238376537646662663033656233376566376566646534636633 -61633535313565633730343363343634336230323330316135353037663332663164303461643839 -66633239366431353932646339303639323732633462393466356634636237376536646537333537 -35613364373632636532623632646231366266363965393335313231353835633931653465333563 -30386262633761613136363231366434633639306631646466626231623038303566356165363666 -66663664303934386462366161633037396439653761386437666637386464383132616462343533 -63666630623330363934366234613237346138636337626530666364303238373839386263333936 -34363262666262353965643834363666623961376265386366303962376439356531626139303436 -39373839373937343135363061373735326164656436373732336363623363343239346163343130 -65363032376638663537623539393835643535313164626639363765303036643264383339646437 -62383230353636643863626636386266646463643239383265616434623161646332633363353439 -36306164366534306434623431376333626361376565316631323861383432646135643031373762 -30613335363164656336353564323065316532626561666635376438613562623737336366383734 -30363535643163386631343334666132333135646535323161613339393631386632336135353438 -65366332656232363563323961383264666133326566316132353338623965383837613438353830 -61396232663663383262333436313834366434646265373139386131613565656462626134346437 -63393232643932626534363133623263636235653235373736366563376639346534323732636365 -35343031346236383264303265633530363439636635366562363266613038663736373030343636 -37396436353836613866353933353964373239383438653961323466326330383238376536343331 -62623266363238383863306138393364653161323536663937636634663063303461366364383063 -34346431323636356666336633613466346231373830366630616361313932313634343364343331 -39343936323136643830643934633632343262653761336466616430656636353466666430336337 -63303036663362363036613035633261363761353562363337353937373064616335383366393432 -61616665306264643466616661393332643532343434383936303166633333636534663161316463 -66373461376563663438323462653038396364393166373138363631326231613961373834316634 -33323031616332383430336666396536356130336665383763623364363433306262373639303264 -31646637666666306533653863363531666334346631656238346239313465613037646563353161 -31616364356434326265646662383134373565656530303466323065306134613663316338336430 -30353838656464376334353733303334333134343637363136653833386536313932373931386665 -66303235323266643966313366386665613439666462623035336661353366663362353038373231 -34363236636361353764323062396131336263326137356335346133633138643534366264646361 -34313436366237396530646336323835363138333231346433366266653765383963643631303063 -35613063383161326133623264366562633633356139383233643964363461356639376466663366 -63663566376164353161323738346539333839333266303863623266373763383638623866373031 -36663931353538323935666366643235616530613062313438373866633232393737353531626631 -61613731623031316339336234616537346162373464383339383533613832633333383038343736 -36363237356262316462356461313463306335636264643566343764343565663234383630346630 -32303037633731373761343161343638393764313630346562343064316535626161353036316430 -33386532646337323539333531613539646332373065353462396338393264373232373635643539 -65653866623931353136353738343736656466323231613032643866656462343834613166636534 -32393130363533396332653039306666663362393261393834343464636337336333633364663562 -39306565323133623063323437336131346536653735633638633562633932383561346366646665 -39653630623639373037383432383937333365383039636430626632636139333435656538356361 -34633562613165666334613336346265323364373266363939316635666232626164353338643061 -65313932313161303530333931653837316439623439346364333030623263303634383366396637 -64373339376162356635636264336330336531613763336637303534616339636637313131663030 -66303362646362656239323162666365303030343466383563386637356434653632306437366335 -66363366343733616164326564633639616535366438323662336436383265346234303135326538 -62353861393135666465656532313961626265333339613933663166623339616362643738643137 -35336133313736343032383030306561626634316538306666666563363038633030373537613861 -63393163333730306637653131306535623836336334353439386238666639303636376138323064 -35646639303761636366373937363564663832383330323362616561326339613761346636386465 -36366239643839343366363031373233306465313332643866396231333833333163393463303733 -63316533353665333632306361636365393132653435623630316135633335393031316630646539 -61653739376136653936353962386130626332353135323135663435363433303666333637316331 -63643839653963336164316464396439653437316662656235633764343932373862643865353763 -6161663837383562386234346433633138616532313035333365 +36323732633736386639663337663335306565336161633137653936363237343965393265643166 +6539363039653233626163383764663734356530633764330a666530323461363830646364623235 +31643333353064656231666138356437316139366464613432376130666362326364343066646138 +6164356563326234350a323264333065336464313533623833383763646533393966333530633437 +32316566663165663461303063303333646166623438316639653062346564626262663039393437 +61303366663466343539626432333262343136373032393863366436383966336439323839303762 +66656539623131373866306135646432646165366436313863356337353534626238366336643561 +34326530613734373164326265366639663539633266313736376365663763633632323839613333 +62643664613463376463316236626239383565646135353930313535363966363139386635633337 +66643836666533626538663438386465396532346365653864303766636532316639626534613165 +35613064316432616361393562396534396334666463346138353131373537313930393065613566 +35343663356237343336343738616362376432383335326139383734313430373066613937643539 +36316639366363303933613834373862663635343830383239376265396562633633333638323561 +31313034343065323231366266333261343061373464313338343864356434336536383033313966 +30396433376161616238633133396234616637343139363665656234326539336239623632643338 +33363861633730313363383263333930323037633735356331336633306531663466613132636164 +65376534373935613038366466373539326535393066336430393537373730306564306636663265 +62633830303266323431383864656637623433373233376237633961616530333463353037363433 +63613039366539663539623434613533613334636332336566303132373565363135313934323133 +32363931303636616462343638313565366336333261646232316461333538636134353937393432 +64393830363934346130373566646661616638373432343339363938616464353461363861353633 +38353039663431633265613339386133326630623662303337393335643434303738653439326339 +36366138643632613834633135353330363261656337336638313337346432313838353262633435 +38363233323734613165313137613633303131353830633335323137653565636136336633623762 +61326132663531373538636165346266386264356630323664346634396338353338316335313030 +62653035623966386537316338323831333261313731386566326161306166316265393034613837 +38373766316663333065316663636338333264343332653539333532623066346533373336633837 +36643164316230333539303332633762373831393961366364366130646338346664396131306264 +34623039643464313061653538386137616331336461313063616534383639366239663630636639 +37663835313465666361313037383263636437653630316264623464386161666164646236626335 +31376334613166373365646230316137636366313962343736326438356131633862623239363664 +63656639653965363137333539383639623839363832633331656530393233653632306638643830 +32346561376533396266373430653631303837376438323335393965376165653239326463623134 +33616665643936396264376135336662396538626137633665303230393263626438633936623062 +32336336353864306336656137333661383866656432633865656461366661653731613031343061 +31366639363762383133666163656337613764616437633262336634393730353136656339616334 +65363730383333303536386238396463393731613339623735303037626339333063313361613533 +31653764393861623237633337353634363966353736663936346331623833306565663635313263 +38353338623064626463633264333064363763313539336366396162366135333835353966373333 +63623234313431613463666634303864333230653333386532616565326564616162626633353139 +61343933626263623435303332393262373337333935386231613961656431303232663966396133 +61383030636231633137663033333736366133366461386564376663306434303232623662663162 +30313665633132323436633862643333643664633063386362393363343735313561646635653761 +36383939663464613337393037303537346239666164336131646431373537623638366634376432 +31636434656465393730356335643433306135323235376539663964643232653666333861643564 +30663636613430613630616538373138653239313965373233356465343734393534343434663838 +65316136613365373336346637356234343935333531353037666161633063323330383535303633 +32666233346630376439623764303464356662333530383838396361326135623164313364326537 +35643235653839656365363565386566366634356262336531323965626161373834646365306331 +31363331616262383166313036376134346462643632326630383237323733663038666364633261 +34303033643832326235613138623739313733373735626266613734386634383338313837336366 +33363836663636353637656131626136626565306462663436336232623261636434646463333761 +34373962333038343664353561393230346632633063393032313563343565323332363532643836 +35396134646262643330313931623639636164353963346538626566363334663265386536323431 +64613238316666393031313932616165616539313837383238383735623266353464636537633837 +33373239323339623137623434393837373966396535613136303337333332336339383664373461 +36343739633965343862393165386437363565323231633564356534393463393235633463633462 +64383062396634393564633365383265613832396666366332636437363538313566353030303266 +66383462383366653966313534383430396132353431393836323262383766333562343766356630 +64613331303230333935346139343761383230333134366136653939346330363261363565616130 +33616264306234313135393239316334393661623738356434336132653730396438653532636466 +64313039616234616665396366303534306264363739626661383537633361376663363930343334 +62623036323833623966626534383339626466346433326630373239623133663136313939343333 +30336561376636663765393165326432306266643030316463343431633462373731303265646631 +36353731646430356235313463626438396136326535313836633637336435386332353435383032 +38386463346638303461363938306332656166353134323930343836316439613635366162646466 +65316263303865383731333230343065383339326631373166343333303931343939653861383933 +30333835323238633266316438623364366236613364633864396663356136333364303361373330 +36646666393037316237646531363061343333653465393063383233326564373636373730313338 +3230623237366637613462373635633562663732633063363338 diff --git a/solution/verified-with.json b/solution/verified-with.json new file mode 100644 index 0000000..f81ade7 --- /dev/null +++ b/solution/verified-with.json @@ -0,0 +1,426 @@ +{ + "verifie_le": "2026-07-27T11:46:19+00:00", + "python": "3.14.2", + "labs": { + "lfcs-mock-exam": { + "etat": "vert", + "runtime": "vm", + "resume": "17 passed, 17 warnings in 22.81s" + }, + "rhcsa-mock-exam": { + "etat": "vert", + "runtime": "vm", + "resume": "20 passed, 20 warnings in 40.16s" + }, + "drill-apparmor": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed, 4 warnings in 4.08s" + }, + "drill-essential-commands": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 6.26s" + }, + "drill-firewall": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 16.19s" + }, + "drill-network": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed, 4 warnings in 14.11s" + }, + "drill-packages": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 17.28s" + }, + "drill-selinux": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed, 4 warnings in 5.55s" + }, + "drill-storage": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 6.13s" + }, + "drill-systemd": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 9.07s" + }, + "drill-users-groups": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed, 5 warnings in 5.93s" + }, + "l1-bash-script": { + "etat": "vert", + "runtime": "shell", + "resume": "3 passed in 0.24s" + }, + "l1-choose-distro": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.22s" + }, + "l1-discover-linux-map": { + "etat": "vert", + "runtime": "shell", + "resume": "6 passed in 0.25s" + }, + "l1-env-profiles": { + "etat": "vert", + "runtime": "shell", + "resume": "4 passed in 0.22s" + }, + "l1-find-files": { + "etat": "vert", + "runtime": "shell", + "resume": "4 passed in 0.23s" + }, + "l1-first-terminal": { + "etat": "vert", + "runtime": "shell", + "resume": "7 passed in 0.23s" + }, + "l1-get-help": { + "etat": "vert", + "runtime": "shell", + "resume": "4 passed in 0.21s" + }, + "l1-git-basics": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.23s" + }, + "l1-grep-regex": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.22s" + }, + "l1-links-hard-sym": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.21s" + }, + "l1-linux-filesystem": { + "etat": "vert", + "runtime": "shell", + "resume": "6 passed in 0.22s" + }, + "l1-navigate-filesystem": { + "etat": "vert", + "runtime": "shell", + "resume": "10 passed, 3 warnings in 0.22s" + }, + "l1-paths-absolute-relative": { + "etat": "vert", + "runtime": "shell", + "resume": "9 passed, 5 warnings in 0.23s" + }, + "l1-permissions-ugo": { + "etat": "vert", + "runtime": "shell", + "resume": "4 passed in 0.22s" + }, + "l1-prepare-vm": { + "etat": "vert", + "runtime": "shell", + "resume": "6 passed in 0.23s" + }, + "l1-read-a-command": { + "etat": "vert", + "runtime": "shell", + "resume": "4 passed in 0.23s" + }, + "l1-redirections-pipes": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.23s" + }, + "l1-ssl-certificates": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.30s" + }, + "l1-tar-archives": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.24s" + }, + "l1-text-processing": { + "etat": "vert", + "runtime": "shell", + "resume": "5 passed in 0.24s" + }, + "l2-acl-posix": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.17s" + }, + "l2-autofs-ondemand": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 5.63s" + }, + "l2-collaborative-setgid": { + "etat": "vert", + "runtime": "vm", + "resume": "2 passed in 2.79s" + }, + "l2-disk-space-troubleshoot": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.45s" + }, + "l2-filesystem-create-xfs": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 3.52s" + }, + "l2-fstab-persist-uuid": { + "etat": "vert", + "runtime": "vm", + "resume": "6 passed in 4.88s" + }, + "l2-luks-encryption": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 14.78s" + }, + "l2-lvm-extend-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 2.74s" + }, + "l2-nfs-mount-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 3.06s" + }, + "l2-package-management": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 4.06s" + }, + "l2-partition-gpt": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 3.77s" + }, + "l2-password-policy": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 2.99s" + }, + "l2-raid-mdadm": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 5.62s" + }, + "l2-repo-configure": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 2.54s" + }, + "l2-storage-performance": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.12s" + }, + "l2-sudo-delegation": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 2.60s" + }, + "l2-swap-management": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 5.74s" + }, + "l2-user-lifecycle": { + "etat": "ROUGE", + "runtime": "vm", + "resume": "4 errors in 1.86s" + }, + "l3-app-constraints": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 1.94s" + }, + "l3-boot-target": { + "etat": "vert", + "runtime": "vm", + "resume": "1 passed in 1.51s" + }, + "l3-fs-readonly-recover": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 2.90s" + }, + "l3-grub-kernel-args": { + "etat": "vert", + "runtime": "vm", + "resume": "2 passed in 2.31s" + }, + "l3-journald-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 4.65s" + }, + "l3-process-signals-priority": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.91s" + }, + "l3-scheduling-at": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.42s" + }, + "l3-scheduling-cron": { + "etat": "vert", + "runtime": "vm", + "resume": "2 passed in 1.77s" + }, + "l3-scheduling-timers": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 4.53s" + }, + "l3-service-create-unit": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.71s" + }, + "l3-service-diagnose": { + "etat": "vert", + "runtime": "vm", + "resume": "6 passed in 5.44s" + }, + "l3-ssh-access-recovery": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 3.38s" + }, + "l3-sysctl-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.32s" + }, + "l3-tuned-profile": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.45s" + }, + "l4-bridge-bonding": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 3.04s" + }, + "l4-firewall-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.29s" + }, + "l4-ldap-integration": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 4.44s" + }, + "l4-nat-portforward": { + "etat": "vert", + "runtime": "vm", + "resume": "5 passed in 16.58s" + }, + "l4-network-static-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 2.34s" + }, + "l4-network-troubleshoot": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 1.60s" + }, + "l4-ntp-sync": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.47s" + }, + "l4-podman-basic": { + "etat": "vert", + "runtime": "vm", + "resume": "2 passed in 1.60s" + }, + "l4-podman-images": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 5.55s" + }, + "l4-podman-systemd-persist": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.78s" + }, + "l4-reverse-proxy-lb": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 4.34s" + }, + "l4-selinux-boolean-port": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.64s" + }, + "l4-selinux-context-fix": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 2.33s" + }, + "l4-selinux-diagnose-avc": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 1.56s" + }, + "l4-ssh-key-auth-harden": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 4.37s" + }, + "lfcs-apparmor": { + "etat": "vert", + "runtime": "vm", + "resume": "2 passed in 1.50s" + }, + "lfcs-firewall-ufw": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 1.97s" + }, + "lfcs-mount-cifs": { + "etat": "vert", + "runtime": "vm", + "resume": "6 passed in 4.54s" + }, + "lfcs-netplan-static": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 3.37s" + }, + "lfcs-package-apt": { + "etat": "vert", + "runtime": "vm", + "resume": "3 passed in 5.66s" + }, + "lfcs-storage-quotas": { + "etat": "vert", + "runtime": "vm", + "resume": "4 passed in 3.25s" + } + } +} diff --git a/tests/test_marqueurs_setup_cleanup.py b/tests/test_marqueurs_setup_cleanup.py new file mode 100644 index 0000000..9bf0d49 --- /dev/null +++ b/tests/test_marqueurs_setup_cleanup.py @@ -0,0 +1,130 @@ +"""Tout marqueur qui garde un `setup.yaml` doit être effacé par son `cleanup.yaml`. + +Incident à l'origine de ce module. `l2-filesystem-create-xfs` prépare sa +partition dans une tâche gardée par `args: creates: /root/.xfs-lab-ready`. Son +`cleanup.yaml` effaçait le système de fichiers mais **pas le marqueur**. Après +un nettoyage, le disque était vierge et le marqueur toujours là : au `setup` +suivant, Ansible sautait la tâche « déjà faite », la partition n'était jamais +recréée, et la solution de référence échouait sur une partition inexistante. + +Le lab n'était donc plus rejouable, et rien ne le signalait : ni le validator, +ni les tests du lab, qui ne tournent que sur un lab déjà préparé. + +La règle est simple et vérifiable sans VM, par simple lecture des deux +playbooks : ce qu'un `creates:` protège, un `cleanup` doit le rendre. Sinon le +lab ne se réinitialise qu'une fois. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent + +#: `args: creates: ` — la garde d'idempotence d'Ansible. +_CREATES = re.compile(r"^\s*creates:\s*(?P\S+)\s*$", re.MULTILINE) + +#: Marqueurs volontairement conservés, avec le motif. Une exemption se discute +#: en revue ; elle ne se contourne pas en supprimant le test. Le motif doit +#: expliquer pourquoi le lab reste rejouable **malgré** le marqueur conservé. +EXEMPTIONS: dict[tuple[str, str], str] = { + ("l4-ldap-integration", "/etc/dirsrv/slapd-lab"): ( + "L'instance 389-ds est délibérément conservée entre deux passages : " + "elle est longue à créer et son cleanup ne vise que le client. Le lab " + "reste rejouable parce que le setup réutilise l'annuaire en place au " + "lieu de le recréer." + ), +} + + +def _labs_avec_setup() -> list[Path]: + return sorted(p.parent for p in (REPO / "labs").rglob("setup.yaml")) + + +def _marqueurs(setup: Path) -> list[str]: + return _CREATES.findall(setup.read_text(encoding="utf-8")) + + +CAS = [ + (lab, marqueur) + for lab in _labs_avec_setup() + for marqueur in _marqueurs(lab / "setup.yaml") +] + + +@pytest.mark.parametrize( + ("lab", "marqueur"), + CAS, + ids=[f"{lab.name}:{marqueur}" for lab, marqueur in CAS], +) +def test_le_cleanup_efface_le_marqueur(lab: Path, marqueur: str) -> None: + """Sans cela, le lab ne se réinitialise qu'une seule fois.""" + motif = EXEMPTIONS.get((lab.name, marqueur)) + if motif: + pytest.skip(f"exemption assumée : {motif}") + + cleanup = lab / "cleanup.yaml" + assert cleanup.is_file(), f"{lab.name} a un setup.yaml mais pas de cleanup.yaml" + + texte = cleanup.read_text(encoding="utf-8") + assert marqueur in texte, ( + f"{lab.name} : le setup se garde avec « creates: {marqueur} », mais le " + f"cleanup ne mentionne jamais ce chemin.\n" + f"Le nettoyage laissera donc le marqueur en place : au prochain setup, " + f"la tâche sera sautée et l'état de départ ne sera jamais reconstruit.\n" + f"Ajouter par exemple : rm -f {marqueur}" + ) + + +def test_il_y_a_bien_des_marqueurs_a_verifier() -> None: + """Une liste vide ferait passer ce module pour rien.""" + assert len(CAS) >= 5, ( + f"seulement {len(CAS)} garde(s) « creates: » trouvée(s) : la détection " + "est cassée, pas le catalogue" + ) + + +# ── comptes créés par un setup ──────────────────────────────────────────────── +# +# Même règle, autre ressource. `l2-collaborative-setgid` créait alice, bob et +# devteam sans jamais les supprimer : `l2-user-lifecycle`, qui passe après dans +# la séquence et dont la solution fait un `useradd alice`, échouait sur un +# compte déjà pris. Le lab fautif, lui, restait vert. + +#: `ansible.builtin.user: name: X` / `group: name: X` dans un setup. +_COMPTE = re.compile( + r"ansible\.builtin\.(?Puser|group):\s*\n\s*name:\s*(?P[a-z][\w-]*)", + re.MULTILINE, +) + +#: Comptes fournis par l'image ou par dsoxlab, qu'un lab ne doit pas supprimer. +SYSTEME = {"root", "ansible", "student", "nobody", "wheel", "sudo", "adm"} + +COMPTES = [ + (lab, m.group("genre"), m.group("nom")) + for lab in _labs_avec_setup() + for m in _COMPTE.finditer((lab / "setup.yaml").read_text(encoding="utf-8")) + if m.group("nom") not in SYSTEME +] + + +@pytest.mark.parametrize( + ("lab", "genre", "nom"), + COMPTES, + ids=[f"{lab.name}:{genre}:{nom}" for lab, genre, nom in COMPTES], +) +def test_le_cleanup_supprime_les_comptes_crees(lab: Path, genre: str, nom: str) -> None: + """Un compte laissé derrière soi fait échouer le lab suivant, pas celui-ci.""" + cleanup = lab / "cleanup.yaml" + assert cleanup.is_file(), f"{lab.name} a un setup.yaml mais pas de cleanup.yaml" + + assert nom in cleanup.read_text(encoding="utf-8"), ( + f"{lab.name} : le setup crée le {genre} « {nom} », que le cleanup ne " + f"mentionne jamais.\n" + f"Le compte survivra au nettoyage et occupera son nom (et son UID/GID) " + f"pour tous les labs suivants.\n" + f"Ajouter par exemple : {'userdel -r' if genre == 'user' else 'groupdel'} {nom}" + ) diff --git a/tests/test_playbooks_syntaxe.py b/tests/test_playbooks_syntaxe.py new file mode 100644 index 0000000..f4c925e --- /dev/null +++ b/tests/test_playbooks_syntaxe.py @@ -0,0 +1,62 @@ +"""Tout `setup.yaml` / `cleanup.yaml` doit être chargeable par Ansible. + +Incident à l'origine de ce module. Un commentaire français ajouté **dans** un +bloc `ansible.builtin.shell` a suffi à casser un playbook : l'apostrophe de +« n'empêche » déséquilibre le découpage des arguments, et Ansible refuse de +charger la tâche avec « failed at splitting arguments, either an unbalanced +jinja2 block or quotes ». + +Le fichier restait un YAML parfaitement valide. `yaml.safe_load` passait, +`dsoxlab validate-structure` passait, et le défaut ne se voyait qu'à +l'exécution, sous la forme d'un `dsoxlab reset` qui échouait en `rc=4` sans +jouer une seule tâche. Autrement dit : vérifier la forme ne prouve pas que ça +s'exécute, seul `--syntax-check` charge réellement les tâches. + +Un commentaire explicatif se met donc **au-dessus** de la tâche, en YAML, pas +à l'intérieur du bloc shell. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parent.parent +PLAYBOOKS = sorted( + p for p in (REPO / "labs").rglob("*.yaml") + if p.name in {"setup.yaml", "cleanup.yaml"} +) + +ANSIBLE = shutil.which("ansible-playbook") + + +@pytest.mark.skipif(ANSIBLE is None, reason="ansible-playbook absent du PATH") +@pytest.mark.parametrize("playbook", PLAYBOOKS, ids=lambda p: str(p.relative_to(REPO))) +def test_le_playbook_se_charge(playbook: Path) -> None: + """`--syntax-check` charge les tâches, là où un parseur YAML ne fait que lire.""" + assert ANSIBLE is not None + resultat = subprocess.run( + [ANSIBLE, "--syntax-check", "-i", "localhost,", str(playbook)], + capture_output=True, + text=True, + cwd=REPO, + # C'est le code de retour qu'on veut juger, pas une exception : un + # playbook qui ne se charge pas doit produire un échec de test lisible. + check=False, + ) + + assert resultat.returncode == 0, ( + f"{playbook.relative_to(REPO)} ne se charge pas :\n" + f"{resultat.stdout.strip()}\n{resultat.stderr.strip()}" + ) + + +def test_le_catalogue_a_bien_des_playbooks() -> None: + """Garde-fou du garde-fou : une liste vide ferait passer le test pour rien.""" + assert len(PLAYBOOKS) > 100, ( + f"seulement {len(PLAYBOOKS)} playbook(s) découvert(s) : la découverte " + "est cassée, pas le catalogue" + ) diff --git a/tests/test_solutions_chiffrees.py b/tests/test_solutions_chiffrees.py index b259a02..344303c 100644 --- a/tests/test_solutions_chiffrees.py +++ b/tests/test_solutions_chiffrees.py @@ -43,8 +43,10 @@ SOLUTION_DIR = REPO_ROOT / "solution" VAULT_HEADER = b"$ANSIBLE_VAULT" -# Rien à chiffrer dans ces fichiers de service. -EXEMPTS = {".gitkeep", ".gitignore"} +# Rien à chiffrer dans ces fichiers de service. `verified-with.json` est le +# verdict écrit par `scripts/verify-solutions.py` : il dit quelle solution a +# été rejouée avec quel interpréteur et quand, sans jamais citer son contenu. +EXEMPTS = {".gitkeep", ".gitignore", "verified-with.json"} def _fichiers_suivis() -> list[Path]: