From 6c31c67a11cb75aa05e6d972e47c675d0945369a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 27 Jul 2026 13:59:30 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(labs):=20rendre=20les=2084=20labs=20rej?= =?UTF-8?q?ouables=20en=20s=C3=A9quence,=20et=20le=20prouver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Première campagne de validation complète du catalogue : les 84 labs rejoués sur KVM avec contrôle négatif (rouge sans la solution, vert avec). Dix 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. Trois labs étaient purement infaisables. l4-reverse-proxy-lb et l4-ldap-integration démarraient leur service sur le second nœud sans ouvrir le port dans son pare-feu : HAProxy rendait un 503, SSSD ne résolvait rien. l2-filesystem-create-xfs n'était rejouable qu'une fois, son cleanup laissant le marqueur qui garde son setup. Six cleanup.yaml ne pouvaient pas s'exécuter : « . fichier || exit 0 » ne protège rien, un sh POSIX non interactif meurt sur le source avant d'atteindre le ||. Leur dsoxlab reset échouait, donc l'apprenant restait bloqué. Le reste tient à l'état partagé entre labs, qui se joue toujours de la même façon : le lab fautif reste vert, et c'est le suivant qui échoue. Trois labs laissaient une partition sur le disque commun, trois autres laissaient leurs comptes (alice, appuser, smbuser…) et drill-firewall laissait firewalld désactivé, ce qui cassait le capstone RHCSA. Un cas inverse mérite d'être noté : sur l2-luks-encryption, c'était le TEST qui é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 9e position. Le volume était en LUKS2 depuis le début. Trois garde-fous accompagnent ces corrections, chacun vérifié en le faisant échouer : - test_playbooks_syntaxe.py joue ansible-playbook --syntax-check sur les 129 playbooks. Un YAML valide ne prouve pas qu'Ansible charge les tâches : une apostrophe française dans un bloc shell casse le découpage des arguments, et cela ne se voyait qu'à l'exécution, en rc=4 sans une seule tâche jouée. - test_marqueurs_setup_cleanup.py exige qu'un cleanup rende ce que son setup garde : marqueurs « creates: » et comptes créés. Il a trouvé deux défauts que la lecture manuelle avait manqués, dont smbuser. - verify-solutions.py compare l'état du disque partagé avant et après chaque lab vm, et nomme le lab qui salit au lieu de laisser échouer le suivant. Enfin, la séquence l1 exigeait d'éditer un fichier sans jamais l'avoir enseigné : l1-first-terminal passe en tête et gagne une section sur nano et la survie sous vi. Le challenge interdit la redirection, l'éditeur était donc obligatoire et introuvable dans le cours. Co-Authored-By: Claude Opus 5 --- .pre-commit-config.yaml | 24 + CHANGELOG.fr.md | 95 ++++ CHANGELOG.md | 90 ++++ README.fr.md | 12 +- README.md | 12 +- .../capstones/rhcsa-mock-exam/cleanup.yaml | 23 + .../capstones/rhcsa-mock-exam/setup.yaml | 11 + labs/linux/drills/drill-firewall/cleanup.yaml | 10 +- labs/linux/l1/l1-first-terminal/README.fr.md | 94 ++++ labs/linux/l1/l1-first-terminal/README.md | 92 ++++ labs/linux/l2/l2-autofs-ondemand/cleanup.yaml | 5 +- .../l2/l2-collaborative-setgid/cleanup.yaml | 14 + .../l2-disk-space-troubleshoot/cleanup.yaml | 5 +- .../l2/l2-filesystem-create-xfs/cleanup.yaml | 7 +- .../challenge/tests/test_functional.py | 12 +- .../l2/l2-lvm-extend-persist/cleanup.yaml | 6 + labs/linux/l2/l2-partition-gpt/cleanup.yaml | 3 +- .../l2/l2-storage-performance/cleanup.yaml | 5 +- .../l3/l3-fs-readonly-recover/cleanup.yaml | 3 +- labs/linux/l4/l4-ldap-integration/setup.yaml | 14 + labs/linux/l4/l4-reverse-proxy-lb/setup.yaml | 15 + labs/linux/lfcs/lfcs-mount-cifs/cleanup.yaml | 13 + meta.yml | 8 +- pyproject.toml | 22 + scripts/verify-solutions.py | 353 +++++++++++++++ .../l3/l3-journald-persist/solution.yaml | 132 +++--- solution/verified-with.json | 426 ++++++++++++++++++ tests/test_marqueurs_setup_cleanup.py | 130 ++++++ tests/test_playbooks_syntaxe.py | 62 +++ tests/test_solutions_chiffrees.py | 6 +- 30 files changed, 1617 insertions(+), 87 deletions(-) create mode 100755 scripts/verify-solutions.py create mode 100644 solution/verified-with.json create mode 100644 tests/test_marqueurs_setup_cleanup.py create mode 100644 tests/test_playbooks_syntaxe.py 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..da37c8c 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/) | diff --git a/README.md b/README.md index 74b1877..5d267a9 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/) | 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-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/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-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/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]: From fad12e1dcb574ab9c5c71a7e7e03698557787c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 27 Jul 2026 14:11:55 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(lint):=20solder=20la=20dette=20ruff?= =?UTF-8?q?=20que=20la=20CI=20r=C3=A9v=C3=A9lait,=20et=20une=20l=C3=A9gend?= =?UTF-8?q?e=20jamais=20traduite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le job « Pre-commit parity » joue les hooks sur TOUS les fichiers, là où un commit ne les joue que sur les fichiers touchés. Il révélait donc 27 problèmes ruff préexistants, dans du code que personne n'avait relinté depuis que le hook utilise `uvx ruff` (donc la dernière version publiée, pas un pin). Dix-sept se corrigent d'eux-mêmes (imports, `re.M` en `re.MULTILINE`). Les dix autres demandaient une décision : - cinq subprocess.run sans `check` explicite, dans des tests de labs et le conftest racine. Tous JUGENT le code de retour, ils ne doivent surtout pas lever dessus : `check=False` est rendu explicite. - `datetime.date.today()` sans fuseau, dans le test l1 qui compare l'année courante. Remplacé par une heure locale assumée. - un `if` imbriqué inutile dans le conftest racine. - un shebang sans bit exécutable sur gen_catalog.py. Les deux dernières étaient le même défaut, et il avait une conséquence visible : deux conditions « if lang == en else » dont les deux branches disaient exactement la même chose. La variante anglaise n'avait jamais été écrite, si bien que le README ANGLAIS affichait sa légende de catalogue en français. Elle est traduite, et les deux catalogues sont régénérés. Co-Authored-By: Claude Opus 5 --- README.fr.md | 2 +- README.md | 2 +- conftest.py | 11 +++++------ .../l1-bash-script/challenge/tests/test_functional.py | 1 + .../l1/l1-choose-distro/challenge/tests/conftest.py | 2 +- .../l1-discover-linux-map/challenge/tests/conftest.py | 2 +- .../challenge/tests/test_functional.py | 1 + .../l1/l1-first-terminal/challenge/tests/conftest.py | 1 + .../challenge/tests/test_functional.py | 5 ++++- labs/linux/l1/l1-get-help/challenge/tests/conftest.py | 1 + .../l1-git-basics/challenge/tests/test_functional.py | 1 + .../l1-linux-filesystem/challenge/tests/conftest.py | 1 + .../challenge/tests/conftest.py | 1 + .../challenge/tests/test_functional.py | 1 + .../challenge/tests/conftest.py | 1 + .../challenge/tests/test_functional.py | 1 + .../l1/l1-prepare-vm/challenge/tests/conftest.py | 1 + .../l1/l1-read-a-command/challenge/tests/conftest.py | 1 + .../challenge/tests/test_functional.py | 1 + .../challenge/tests/test_functional.py | 4 ++-- .../l2-raid-mdadm/challenge/tests/test_functional.py | 2 +- scripts/gen_catalog.py | 10 ++++++---- 22 files changed, 35 insertions(+), 18 deletions(-) mode change 100644 => 100755 scripts/gen_catalog.py diff --git a/README.fr.md b/README.fr.md index da37c8c..63d289f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 5d267a9..8484b31 100644 --- a/README.md +++ b/README.md @@ -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/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/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-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/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)