From 0c3295a5a73d991756573ce0072eb49946e8b59f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Mon, 24 Aug 2026 01:09:32 +0200 Subject: [PATCH] docs(par-public): trois portes, et des chemins que le code confirme (0.1.63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toute la documentation tenait dans deux README qui parlaient en même temps à l'apprenant, à l'auteur de catalogue et au formateur. Aucun des trois n'y trouvait son chemin, et certaines sections décrivaient un produit qui n'existe pas. Les affirmations fausses, chacune vérifiée contre le code : - `~/.local/share/dsoxlab/progress.db` et `~/.config/dsoxlab/config.yaml` n'existent nulle part. La base est `/.dsoxlab.db`, une par catalogue, et aucun fichier de configuration utilisateur n'est lu. - « une base SQLite locale conforme à la spécification XDG » : elle est dans le catalogue, précisément pour que la progression le suive. - `incus` et `kvm` annoncés comme des runtimes. Il y en a deux, `shell` et `vm` ; le backend vient de `meta.yml: infra.provider`, pas du lab. - une carte d'architecture nommant `IncusRuntime` et `KvmRuntime`, deux classes qui n'existent pas. - un `runtime.host` dans l'exemple de `lab.yaml` le plus en vue, champ qu'aucun code ne lit, et que le contrat documente comme un piège. - l'aide de `--lab-home` nommait « le dépôt linux-training » comme s'il était le seul catalogue, dans les deux langues. Le contrôle qui empêche la dérive de recommencer étend le mécanisme existant plutôt que d'en ouvrir un second : `scripts/generer-doc.py`, qui produisait déjà la table des commandes depuis la CLI, relève maintenant les emplacements réels **en appelant les fonctions que la CLI appelle**, sur un HOME jetable, et refuse tout chemin qu'une page cite et que le code ne produit pas. Prouvé par mutation : réintroduire `progress.db` dans un README rend deux tests rouges. Un chemin peut être cité pour dire qu'il n'existe pas, mais par la seule page dont c'est le sujet, et un test vérifie l'autre bout : le jour où #78 rendra `config.yaml` réel, la page qui l'annonce absent deviendra fausse, et le test le dira. Les pages, chacune nommant son public en tête : apprenant, auteur de catalogue, formateur, plus deux références communes (où dsoxlab écrit, les commandes). Les deux README restent la porte d'entrée et se lisent en trente secondes. Aucun générateur de site n'est introduit : cette décision appartient au propriétaire du dépôt. Closes #86 --- .pre-commit-config.yaml | 4 +- CHANGELOG.fr.md | 50 ++++ CHANGELOG.md | 47 +++ CONTRIBUTING.fr.md | 41 ++- CONTRIBUTING.md | 39 ++- README.fr.md | 352 +++-------------------- README.md | 388 +++---------------------- docs/README.fr.md | 49 ++++ docs/README.md | 48 ++++ docs/brand.fr.md | 4 + docs/brand.md | 3 + docs/catalog-author.fr.md | 221 +++++++++++++++ docs/catalog-author.md | 215 ++++++++++++++ docs/commands.fr.md | 69 +++++ docs/commands.md | 69 +++++ docs/contract-v1.fr.md | 3 + docs/contract-v1.md | 3 + docs/files.fr.md | 119 ++++++++ docs/files.md | 115 ++++++++ docs/learner.fr.md | 198 +++++++++++++ docs/learner.md | 195 +++++++++++++ docs/trainer.fr.md | 180 ++++++++++++ docs/trainer.md | 175 ++++++++++++ pyproject.toml | 4 +- scripts/generer-doc.py | 394 +++++++++++++++++++++++++- src/dsoxlab/i18n/strings/en.py | 11 +- src/dsoxlab/i18n/strings/fr.py | 13 +- tests/test_documentation_synchrone.py | 237 +++++++++++++++- uv.lock | 2 +- 29 files changed, 2547 insertions(+), 701 deletions(-) create mode 100644 docs/README.fr.md create mode 100644 docs/README.md create mode 100644 docs/catalog-author.fr.md create mode 100644 docs/catalog-author.md create mode 100644 docs/commands.fr.md create mode 100644 docs/commands.md create mode 100644 docs/files.fr.md create mode 100644 docs/files.md create mode 100644 docs/learner.fr.md create mode 100644 docs/learner.md create mode 100644 docs/trainer.fr.md create mode 100644 docs/trainer.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b874703..5e56de6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -81,11 +81,11 @@ repos: # que le zéro-bash interdit, et il y manquait deux commandes. Elle est # désormais produite par la CLI, et ce hook refuse une version périmée. - id: doc-a-jour - name: la documentation décrit la CLI réelle + name: la documentation décrit la CLI et les chemins réels entry: python3 scripts/generer-doc.py --verifier language: system pass_filenames: false - files: '^(src/dsoxlab/(cli\.py|i18n/)|README(\.fr)?\.md|scripts/generer-doc\.py)' + files: '^(src/dsoxlab/.*\.py|docs/.*\.md|[A-Z]+(\.fr)?\.md|scripts/generer-doc\.py)$' # Full test suite runs on push (kept off the per-commit path for speed). - id: pytest diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0f13380..e4e8c8e 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -9,6 +9,56 @@ et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). ## [Non publié] +## [0.1.63] - 2026-08-24 + +- **La documentation décrivait un produit qui n'existe pas.** Trois + affirmations de la section « Persistence » des deux README étaient fausses, et + c'étaient celles que suit un lecteur cherchant ses notes : une base + `~/.local/share/dsoxlab/progress.db`, un fichier de configuration utilisateur + `~/.config/dsoxlab/config.yaml`, et `XDG_DATA_HOME` / `XDG_CONFIG_HOME` pour + les déplacer. La base est `/.dsoxlab.db`, une par catalogue, et + aucun fichier de configuration n'est lu nulle part. Quatre autres + affirmations ont suivi le même chemin : une progression « conforme à la + spécification XDG », `incus` et `kvm` présentés comme des runtimes (il y en a + deux, `shell` et `vm`, et le backend est le choix du catalogue), une carte + d'architecture nommant des classes `IncusRuntime` et `KvmRuntime` qui + n'existent pas, et un champ `runtime.host` dans l'exemple de `lab.yaml` le + plus en vue, qu'aucun code ne lit. + +- L'aide de `--lab-home` annonçait « racine du dépôt linux-training », nommant + un catalogue comme s'il était le seul, dans les deux langues. Elle dit + désormais « le catalogue de labs ». + +### Ajouté + +- **Un contrôle qui interdit à la dérive de recommencer.** Les emplacements de + fichiers documentés sont désormais confrontés au code comme l'était déjà la + table des commandes : `scripts/generer-doc.py` relève les emplacements réels + **en appelant les fonctions que la CLI appelle**, sur un `HOME` jetable, puis + signale tout chemin qu'une page cite et qui ne correspond à aucun d'eux. + Prouvé par mutation, et par six tests dans + `tests/test_documentation_synchrone.py`. Un chemin peut être cité pour dire + qu'il **n'**existe **pas**, mais par la seule page dont c'est le sujet, et un + test vérifie qu'il est bien absent du code. + +### Modifié + +- **Documentation découpée par public**, chaque page nommant son lecteur dès ses + premières lignes : [l'apprenant](docs/learner.fr.md), [l'auteur de + catalogue](docs/catalog-author.fr.md), [le formateur](docs/trainer.fr.md), + plus deux références communes aux trois ([où dsoxlab écrit](docs/files.fr.md) + et [les commandes](docs/commands.fr.md), toujours produites par la CLI). Les + deux README gardent leur rôle de porte d'entrée et se lisent maintenant en + trente secondes. Aucun générateur de site n'est introduit : cette décision + appartient au propriétaire du dépôt. + +- `Documentation` dans `pyproject.toml` pointe sur la documentation de l'outil + plutôt que sur l'index générique d'un blog. La carte de l'architecture a + déménagé dans `CONTRIBUTING.fr.md`, corrigée, là où les contributeurs à qui + elle s'adresse la trouveront. + +Closes #86. + ## [0.1.62] - 2026-08-24 ### Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index db0a9d7..e50c4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.63] - 2026-08-24 + +- **The documentation described a product that does not exist.** Three claims + of the "Persistence" section of both READMEs were false, and they were the + ones a reader looking for their scores would follow: a database at + `~/.local/share/dsoxlab/progress.db`, a user configuration file at + `~/.config/dsoxlab/config.yaml`, and `XDG_DATA_HOME` / `XDG_CONFIG_HOME` as + the way to move them. The database is `/.dsoxlab.db`, one per + catalog; no configuration file is read anywhere. Four more claims went the + same way: progress "following the XDG spec", `incus` and `kvm` presented as + runtimes (there are two, `shell` and `vm`, and the backend is the catalog's + choice), an architecture map naming `IncusRuntime` and `KvmRuntime` classes + that do not exist, and a `runtime.host` field in the flagship `lab.yaml` + example that no code reads. + +- The `--lab-home` help said "root of the linux-training repo", naming one + catalog as if it were the only one, in both languages. It now says "the lab + catalog". + +### Added + +- **A control that forbids the drift from starting again.** The documented file + locations are now confronted with the code the same way the command table + already was: `scripts/generer-doc.py` derives the real locations **by calling + the functions the CLI calls**, on a throwaway `HOME`, then reports any path a + page cites that matches none of them. Proven by mutation, and by six tests in + `tests/test_documentation_synchrone.py`. A path may be cited as *not* + existing, but only by the page whose subject that is, and a test checks that + such a path really is absent from the code. + +### Changed + +- **Documentation split by audience**, each page naming its reader in its first + lines: [the learner](docs/learner.md), [the catalog + author](docs/catalog-author.md), [the trainer](docs/trainer.md), plus two + references shared by the three ([where dsoxlab writes](docs/files.md) and + [the commands](docs/commands.md), still generated from the CLI). Both READMEs + keep their role as the entry point and now fit in a thirty-second read. No + site generator is introduced: that decision belongs to the repository owner. + +- `Documentation` in `pyproject.toml` points at the tool's documentation instead + of the generic index of a blog. The architecture map moved to + `CONTRIBUTING.md`, corrected, where the contributors it addresses will find + it. + +Closes #86. + ## [0.1.62] - 2026-08-24 ### Fixed diff --git a/CONTRIBUTING.fr.md b/CONTRIBUTING.fr.md index a93fdd9..962e70c 100644 --- a/CONTRIBUTING.fr.md +++ b/CONTRIBUTING.fr.md @@ -14,6 +14,7 @@ commit sont rédigés en anglais pour que tout le monde puisse participer. - [Règles fondamentales](#règles-fondamentales) - [Mise en place](#mise-en-place) +- [Carte de l'architecture](#carte-de-larchitecture) - [Contrôles qualité](#contrôles-qualité) - [Scanners de workflow](#scanners-de-workflow) - [Fuzzer le contrat non fiable](#fuzzer-le-contrat-non-fiable) @@ -60,6 +61,40 @@ cd ~/Projets/linux-dsoxlab-training && dsoxlab list-labs cd ~/Projets/ansible-training && dsoxlab list-labs ``` +## Carte de l'architecture + +```text +src/dsoxlab/ +├── cli.py ← point d'entrée Typer (+ le groupe de commandes i18n) +├── config.py ← LAB_HOME, contexte actif, .dsoxlab-context.json +├── locking.py ← verrou d'écriture par catalogue (flock), code de sortie 7 +├── logging_setup.py ← le journal qu'écrit chaque commande, quelle que soit la verbosité +├── i18n/ ← get_lang(), _(), strings/en.py + strings/fr.py +├── models/ ← schémas typés du contrat déclaratif +├── discovery/ ← scan du meta.yml et de tous les lab.yaml du catalogue +├── services/ ← orchestration métier (lab, progression, guide, doctor…) +├── sessions/ ← persistance SQLite (results + hint_requests) +├── runtimes/ ← BaseRuntime, ShellRuntime, VmRuntime, RuntimeManager +├── infra/ ← Terraform, Ansible, inventaire, snapshots, identifiants +├── validators/ ← structure, métadonnées et contenu d'un catalogue +├── reporting/ ← sorties terminal Rich, et le JSON de --json +├── utils/ ← wrapper subprocess centralisé +└── templates/ ← Terraform, cloud-init et le catalogue de démonstration +``` + +Il existe deux runtimes, et deux seulement : `shell` et `vm`. Les valeurs `kvm` +et `incus` de `runtime.type` sont des alias historiques traités par +`VmRuntime` ; quel backend sert réellement une VM vient de +`meta.yml: infra.provider`. + +`infra/inventory.py` est la surface publique qu'un catalogue consomme : son +`conftest.py` importe `build_inventory`, `read_terraform_outputs` et +`write_ssh_config` pour construire l'inventaire testinfra sans coder la moindre +adresse IP. + +Le moteur reste indépendant de l'arborescence d'un dépôt : `discovery/` +fonctionne sur n'importe quel arbre déclaré par le `meta.yml`. + ## Contrôles qualité À lancer avant d'ouvrir une pull request. La CI exécute les mêmes contrôles. @@ -198,9 +233,9 @@ Nous utilisons les Conventional Commits avec un scope de module : Types : `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, `ci`. Exemples : -- `feat(discovery): support multi-repo via ~/.config/dsoxlab/config.yaml` -- `fix(runtimes/kvm): make snapshot revert idempotent when snapshot is absent` -- `docs(readme): document the incus runtime` +- `feat(runtimes): declare containerized services in lab.yaml` +- `fix(runtimes/vm): make snapshot revert idempotent when the snapshot is absent` +- `docs(contract): document the meta.fr.yml overrides` Gardez des commits ciblés et un historique lisible. Avant un commit groupé, consultez `git log --oneline -5` pour coller au style. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80e5042..b387245 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ in English so everyone can take part. - [Ground rules](#ground-rules) - [Development setup](#development-setup) +- [Architecture map](#architecture-map) - [Quality gates](#quality-gates) - [Workflow scanners](#workflow-scanners) - [Fuzzing the untrusted contract](#fuzzing-the-untrusted-contract) @@ -60,6 +61,38 @@ cd ~/Projets/linux-dsoxlab-training && dsoxlab list-labs cd ~/Projets/ansible-training && dsoxlab list-labs ``` +## Architecture map + +```text +src/dsoxlab/ +├── cli.py ← Typer entry point (+ the i18n command group) +├── config.py ← LAB_HOME, active context, .dsoxlab-context.json +├── locking.py ← per-catalog write lock (flock), exit code 7 +├── logging_setup.py ← the log every command writes, whatever the verbosity +├── i18n/ ← get_lang(), _(), strings/en.py + strings/fr.py +├── models/ ← typed schemas of the declarative contract +├── discovery/ ← scan meta.yml + every lab.yaml of the current catalog +├── services/ ← business orchestration (lab, progress, guide, doctor…) +├── sessions/ ← SQLite persistence (results + hint_requests) +├── runtimes/ ← BaseRuntime, ShellRuntime, VmRuntime, RuntimeManager +├── infra/ ← Terraform, Ansible, inventory, snapshots, credentials +├── validators/ ← structure, metadata and content of a catalog +├── reporting/ ← Rich terminal output, and the JSON of --json +├── utils/ ← centralized subprocess wrapper +└── templates/ ← Terraform, cloud-init and the demonstration catalog +``` + +Two runtimes exist, and only two: `shell` and `vm`. The `kvm` and `incus` +values of `runtime.type` are legacy aliases handled by `VmRuntime`; which +backend actually serves a VM comes from `meta.yml: infra.provider`. + +`infra/inventory.py` is the public surface a catalog consumes: its `conftest.py` +imports `build_inventory`, `read_terraform_outputs` and `write_ssh_config` to +build the testinfra inventory without hardcoding a single IP address. + +The engine stays independent of any single repository layout: `discovery/` +works on whatever tree the `meta.yml` declares. + ## Quality gates Run these before opening a pull request. CI runs the same checks. @@ -197,9 +230,9 @@ We use Conventional Commits with a module scope: Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `test`, `ci`. Examples: -- `feat(discovery): support multi-repo via ~/.config/dsoxlab/config.yaml` -- `fix(runtimes/kvm): make snapshot revert idempotent when snapshot is absent` -- `docs(readme): document the incus runtime` +- `feat(runtimes): declare containerized services in lab.yaml` +- `fix(runtimes/vm): make snapshot revert idempotent when the snapshot is absent` +- `docs(contract): document the meta.fr.yml overrides` Keep commits focused and the history readable. Before a grouped commit, check `git log --oneline -5` to match the style. diff --git a/README.fr.md b/README.fr.md index c3405aa..2bc37d4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -35,344 +35,76 @@ l'historique en local, par catalogue. --- -## Pourquoi dsoxlab - -- **Un moteur, plusieurs catalogues.** Une seule CLI pilote tous les dépôts de - formation. On ajoute un domaine en écrivant un `meta.yml`, pas en modifiant - l'outil. -- **La validation prouve, elle ne fait pas confiance.** Les labs sont évalués - sur l'**état réel du système** (`pytest-testinfra`) et, quand le sujet le - justifie, sur la **persistance après reboot**, le piège qui fait échouer les - candidats RHCSA/LFCS. -- **Plusieurs runtimes.** Jouer un lab dans un simple **shell**, un conteneur - **Incus** ou une VM **KVM/libvirt** complète, au choix par lab. -- **Une progression qui persiste.** Scores, coûts des indices et historique sont - conservés dans une base SQLite locale conforme à la spécification XDG. -- **Expérience bilingue.** Chaque chaîne affichée existe en anglais et en - français (`DSOXLAB_LANG=en|fr`). +## Installer et jouer, en cinq minutes ---- - -## Installation - -Nécessite **Python 3.11+**. +Nécessite **Python 3.11+**. Rien à cloner, rien à compiler. ```bash uv tool install dsoxlab # ou : pipx install dsoxlab -dsoxlab --version -``` - -C'est toute l'installation. Rien à cloner, rien à compiler. - ---- - -## Votre premier lab, en cinq minutes - -Nul besoin d'un catalogue pour commencer. `dsoxlab demo` installe un catalogue -de démonstration d'un seul lab, dont le sujet est dsoxlab lui-même : la boucle -que vous répéterez sur tous les autres labs. - -```bash -dsoxlab demo # l'installe et affiche la suite +dsoxlab demo # installe un catalogue de démonstration d'un lab cd ~/.local/share/dsoxlab/demo -dsoxlab course premiers-pas # le cours +dsoxlab course premiers-pas # la leçon dsoxlab run premiers-pas # vous dépose dans le répertoire de travail dsoxlab challenge premiers-pas # la mission -dsoxlab check premiers-pas # les tests, et le score +dsoxlab check premiers-pas # les tests, et la note ``` -Ni VM, ni conteneur, ni Docker : cela tourne partout où dsoxlab tourne. +Le lab de démonstration a dsoxlab lui-même pour sujet, et ne demande ni VM, ni +conteneur, ni Docker : il tourne partout où dsoxlab tourne. --- -## Ensuite, un vrai catalogue - -Les labs vivent dans leurs propres dépôts, publiés séparément du moteur. -Clonez-en un, puis lancez `dsoxlab` depuis l'intérieur : - -```bash -git clone https://github.com/stephrobert/linux-dsoxlab-training.git -cd linux-dsoxlab-training - -dsoxlab doctor # ce que ce catalogue exige, et ce qui manque -dsoxlab list-labs -dsoxlab run -dsoxlab check -``` - -`dsoxlab doctor` ne signale que ce dont *ce* catalogue a besoin : un catalogue -entièrement `shell` ne réclame jamais d'hyperviseur. `dsoxlab doctor --fix` -répare ce qui peut l'être sans risque. En cas de problème, `dsoxlab support` -produit un rapport de diagnostic anonymisé, prêt à coller dans une issue. - -### Installer depuis les sources (contributeurs) - -```bash -git clone https://github.com/stephrobert/dsoxlab.git -cd dsoxlab -uv tool install --editable . -``` - -### Lire le cours - -Le cours n'est pas embarqué dans le dépôt de labs : chaque lab déclare un -`doc_url` qui pointe vers le site du formateur. `dsoxlab guide` ouvre cette page -dans un vrai onglet de navigateur, donc elle s'affiche telle qu'elle est publiée, -avec ses images, ses blocs de code et sa navigation. - -```bash -dsoxlab guide # le lab actif -dsoxlab guide # un lab précis -dsoxlab guide --print # affiche l'URL au lieu de l'ouvrir (utile en SSH) -``` - -L'URL porte des paramètres de campagne (`utm_source=dsoxlab`, `utm_medium=lab`, -`utm_campaign=`), ce qui permet à un formateur de voir quels labs amènent -réellement des lecteurs vers quels guides. Un lien ouvert depuis une interface -locale ne transmet aucun referrer exploitable : sans ce marquage, ces lectures -seraient indistinguables du trafic direct. - -Changer de langue à la volée : - -```bash -DSOXLAB_LANG=fr dsoxlab fullhelp -DSOXLAB_LANG=en dsoxlab fullhelp -``` +## Documentation -### Lecture des cours longs +Trois lecteurs, trois portes. Chaque page nomme son public dès ses premières +lignes. -`course` et `challenge` passent par le pager dès que leur sortie dépasse la -hauteur du terminal : un cours de plusieurs centaines de lignes reste lisible -sans dépendre du scrollback du terminal. La pagination ne s'applique jamais à -un tube ni à une redirection, qui reçoivent toujours le texte complet. - -```bash -DSOXLAB_PAGER='bat --plain' dsoxlab course # choisir son pager (défaut : less -R) -dsoxlab course --no-pager # tout déverser d'un bloc -dsoxlab course > cours.txt # jamais paginé : texte brut -``` - ---- - -## Runtimes - -| Runtime | Backend | Usage typique | -| --- | --- | --- | -| `shell` | Shell local | Exercices rapides mono-hôte, sans surcoût de VM | -| `incus` | Conteneurs Incus | Environnements Linux isolés, à démarrage rapide | -| `kvm` | Terraform + libvirt | VM complètes avec test de reboot/persistance | - -Chaque runtime est opt-in et auto-descriptif (`is_available()`), le moteur ne -dépend jamais en dur d'un backend non installé. Les templates de provisioning -(HCL Terraform, cloud-init) vivent sous `dsoxlab.templates` et couvrent Incus, -KVM/libvirt et Outscale. - ---- - -## Le contrat déclaratif - -Un dépôt qui héberge des labs décrit son catalogue avec deux niveaux de -fichiers. - -Le contrat est **versionné**. Les deux fichiers acceptent un entier -`schema_version` à leur racine ; l'omettre vaut la version 1, aucun catalogue -existant n'a donc rien à changer. Un fichier qui déclare une version que ce -dsoxlab ne lit pas est nommé dans un message, au lieu de disparaître du -catalogue. Champ par champ, avec la règle d'évolution et le chemin de migration -vers une future v2 : **[la référence du contrat v1](docs/contract-v1.fr.md)**. - -Deux schémas JSON décrivent le même contrat pour ton éditeur et pour ta CI : -[`schemas/lab.schema.json`](schemas/lab.schema.json) et -[`schemas/meta.schema.json`](schemas/meta.schema.json). Pose cette ligne en tête -d'un fichier, et tout éditeur qui fait tourner `yaml-language-server` -(l'extension YAML de VS Code, entre autres) complète les champs et souligne les -fautes à la frappe : - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json -id: mon-lab -title: Mon lab -``` - -Remplace `main` par un tag de release (`v0.1.46`) pour épingler le schéma en CI. -Un test confronte les deux schémas au parseur, dans les deux sens : ils ne -peuvent pas dériver du code en silence. - -### 1. `meta.yml` à la racine du dépôt - -Métadonnées du dépôt, topologie d'infrastructure (KVM/Incus), ordre des -sections. - -```yaml -repo: - id: linux-training - category: linux - title: "Linux Training — RHCSA + LFCS 2026" - blog_url: "https://blog.stephane-robert.info/docs/admin-serveurs/linux/" - -infra: - network: lab-linux - hosts: - - { name: alma-rhcsa-1.lab, ip: 10.10.30.11, distro: alma10 } - - { name: alma-rhcsa-2.lab, ip: 10.10.30.12, distro: alma10 } - - { name: ubuntu-lfcs-1.lab, ip: 10.10.30.21, distro: ubuntu24 } - -sections: - - id: depanner - title: "Dépanner" - labs: - - depanner/services-processus/service-crash-loop - - depanner/stockage-fs/disque-plein-mais-pas-de-fichiers -``` - -### 2. `lab.yaml` par lab (sous `labs//
//`) - -Métadonnées spécifiques à un lab (skills, runtime, distros, validation). - -```yaml -id: depanner-service-crash-loop -title: "Identifier et corriger un service systemd en crash loop" -section: linux -level: l2 -track: [depanner, rhcsa] -skills: [systemd, journalctl, debug] -difficulty: intermediate -estimated_time: 30m -runtime: - type: kvm - host: alma-rhcsa-1.lab -distros: [rhel10, ubuntu24.04] -doc_url: https://blog.stephane-robert.info/docs/admin-serveurs/linux/depanner/services-processus/service-crash-loop/ -validation: - functional: true - security: false - persistence_after_reboot: true -``` - -Un `lab.fr.yaml` optionnel peut surcharger `title` et `description` pour le -français uniquement. La même convention vaut un cran au-dessus : un -`meta.fr.yml` posé à côté du `meta.yml` traduit `repo.title`, -`repo.description` et les titres et descriptions des `sections[]`, appariées -par `id`. - -`dsoxlab validate-structure` vérifie que tout le contrat tient : le `meta.yml` -racine est conforme, chaque lab référencé existe avec un `lab.yaml` valide, -chaque `runtime.host` pointe un hôte déclaré, et tous les scripts et fichiers de -test référencés sont présents. - ---- - -## Référence des commandes - - - -| Commande | Rôle | +| Je veux… | Lire | | --- | --- | -| `dsoxlab challenge` | Affiche la mission du challenge (challenge/README.md). | -| `dsoxlab check` | Exécute les tests, calcule le score (hints déduits) et enregistre le résultat. | -| `dsoxlab clean` | Supprime toutes les ressources créées par le lab. | -| `dsoxlab completion install` | Installe l'auto-complétion pour le shell courant (zsh, bash). | -| `dsoxlab completion show` | Imprime le script de complétion sur la sortie standard, sans rien écrire. | -| `dsoxlab course` | Affiche une section du cours, ou le sommaire si aucune section n'est précisée. | -| `dsoxlab demo` | Installe un catalogue de démonstration et joue un premier lab, sans rien cloner ni provisionner. | -| `dsoxlab destroy` | Détruit l'infrastructure du lab (terraform destroy), machines restées hors du state comprises. | -| `dsoxlab doctor` | Diagnostique l'environnement (runtimes, outils, labs détectés). | -| `dsoxlab fullhelp` | Affiche le guide complet de la plateforme (concepts, workflow, commandes). | -| `dsoxlab guide` | Ouvre le guide en ligne du lab dans le navigateur. | -| `dsoxlab hint` | Affiche le prochain indice du challenge (déduit des points au score final). | -| `dsoxlab install` | Déprécié : utilise « dsoxlab completion install ». Installe l'auto-complétion. | -| `dsoxlab instructor bootstrap` | Génère la clé SSH du lab (si absente) et vérifie que terraform/ansible-runner sont installés. | -| `dsoxlab list-labs` | Liste tous les labs disponibles (filtrés par contexte actif si défini). | -| `dsoxlab next` | Recommande le prochain lab ou challenge à compléter dans le contexte actif. | -| `dsoxlab progress` | Affiche la progression par bloc (labs complétés, score moyen, challenges et capstones). | -| `dsoxlab provision` | Provisionne l'infrastructure du lab (terraform apply sur le provider courant). | -| `dsoxlab reset` | Remet le lab à l'état initial (clean + redémarrage). | -| `dsoxlab run` | Prépare et démarre l'environnement du lab. | -| `dsoxlab scores` | Affiche l'historique des scores enregistrés. | -| `dsoxlab show` | Affiche le détail et le statut d'un lab. | -| `dsoxlab ssh` | Ouvre une session SSH interactive sur un hôte du lab. | -| `dsoxlab status` | Vérifie la connectivité SSH des hôtes déclarés dans meta.yml, et nomme la cause quand l'un reste muet. | -| `dsoxlab submit` | Soumission finale : lance les tests, enregistre le score, puis tapez 'exit' pour quitter la session. | -| `dsoxlab support` | Produit un rapport de diagnostic anonymisé, à coller dans une issue. | -| `dsoxlab use` | Définit le contexte actif (section et/ou niveau par défaut). Utilisez --reset pour l'effacer. | -| `dsoxlab validate-structure` | Vérifie la structure et les métadonnées de tous les labs. | - - - -Chaque runtime est opt-in et auto-descriptif (`is_available()`), le moteur ne -dépend jamais en dur d'un backend non installé. Les templates de provisioning -(HCL Terraform, cloud-init) vivent sous `dsoxlab.templates` et couvrent Incus, -KVM/libvirt et Outscale. - ---- +| Installer dsoxlab, jouer des labs, comprendre ma note | **[Pour l'apprenant](docs/learner.fr.md)** | +| Écrire mon propre catalogue de labs | **[Pour l'auteur de catalogue](docs/catalog-author.fr.md)**, puis [le contrat v1](docs/contract-v1.fr.md) champ par champ | +| Monter les machines dont les labs ont besoin | **[Pour le formateur](docs/trainer.fr.md)** | +| Savoir où dsoxlab écrit sur mon disque | [Où dsoxlab écrit](docs/files.fr.md) | +| Voir toutes les commandes | [Référence des commandes](docs/commands.fr.md), produite par la CLI | -## Architecture - -```text -src/dsoxlab/ -├── cli.py ← point d'entrée Typer (+ groupe de commandes i18n) -├── config.py ← LAB_HOME, contexte actif, .dsoxlab-context.json -├── i18n/ ← get_lang(), _(), en.py + fr.py -├── models/ ← schémas typés du contrat déclaratif -├── discovery/ ← scan meta.yml + tous les lab.yaml du dépôt courant -├── services/ ← orchestration métier (get_lab, run_lab, check_lab…) -├── sessions/ ← persistance SQLite (results + hint_requests) -├── runtimes/ ← BaseRuntime, ShellRuntime, IncusRuntime, KvmRuntime -├── infra/ ← Terraform, Ansible, inventaire, snapshots -├── validators/ ← validation du contrat (meta.yml + lab.yaml) -├── reporting/ ← sorties terminal Rich -├── utils/ ← wrapper subprocess centralisé -└── templates/ ← templates de provisioning (HCL, cloud-init) -``` - -Le moteur reste indépendant de l'arborescence d'un dépôt : `discovery/` -fonctionne sur n'importe quel arbre déclaré par le `meta.yml`. +Dans le terminal, `dsoxlab fullhelp` affiche le guide complet de la plateforme, +en anglais comme en français. --- -## Persistance - -Tout ce que dsoxlab conserve tient dans quatre emplacements, et **la -progression est par catalogue**, jamais globale : deux catalogues côte à côte -ont chacun leur historique. - -| Quoi | Où | Surcharge | -| --- | --- | --- | -| Scores et indices | `/.dsoxlab.db` (SQLite) | aucune, c'est le dépôt | -| Contexte de session | `/.dsoxlab-context.json` | aucune, c'est le dépôt | -| Journal, état Terraform | `~/.local/state/dsoxlab/` | `XDG_STATE_HOME` | -| Catalogue de démonstration | `~/.local/share/dsoxlab/demo/` | `XDG_DATA_HOME` | - -Les deux premiers sont à ignorer dans le `.gitignore` de chaque catalogue. +## Pourquoi dsoxlab -Il n'existe **aucun fichier de configuration utilisateur** : rien n'est lu dans -`~/.config/dsoxlab/`. Ce que l'on règle passe par le contrat (`meta.yml`), par -le contexte actif (`dsoxlab use`) ou par une variable d'environnement -(`DSOXLAB_PROVIDER`, `DSOXLAB_LANG`, `DSOXLAB_LOG`, -`DSOXLAB_HOST_READY_TIMEOUT`). +- **Un moteur, plusieurs catalogues.** Une seule CLI pilote tous les dépôts de + formation. On ajoute un domaine en écrivant un `meta.yml`, pas en modifiant + l'outil. +- **La validation prouve, elle ne fait pas confiance.** Les labs sont évalués + sur l'**état réel du système** (`pytest-testinfra`) et, quand le sujet le + justifie, sur la **persistance après reboot**, le piège qui fait échouer les + candidats RHCSA/LFCS. +- **Deux runtimes.** Un lab se joue soit dans un **shell** sur votre machine, + soit dans une **vm** provisionnée pour vous. Quel backend sert cette VM + (KVM/libvirt, Incus, Outscale) est la décision du catalogue, pas celle du lab. +- **Une progression qui persiste, par catalogue.** Scores, coûts des indices et + historique sont conservés dans le catalogue lui-même : deux catalogues ne + mélangent jamais leurs historiques. +- **Expérience bilingue.** Chaque chaîne affichée existe en anglais et en + français (`DSOXLAB_LANG=en|fr`). --- -## Développement +## Contribuer ```bash -uv sync # installe les dépendances de dev -uv run pre-commit install --install-hooks # active les hooks git -uv run ruff check src/dsoxlab # lint + sécurité -uv run mypy src/dsoxlab # typage (strict) -uv run pytest # tests unitaires -uv run pytest tests_e2e # bout en bout, sur la roue construite +git clone https://github.com/stephrobert/dsoxlab.git +cd dsoxlab +uv tool install --editable . ``` -`tests_e2e/` est une suite boîte noire : elle n'importe jamais `dsoxlab`. Elle -construit la roue, l'installe dans un environnement virtuel jetable et pilote le -binaire par sous-processus, de `dsoxlab demo` jusqu'au 100/100. C'est le seul -contrôle qui voie un défaut d'empaquetage. - -Voir [CONTRIBUTING.fr.md](./CONTRIBUTING.fr.md) pour le workflow, les conventions de -commit et les règles non négociables (le moteur reste neutre vis-à-vis du -domaine, toute chaîne affichée passe par `_()` dans les deux langues). +Voir [CONTRIBUTING.fr.md](./CONTRIBUTING.fr.md) pour l'installation de +développement, les contrôles de qualité et les règles non négociables (le moteur +reste neutre vis-à-vis du domaine, toute chaîne affichée passe par `_()` dans +les deux langues). --- diff --git a/README.md b/README.md index 444cdc9..d663cc8 100644 --- a/README.md +++ b/README.md @@ -35,385 +35,73 @@ locally, per catalog. --- -## Why dsoxlab - -- **One engine, many catalogs.** A single CLI drives every training - repository. Add a new domain by writing a `meta.yml`, not by patching the - tool. -- **Validation proves, it does not trust.** Labs are graded on the actual - **state of the system** (`pytest-testinfra`) and, when it matters, on - **persistence after reboot** — the trap that fails RHCSA/LFCS candidates. -- **Multiple runtimes.** Run a lab in a plain **shell**, an **Incus** - container, or a full **KVM/libvirt** virtual machine, chosen per lab. -- **Progress that sticks.** Scores, hint costs and history are persisted in a - local SQLite database following the XDG spec. -- **Bilingual UX.** Every user-facing string ships in English and French - (`DSOXLAB_LANG=en|fr`). - ---- +## Install and play, in five minutes -## Installation - -Requires **Python 3.11+**. +Requires **Python 3.11+**. Nothing to clone, nothing to build. ```bash uv tool install dsoxlab # or: pipx install dsoxlab -dsoxlab --version -``` - -That is the whole installation. Nothing to clone, nothing to build. - ---- - -## Your first lab, in five minutes - -You do not need a catalog to start. `dsoxlab demo` installs a one-lab -demonstration catalog whose subject is dsoxlab itself: the loop you will repeat -on every other lab. - -```bash -dsoxlab demo # installs it and prints what to do next +dsoxlab demo # installs a one-lab demonstration catalog cd ~/.local/share/dsoxlab/demo dsoxlab course premiers-pas # the lesson -dsoxlab run premiers-pas # drops you in the lab's work directory +dsoxlab run premiers-pas # drops you into the lab's work directory dsoxlab challenge premiers-pas # the mission dsoxlab check premiers-pas # the tests, and the score ``` -No VM, no container, no Docker: it runs anywhere dsoxlab runs. - ---- - -## Then, a real catalog - -Labs live in their own repositories, published separately from the engine. -Clone one, then run `dsoxlab` from inside it: - -```bash -git clone https://github.com/stephrobert/linux-dsoxlab-training.git -cd linux-dsoxlab-training - -dsoxlab doctor # what this catalog needs, and what is missing -dsoxlab list-labs -dsoxlab run -dsoxlab check -``` - -`dsoxlab doctor` only reports what *this* catalog needs: a shell-only catalog -never asks for a hypervisor. `dsoxlab doctor --fix` repairs what can be repaired -safely. If something goes wrong, `dsoxlab support` produces an anonymised -diagnostic report ready to paste into an issue. - -### Installing from source (contributors) - -```bash -git clone https://github.com/stephrobert/dsoxlab.git -cd dsoxlab -uv tool install --editable . -``` - -### Reading the course - -The course itself is not bundled in the lab repository: each lab declares a -`doc_url` pointing to the trainer's site. `dsoxlab guide` opens that page in a -real browser tab, so it renders exactly as published, with its images, code -blocks and navigation. - -```bash -dsoxlab guide # the active lab -dsoxlab guide # a specific lab -dsoxlab guide --print # print the URL instead (useful over SSH) -``` - -The URL carries campaign parameters (`utm_source=dsoxlab`, `utm_medium=lab`, -`utm_campaign=`), so a trainer can see which labs actually drive readers -to which guides. A link opened from a local interface carries no usable referrer, -so without this marking those reads would be indistinguishable from direct traffic. - -Switch language on the fly: - -```bash -DSOXLAB_LANG=fr dsoxlab fullhelp -DSOXLAB_LANG=en dsoxlab fullhelp -``` - -### Reading long courses - -`course` and `challenge` go through the pager as soon as their output is -taller than the terminal, so a course of several hundred lines stays readable -without depending on the terminal's scrollback. Pipes and redirections are -never paged: they always receive the full text. - -```bash -DSOXLAB_PAGER='bat --plain' dsoxlab course # pick your pager (default: less -R) -dsoxlab course --no-pager # dump everything at once -dsoxlab course > course.txt # never paged: plain text -``` +The demonstration lab is about dsoxlab itself, and needs no VM, no container +and no Docker: it runs anywhere dsoxlab runs. --- -## The declarative contract - -A lab-hosting repository describes its catalog with two levels of files. - -The contract is **versioned**. Both files accept a `schema_version` integer at -their root; leaving it out means version 1, so no existing catalog has anything -to change. A file declaring a version this dsoxlab does not read is named in a -message rather than vanishing from the catalog. Field by field, with the -evolution rule and the migration path to a future v2: -**[the v1 contract reference](docs/contract-v1.md)**. - -Two JSON Schemas describe the same contract for your editor and your CI: -[`schemas/lab.schema.json`](schemas/lab.schema.json) and -[`schemas/meta.schema.json`](schemas/meta.schema.json). Put this line at the top -of a file and any editor running `yaml-language-server` (the YAML extension of -VS Code, among others) completes fields and underlines mistakes as you type: - -```yaml -# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json -id: my-lab -title: My lab -``` +## Documentation -Swap `main` for a release tag (`v0.1.46`) to pin the schema in CI. A test -confronts both schemas with the parser, in both directions, so they cannot -quietly drift from the code. - -### 1. `meta.yml` at the repository root - -Repository metadata, infrastructure topology (KVM/Incus), section ordering. - -```yaml -repo: - id: linux-training - category: linux - title: "Linux Training — RHCSA + LFCS 2026" - blog_url: "https://blog.stephane-robert.info/docs/admin-serveurs/linux/" - -infra: - network: lab-linux - hosts: - - { name: alma-rhcsa-1.lab, ip: 10.10.30.11, distro: alma10 } - - { name: alma-rhcsa-2.lab, ip: 10.10.30.12, distro: alma10 } - - { name: ubuntu-lfcs-1.lab, ip: 10.10.30.21, distro: ubuntu24 } - -sections: - - id: depanner - title: "Troubleshooting" - labs: - - depanner/services-processus/service-crash-loop - - depanner/stockage-fs/disque-plein-mais-pas-de-fichiers -``` - -### 2. `lab.yaml` per lab (under `labs//
//`) - -Lab-specific metadata (skills, runtime, distros, validation). - -```yaml -id: depanner-service-crash-loop -title: "Identify and fix a crash-looping systemd service" -section: linux -level: l2 -track: [depanner, rhcsa] -skills: [systemd, journalctl, debug] -difficulty: intermediate -estimated_time: 30m -runtime: - type: kvm - host: alma-rhcsa-1.lab -distros: [rhel10, ubuntu24.04] -doc_url: https://blog.stephane-robert.info/docs/admin-serveurs/linux/depanner/services-processus/service-crash-loop/ -validation: - functional: true - security: false - persistence_after_reboot: true -``` - -An optional `lab.fr.yaml` may override `title` and `description` for French -only. The same convention applies one level up: a `meta.fr.yml` next to -`meta.yml` translates `repo.title`, `repo.description` and the `sections[]` -titles and descriptions, with sections matched by `id`. - -#### Optional `runtime.fixtures`: the starting files of a `shell` lab - -A `shell` lab lists the files it hands to the learner. Each entry is a path -**relative to `/fixtures/`**, and that path is **preserved** under the -workdir: intermediate directories are created for you, so a lab can ship a local -Terraform module without its `main.tf` colliding with the root one. - -```yaml -runtime: - type: shell - workdir: challenge/work - fixtures: - - versions.tf - - main.tf - - modules/stockage/main.tf # lands in /modules/stockage/main.tf -``` - -Two things to know. A file **not listed here is not copied**, even if it sits in -`fixtures/` — the runtime iterates over this list, not over the directory. And a -path that is absolute or contains `..` is refused with a warning, so a fixture -never writes outside the workdir. - -#### Optional `runtime.services`: containerized sidecars - -Services of a repository share a Docker network, each reachable by its declared -`name`: a lab with an application and its database writes `DB_HOST: db`. On -Docker's default bridge there is no name resolution between containers, so such -a lab could not be declared at all. - -A lab can declare containers that must be up while it runs. dsoxlab starts them -on `run`/`check`/`submit` and stops them on `clean`. The mechanism is -domain-agnostic: it launches exactly the image you declare and knows nothing -about what runs inside. A cloud-API emulator for a Terraform lab is one use; -a database for an app lab is another. - -```yaml -runtime: - type: shell - workdir: challenge/work - services: - - name: cloud # required, unique within the lab - image: some/emulator:1.2.3 # required, the exact image to run - ports: ["4566:4566"] # optional, docker -p mappings - run_args: ["-u", "root"] # optional, extra docker run flags - env: { DEBUG: "1" } # optional, -e VAR=value - ready_tcp: 4566 # optional, HOST port to wait on. Beware: on a - # published port Docker's proxy accepts before - # the service listens, so this alone lies. - ready_exec: check-health # optional but recommended: probe run INSIDE the - # container, retried until it succeeds. This is - # the only trustworthy readiness signal. - post_start: # optional: initialise the service once ready - - seed --from fixtures # (schema, secrets, repository…). Run through - # `docker exec`, no shell. Replayed on every - # start, so it must be idempotent. - ready_timeout: 90 # optional, seconds before giving up (default 90) -``` - -Containers are named `dsoxlab--` so they never collide -across repos. Docker must be reachable; if it is not, the lab fails fast rather -than running against a missing service. - -`dsoxlab validate-structure` checks that the whole contract holds: the root -`meta.yml` is well-formed, every referenced lab exists with a valid -`lab.yaml`, each `runtime.host` maps to a declared host, and all referenced -scripts and test files are present. - ---- +Three readers, three doors. Every page names its audience in its first lines. -## Command reference - - - -| Command | Purpose | +| I want to… | Read | | --- | --- | -| `dsoxlab challenge` | Display the challenge mission for this lab (challenge/README.md). | -| `dsoxlab check` | Run tests, calculate score (hints deducted) and record result. | -| `dsoxlab clean` | Remove all resources created by the lab. | -| `dsoxlab completion install` | Install completion for the current shell (zsh, bash). | -| `dsoxlab completion show` | Print the completion script on stdout, writing nothing. | -| `dsoxlab course` | Display a course section, or the table of contents if no section is given. | -| `dsoxlab demo` | Install a demonstration catalog and play a first lab, with nothing to clone and nothing to provision. | -| `dsoxlab destroy` | Destroy the lab infrastructure (terraform destroy), including machines left outside the state. | -| `dsoxlab doctor` | Diagnose the environment (runtimes, tools, detected labs). | -| `dsoxlab fullhelp` | Show the complete platform guide (concepts, workflow, commands). | -| `dsoxlab guide` | Open the lab's online guide in your web browser. | -| `dsoxlab hint` | Show the next challenge hint (deducts points from final score). | -| `dsoxlab install` | Deprecated: use `dsoxlab completion install`. Installs shell completion. | -| `dsoxlab instructor bootstrap` | Generate the lab SSH key (if missing) and check that terraform/ansible-runner are installed. | -| `dsoxlab list-labs` | List all available labs (filtered by active context if set). | -| `dsoxlab next` | Recommend the next lab or challenge to complete in the active context. | -| `dsoxlab progress` | Show progression by bloc (labs completed, average score, challenges and capstones). | -| `dsoxlab provision` | Provision the lab infrastructure (terraform apply on the current provider). | -| `dsoxlab reset` | Reset the lab to its initial state (clean + restart). | -| `dsoxlab run` | Prepare and start the lab environment. | -| `dsoxlab scores` | Show recorded scores history. | -| `dsoxlab show` | Show details and status of a lab. | -| `dsoxlab ssh` | Open an interactive SSH session on a lab host. | -| `dsoxlab status` | Check SSH connectivity to all hosts declared in meta.yml, and name the cause when one stays silent. | -| `dsoxlab submit` | Final submission: run tests, record score, then type 'exit' to leave the session. | -| `dsoxlab support` | Produce an anonymised diagnostic report, ready to paste into an issue. | -| `dsoxlab use` | Sets the active context (section and/or default level). Use --reset to clear it. | -| `dsoxlab validate-structure` | Check structure and metadata of all labs. | - - - -Each runtime is opt-in and self-describing (`is_available()`), so the engine -never hard-depends on a backend the user has not installed. Provisioning -templates (Terraform HCL, cloud-init) live under `dsoxlab.templates` and -support Incus, KVM/libvirt and Outscale. - ---- - -## Architecture - -```text -src/dsoxlab/ -├── cli.py ← Typer entry point (+ i18n command group) -├── config.py ← LAB_HOME, active context, .dsoxlab-context.json -├── i18n/ ← get_lang(), _(), en.py + fr.py -├── models/ ← typed schemas of the declarative contract -├── discovery/ ← scan meta.yml + every lab.yaml of the current repo -├── services/ ← business orchestration (get_lab, run_lab, check_lab…) -├── sessions/ ← SQLite persistence (results + hint_requests) -├── runtimes/ ← BaseRuntime, ShellRuntime, IncusRuntime, KvmRuntime -├── infra/ ← Terraform, Ansible, inventory, snapshots -├── validators/ ← contract validation (meta.yml + lab.yaml) -├── reporting/ ← Rich terminal output -├── utils/ ← centralized subprocess wrapper -└── templates/ ← provisioning templates (HCL, cloud-init) -``` +| Install dsoxlab, play labs, understand my score | **[For the learner](docs/learner.md)** | +| Write my own catalog of labs | **[For the catalog author](docs/catalog-author.md)**, then [the v1 contract](docs/contract-v1.md) field by field | +| Run the machines the labs need | **[For the trainer](docs/trainer.md)** | +| Know where dsoxlab writes on my disk | [Where dsoxlab writes](docs/files.md) | +| See every command | [Command reference](docs/commands.md), generated from the CLI | -The engine stays independent of any single repository layout: `discovery/` -works on whatever tree the `meta.yml` declares. +In the terminal, `dsoxlab fullhelp` prints the whole platform guide, in English +or in French. --- -## Persistence - -Everything dsoxlab keeps lives in four places, and **progress is per -catalog**, never global: two catalogs side by side each keep their own history. - -| What | Where | Override | -| --- | --- | --- | -| Scores and hints | `/.dsoxlab.db` (SQLite) | none, it is the repo | -| Session context | `/.dsoxlab-context.json` | none, it is the repo | -| Log, Terraform state | `~/.local/state/dsoxlab/` | `XDG_STATE_HOME` | -| Demonstration catalog | `~/.local/share/dsoxlab/demo/` | `XDG_DATA_HOME` | - -The first two belong in each catalog's `.gitignore`. +## Why dsoxlab -There is **no user configuration file**: nothing is read from -`~/.config/dsoxlab/`. What can be set goes through the contract (`meta.yml`), -the active context (`dsoxlab use`) or an environment variable -(`DSOXLAB_PROVIDER`, `DSOXLAB_LANG`, `DSOXLAB_LOG`, -`DSOXLAB_HOST_READY_TIMEOUT`). +- **One engine, many catalogs.** A single CLI drives every training + repository. Add a new domain by writing a `meta.yml`, not by patching the + tool. +- **Validation proves, it does not trust.** Labs are graded on the actual + **state of the system** (`pytest-testinfra`) and, when it matters, on + **persistence after reboot** — the trap that fails RHCSA/LFCS candidates. +- **Two runtimes.** A lab runs either in a **shell** on your own machine, or in + a **vm** provisioned for you. Which backend serves that VM (KVM/libvirt, + Incus, Outscale) is the catalog's decision, not the lab's. +- **Progress that sticks, per catalog.** Scores, hint costs and history are + persisted inside the catalog itself, so two catalogs never mix their + histories. +- **Bilingual UX.** Every user-facing string ships in English and French + (`DSOXLAB_LANG=en|fr`). --- -## Development +## Contributing ```bash -uv sync # install dev dependencies -uv run pre-commit install --install-hooks # enable the git hooks -uv run ruff check src/dsoxlab # lint + security -uv run mypy src/dsoxlab # type-check (strict) -uv run pytest # unit tests -uv run pytest tests_e2e # end-to-end, on the built wheel +git clone https://github.com/stephrobert/dsoxlab.git +cd dsoxlab +uv tool install --editable . ``` -`tests_e2e/` is a black-box suite: it never imports `dsoxlab`. It builds the -wheel, installs it into a throwaway virtualenv and drives the binary by -subprocess, from `dsoxlab demo` to a 100/100 score. It is the only check that -sees a packaging defect. - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the workflow, the commit -conventions, and the non-negotiable rules (the engine must stay -domain-agnostic, every user-facing string goes through `_()` in both -languages). +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development setup, the quality +gates and the non-negotiable rules (the engine stays domain-agnostic, every +user-facing string goes through `_()` in both languages). --- diff --git a/docs/README.fr.md b/docs/README.fr.md new file mode 100644 index 0000000..02d846c --- /dev/null +++ b/docs/README.fr.md @@ -0,0 +1,49 @@ +# Documentation de dsoxlab + +**Public :** cette page est un aiguillage, et n'appartient à personne en +particulier. Toutes les autres nomment leur lecteur dès leurs premières lignes, +parce qu'un document qui répond à trois publics à la fois n'en sert aucun. + +**Langue :** [English](./README.md) · [Français](./README.fr.md) + +`dsoxlab` transforme des exercices déclaratifs en environnements de lab +reproductibles, exécutables et vérifiables. Le [README du dépôt](../README.fr.md) +dit ce que c'est en trente secondes ; ces pages disent comment cela fonctionne. + +--- + +## Les trois portes + +| Page | Pour vous si… | +| --- | --- | +| **[Pour l'apprenant](./learner.fr.md)** | Vous installez dsoxlab, jouez des labs et voulez comprendre votre note | +| **[Pour l'auteur de catalogue](./catalog-author.fr.md)** | Vous écrivez des labs dans votre propre dépôt | +| **[Pour le formateur](./trainer.fr.md)** | Vous montez les machines et les providers dont les labs ont besoin | + +## Références + +| Page | Contenu | +| --- | --- | +| [Le contrat v1](./contract-v1.fr.md) | `meta.yml` et `lab.yaml`, champ par champ, avec ce que la v1 garantit | +| [Référence des commandes](./commands.fr.md) | Toutes les commandes, produites par la CLI elle-même | +| [Où dsoxlab écrit](./files.fr.md) | Chaque fichier que dsoxlab crée, et les variables d'environnement qu'il lit | +| [La marque](./brand.fr.md) | Nom, logo et conditions d'usage | + +Les contributeurs ont [CONTRIBUTING.fr.md](../CONTRIBUTING.fr.md) : installation, +contrôles de qualité, carte de l'architecture et conventions de commit. + +--- + +## Deux habitudes qui valent d'être reprises + +**Rien n'est écrit deux fois ici.** La table des commandes est produite par la +CLI, et les emplacements de fichiers sont confrontés au code par +`tests/test_documentation_synchrone.py`, qui les dérive en appelant les mêmes +fonctions que la CLI appelle. Les deux contrôles existent parce que les deux +avaient déjà dérivé : la table décrivait un `cleanup.sh` que le contrat +interdit, et la section « Persistence » désignait une base de données qui n'a +jamais existé. + +**Ces pages sont du Markdown dans le dépôt, délibérément.** Aucun générateur de +site n'est installé ici : la documentation se lit là où elle s'écrit, se +versionne avec le code qu'elle décrit, et se relit dans la même pull request. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..490e257 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,48 @@ +# dsoxlab documentation + +**Audience:** this page is a switchboard, and belongs to no one in particular. +Every other page names its reader in its first lines, because a document that +answers three people at once answers none of them. + +**Language:** [English](./README.md) · [Français](./README.fr.md) + +`dsoxlab` turns declarative exercises into reproducible, runnable and verifiable +lab environments. The [repository README](../README.md) says what it is in +thirty seconds; these pages say how it works. + +--- + +## The three doors + +| Page | For you if… | +| --- | --- | +| **[For the learner](./learner.md)** | You install dsoxlab, play labs and want to understand your score | +| **[For the catalog author](./catalog-author.md)** | You write labs in your own repository | +| **[For the trainer](./trainer.md)** | You run the machines and providers the labs need | + +## References + +| Page | Content | +| --- | --- | +| [The v1 contract](./contract-v1.md) | `meta.yml` and `lab.yaml`, field by field, with what version 1 guarantees | +| [Command reference](./commands.md) | Every command, generated from the CLI itself | +| [Where dsoxlab writes](./files.md) | Every file dsoxlab creates, and the environment variables it reads | +| [The mark](./brand.md) | Name, logo and their usage terms | + +Contributors have [CONTRIBUTING.md](../CONTRIBUTING.md): setup, quality gates, +architecture map and commit conventions. + +--- + +## Two habits worth borrowing + +**Nothing here is written twice.** The command table is generated from the CLI, +and the file locations are checked against the code by +`tests/test_documentation_synchrone.py`, which derives them by calling the same +functions the CLI calls. Both controls exist because both had already drifted: +the command table described a `cleanup.sh` the contract forbids, and the +persistence section pointed at a database that never existed. + +**These pages are Markdown in the repository, deliberately.** There is no site +generator here, so the documentation is read where it is written, versioned with +the code that it describes, and reviewed in the same pull request. diff --git a/docs/brand.fr.md b/docs/brand.fr.md index dc7e756..02e5457 100644 --- a/docs/brand.fr.md +++ b/docs/brand.fr.md @@ -1,5 +1,9 @@ # La marque +**Public :** quiconque affiche la marque, dans un catalogue, un support de +cours ou un article. Le nom et le logo ne sont pas couverts par la licence +Apache 2.0. + **Langue :** [English](./brand.md) · [Français](./brand.fr.md) ![dsoxlab](assets/brand/dsoxlab-lockup-light.svg) diff --git a/docs/brand.md b/docs/brand.md index 6ae33c4..783ebbc 100644 --- a/docs/brand.md +++ b/docs/brand.md @@ -1,5 +1,8 @@ # The mark +**Audience:** anyone who displays the mark — a catalog, a slide deck, an +article. The name and the logo are not covered by the Apache 2.0 licence. + **Language:** [English](./brand.md) · [Français](./brand.fr.md) ![dsoxlab](assets/brand/dsoxlab-lockup-light.svg) diff --git a/docs/catalog-author.fr.md b/docs/catalog-author.fr.md new file mode 100644 index 0000000..d7735fa --- /dev/null +++ b/docs/catalog-author.fr.md @@ -0,0 +1,221 @@ +# dsoxlab pour l'auteur de catalogue + +**Public :** vous écrivez des labs, dans votre propre dépôt. Vous voulez savoir +ce que dsoxlab lit, ce qu'il refuse, et où il vous ignorera en silence. + +**Langue :** [English](./catalog-author.md) · [Français](./catalog-author.fr.md) + +La référence champ par champ est [le contrat v1](./contract-v1.fr.md). Cette +page est le mode d'emploi autour : comment un catalogue est agencé, dans quel +ordre le contrôler, et quels pièges coûtent le plus de temps. + +--- + +## Ce qu'est un catalogue + +Un dépôt avec un `meta.yml` à la racine et un `lab.yaml` par lab sous `labs/`. +Rien d'autre ne le lie à dsoxlab : aucune dépendance à installer, aucun plugin à +écrire. Retirer dsoxlab doit laisser les labs jouables à la main +(`ansible-playbook setup.yaml` puis `pytest`) : c'est le test de non-couplage, et +c'est lui qui garde le moteur neutre vis-à-vis du domaine. + +```text +ma-formation/ +├── meta.yml ← catalogue : identité, topologie, ordre des sections +├── meta.fr.yml ← optionnel : titres et descriptions en français +├── ssh/id_ed25519.pub ← seulement si le catalogue déclare des labs vm +└── labs/ + └── mon-domaine/l1/premier-lab/ + ├── lab.yaml ← obligatoire + ├── README.md ← obligatoire + ├── scenario.md ← obligatoire + ├── setup.yaml ← obligatoire pour runtime vm (Ansible) + ├── cleanup.yaml ← obligatoire pour runtime vm (Ansible) + ├── fixtures/ ← optionnel, pour runtime shell + └── challenge/ + ├── README.md ← la mission affichée par `dsoxlab challenge` + ├── hints.yaml ← optionnel : les indices et leur coût + └── tests/ + └── test_functional.py ← obligatoire, nom exact +``` + +`challenge/tests/test_functional.py` est le seul nom de fichier de test que le +validator exige. Rien n'interdit d'en poser d'autres à côté : pytest collecte le +répertoire. + +--- + +## L'ordre des opérations + +**`dsoxlab list-labs` d'abord, `dsoxlab validate-structure` ensuite.** Pas +l'inverse, et c'est le conseil le plus utile de cette page. + +Un `lab.yaml` qui lève au parsing fait **disparaître son lab en silence** : le +scanner journalise un avertissement et passe au suivant. `validate-structure` +itère ensuite sur les labs **découverts avec succès** : il valide les +survivants et ne dit rien du disparu. Un lab absent de `list-labs` est presque +toujours un `lab.yaml` qui lève. + +L'avertissement, lui, atteint bien `~/.local/state/dsoxlab/dsoxlab.log` (et +`dsoxlab support` le collecte) : le diagnostic est à une commande, une fois +qu'on sait où regarder. + +Une exception depuis la 0.1.46 : un `schema_version` que cet outil ne sait pas +lire s'affiche à l'écran et nomme le fichier, au lieu de s'évanouir. + +--- + +## Ce que `validate-structure` vérifie + +Trois familles, toutes locales et hors ligne par défaut. + +**Structure.** `lab.yaml`, `README.md`, `scenario.md`, `challenge/tests/` et +`challenge/tests/test_functional.py`. Un lab `vm` exige en plus `setup.yaml` et +`cleanup.yaml`, un `runtime.targets[]` non vide, et un `runtime.default` qui +corresponde à l'une de ces cibles. Un lab `shell` exige un `runtime.workdir` non +vide. + +**Métadonnées.** `id`, `title`, `level` et `doc_url` non vides, `skills` et +`distros` non vides, `doc_url` en `http(s)`, `lab_type` parmi +`lab | challenge | capstone`, et `exam_passing_score` entre 1 et 100 quand il est +déclaré. + +**Contenu.** Tout lien relatif d'un Markdown du lab pointe sur un fichier +existant ; le barème annoncé correspond à la note réellement calculée ; un +document traduit d'un seul côté est signalé ; `runtime.targets[].host` et les +`roles` existent dans les hôtes du `meta.yml` ; et aucun fichier d'un répertoire +`solution/` n'est lisible en clair (un catalogue sans `solution/` n'est pas en +faute, il a fait un autre choix). + +`--check-urls` ajoute le seul contrôle réseau : chaque `doc_url` doit répondre. + +**Ce qu'il ne peut pas vérifier :** qu'un lab listé dans `meta.yml` existe sur le +disque. Le validator parcourt ce que la découverte a déjà chargé. D'où l'ordre +ci-dessus. + +--- + +## Les pièges + +**1. `runtime.type` vaut `vm`, pas `kvm` ni `incus`.** Ces deux valeurs sont des +alias tolérés, traités à l'identique. Le vrai backend vient de +`meta.yml: infra.provider`. Écrivez `vm`. + +**2. `runtime.host` n'existe pas.** Aucun code ne le lit, il est donc ignoré en +silence. Le FQDN vit dans `runtime.targets[].host`. + +**3. La découverte se fait par chemin, jamais par `id`.** Un lab existe si et +seulement si `labs/**/lab.yaml` existe. Le `meta.yml` ne fait qu'**ordonner** les +labs et **nommer** les blocs ; le rattachement compare le chemin relatif depuis +`labs/` aux entrées `sections[].labs[]`. L'`id` n'est qu'une clé CLI. + +**4. Le zéro-bash est imposé.** Le validator **rejette** `cleanup.sh`, +`runtime/kvm.sh`, `runtime/incus.sh`, `runtime/shell.sh` et `Makefile` dans un +répertoire de lab. La préparation est déclarative (`lab.yaml`) ou Ansible +(`setup.yaml`). + +**5. Une fixture non déclarée n'est pas copiée, et rien ne le signale.** Le +runtime shell itère sur `runtime.fixtures`, **pas** sur le répertoire +`fixtures/`. Un lab qui livre des fichiers sans les déclarer ouvre sur un +répertoire de travail vide, et l'apprenant n'a rien à faire. Le contrôle se fait +à la main : + +```bash +dsoxlab run +ls /challenge/work # doit lister exactement ce que fixtures déclare +``` + +Le chemin déclaré est préservé : `modules/stockage/main.tf` atterrit sous +`/modules/stockage/main.tf`, répertoires intermédiaires compris. Un +chemin absolu, ou contenant `..`, est refusé avec un avertissement. + +**6. Une clé hors contrat est signalée, pas refusée.** Depuis la 0.1.54, +`validate-structure` nomme toute clé que le moteur ne lira jamais, avec la plus +proche qu'il lit vraiment au même niveau. Le parseur reste tolérant à dessein, +c'est une garantie de la v1 : ce contrôle est un lint, pas un refus de charger. + +--- + +## Des tests qui prouvent + +Un lab est noté par pytest avec `pytest-testinfra`, et les deux sont embarqués +dans dsoxlab : un catalogue n'installe aucun outillage de test. + +Écrivez des assertions sur l'**état du système**, jamais sur les commandes +tapées. Pour un lab `vm`, le `conftest.py` du catalogue construit les hôtes +testinfra depuis l'inventaire que dsoxlab génère : + +```python +from dsoxlab.infra.inventory import build_inventory, read_terraform_outputs, write_ssh_config +``` + +C'est le seul import qu'un catalogue fait de dsoxlab, et il existe pour qu'aucun +lab ne code une adresse IP en dur. L'hôte à inspecter est nommé par +`DSOXLAB_TARGET_HOST`, que `dsoxlab check --target ` pose : sans le lire, un +lab multi-distributions ne teste jamais que son hôte par défaut. + +Trois groupes Ansible sont injectés à l'exécution : `labenv` (tous les hôtes du +`meta.yml`, porteur des host_vars), `lab_target` (la cible résolue, celle que les +playbooks d'un lab doivent viser) et un `lab_` par entrée de `roles`. + +--- + +## Traductions + +| Fichier | Surcharge | +| --- | --- | +| `lab.fr.yaml` | `title` et `description` de ce lab, et rien d'autre | +| `meta.fr.yml` | `repo.title`, `repo.description`, `sections[].title` et `sections[].description`, appariés **par `id`** | +| `course.fr.yaml` | Les titres de sections du cours | + +Les fichiers de base portent l'anglais, puisque l'anglais est la langue par +défaut de l'outil. + +`course.yaml` est ce qui permet à `dsoxlab course` d'afficher **une section à la +fois**, avec `--next`, `--prev` et `--section`, et de retenir où l'apprenant +s'est arrêté : + +```yaml +sections: + - id: navigation + title: Se déplacer dans l'arborescence + file: course/01-navigation.md +``` + +Sans lui, `course` retombe sur `scenario.md` + `README.md` affichés d'un bloc, +ce qui explique la longueur des cours longs. + +--- + +## Les schémas, dans l'éditeur et en CI + +Deux schémas JSON décrivent le même contrat, et un test les confronte au parseur +dans les deux sens pour qu'ils ne dérivent pas : +[`schemas/meta.schema.json`](../schemas/meta.schema.json) et +[`schemas/lab.schema.json`](../schemas/lab.schema.json). + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json +id: mon-lab +title: Mon lab +``` + +Tout éditeur qui fait tourner `yaml-language-server` complète alors les champs et +souligne les fautes à la frappe. En CI, la validation se fait sans installer +dsoxlab : + +```bash +uvx check-jsonschema \ + --schemafile https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json \ + $(find labs -name lab.yaml) +``` + +Remplacez `main` par un tag de version dans l'URL pour figer le schéma. + +--- + +## Pour aller plus loin + +- [Le contrat v1, champ par champ](./contract-v1.fr.md) +- [Où dsoxlab écrit](./files.fr.md) +- [L'infrastructure, pour le formateur](./trainer.fr.md) diff --git a/docs/catalog-author.md b/docs/catalog-author.md new file mode 100644 index 0000000..f5770a7 --- /dev/null +++ b/docs/catalog-author.md @@ -0,0 +1,215 @@ +# dsoxlab for the catalog author + +**Audience:** you are writing labs, in your own repository. You want to know +what dsoxlab reads, what it refuses, and where it will silently ignore you. + +**Language:** [English](./catalog-author.md) · [Français](./catalog-author.fr.md) + +The field-by-field reference is [the v1 contract](./contract-v1.md). This page +is the workflow around it: how a catalog is laid out, in which order to check +it, and the traps that cost the most time. + +--- + +## What a catalog is + +A repository with a root `meta.yml` and one `lab.yaml` per lab under `labs/`. +Nothing else ties it to dsoxlab: no dependency to install, no plugin to write. +Removing dsoxlab must still leave the labs playable by hand +(`ansible-playbook setup.yaml` then `pytest`) — that is the test of +non-coupling, and it is what keeps the engine domain-agnostic. + +```text +my-training/ +├── meta.yml ← catalog: identity, topology, section ordering +├── meta.fr.yml ← optional: French titles and descriptions +├── ssh/id_ed25519.pub ← only if the catalog declares vm labs +└── labs/ + └── my-domain/l1/first-lab/ + ├── lab.yaml ← required + ├── README.md ← required + ├── scenario.md ← required + ├── setup.yaml ← required for runtime vm (Ansible) + ├── cleanup.yaml ← required for runtime vm (Ansible) + ├── fixtures/ ← optional, for runtime shell + └── challenge/ + ├── README.md ← the mission shown by `dsoxlab challenge` + ├── hints.yaml ← optional: the hints and their cost + └── tests/ + └── test_functional.py ← required, exact name +``` + +`challenge/tests/test_functional.py` is the only test file name the structure +validator requires. Add others next to it if you like — pytest collects the +directory. + +--- + +## The order of operations + +**`dsoxlab list-labs` first, `dsoxlab validate-structure` second.** Not the +other way round, and this is the single most useful thing on this page. + +A `lab.yaml` that raises while being parsed makes its lab **disappear in +silence**: the scanner logs a warning and moves on. `validate-structure` then +iterates over the labs that were *successfully discovered*, so it validates the +survivors and says nothing about the casualty. A lab missing from `list-labs` is +almost always a `lab.yaml` that raises. + +The warning does reach `~/.local/state/dsoxlab/dsoxlab.log` (and `dsoxlab +support` collects it), so the diagnosis is one command away once you know where +to look. + +One exception, since 0.1.46: a `schema_version` this dsoxlab cannot read is +announced on screen and names the file, instead of vanishing. + +--- + +## What `validate-structure` checks + +Three families, all local and offline by default: + +**Structure.** `lab.yaml`, `README.md`, `scenario.md`, `challenge/tests/` and +`challenge/tests/test_functional.py`. A `vm` lab also needs `setup.yaml` and +`cleanup.yaml`, a non-empty `runtime.targets[]`, and a `runtime.default` that +matches one of those targets. A `shell` lab needs a non-empty `runtime.workdir`. + +**Metadata.** `id`, `title`, `level` and `doc_url` non-empty, `skills` and +`distros` non-empty, `doc_url` in `http(s)`, `lab_type` within +`lab | challenge | capstone`, and `exam_passing_score` within 1..100 when +declared. + +**Content.** Every relative link in the lab's Markdown resolves to a file that +exists; the announced total matches the score actually computed; a document +translated on one side only is reported; `runtime.targets[].host` and the +`roles` map to hosts declared in `meta.yml`; and no file of a `solution/` +directory is readable in clear text (a catalog without `solution/` is not at +fault, it made another choice). + +`--check-urls` adds the only network control: each `doc_url` must answer. + +**What it cannot check:** that a lab listed in `meta.yml` exists on disk. The +validator walks what discovery already loaded. Hence the order above. + +--- + +## The traps + +**1. `runtime.type` is `vm`, not `kvm` or `incus`.** Those two are tolerated +aliases and behave identically. The real backend comes from `meta.yml: +infra.provider`. Write `vm`. + +**2. `runtime.host` does not exist.** No code reads it, so it is ignored in +silence. The FQDN belongs in `runtime.targets[].host`. + +**3. Discovery goes by path, never by `id`.** A lab exists if and only if +`labs/**/lab.yaml` exists. `meta.yml` only **orders** labs and **names** the +blocks; the match compares the path relative to `labs/` against +`sections[].labs[]`. The `id` is only a CLI key. + +**4. Zero bash is enforced.** The validator **rejects** `cleanup.sh`, +`runtime/kvm.sh`, `runtime/incus.sh`, `runtime/shell.sh` and `Makefile` inside a +lab directory. Preparation is declarative (`lab.yaml`) or Ansible +(`setup.yaml`). + +**5. An undeclared fixture is not copied, and nothing says so.** The shell +runtime iterates over `runtime.fixtures`, **not** over the `fixtures/` +directory. A lab that ships files without listing them opens on an empty work +directory, and the learner has nothing to work with. Check it by hand: + +```bash +dsoxlab run +ls /challenge/work # must list exactly what fixtures declares +``` + +The declared path is preserved: `modules/storage/main.tf` lands under +`/modules/storage/main.tf`, intermediate directories included. An +absolute path, or one containing `..`, is refused with a warning. + +**6. A key outside the contract is reported, not refused.** Since 0.1.54 +`validate-structure` names any key the engine will never read, along with the +closest one it does read at the same level. The parser stays tolerant on +purpose — that is a v1 guarantee — so this is a lint, not a load failure. + +--- + +## Tests that prove + +A lab is graded by pytest with `pytest-testinfra`, and both ship inside dsoxlab: +a catalog installs no test tooling of its own. + +Write assertions on the **state of the system**, never on the commands typed. +For a `vm` lab, the catalog's `conftest.py` builds the testinfra hosts from the +inventory dsoxlab generates: + +```python +from dsoxlab.infra.inventory import build_inventory, read_terraform_outputs, write_ssh_config +``` + +That is the only import a catalog makes from dsoxlab, and it exists so no lab +ever hardcodes an IP address. The host to inspect is named by +`DSOXLAB_TARGET_HOST`, which `dsoxlab check --target ` sets: without +reading it, a multi-distro lab always tests its default host. + +Three Ansible groups are injected at run time: `labenv` (every host of the +`meta.yml`, carrying the host vars), `lab_target` (the resolved target — this is +what a lab's playbooks must address) and one `lab_` per entry of `roles`. + +--- + +## Translations + +| File | Overrides | +| --- | --- | +| `lab.fr.yaml` | `title` and `description` of that lab, and nothing else | +| `meta.fr.yml` | `repo.title`, `repo.description`, `sections[].title` and `sections[].description`, matched **by `id`** | +| `course.fr.yaml` | the section titles of the course | + +The base files carry English, since English is the tool's default language. + +`course.yaml` is what lets `dsoxlab course` show **one section at a time**, with +`--next`, `--prev` and `--section`, and remember where the learner stopped: + +```yaml +sections: + - id: navigation + title: Moving around the tree + file: course/01-navigation.md +``` + +Without it, `course` falls back to `scenario.md` + `README.md` shown in one +block — which is why long courses are long. + +--- + +## Schemas, in your editor and in CI + +Two JSON Schemas describe the same contract, and a test confronts them with the +parser in both directions so they cannot drift: +[`schemas/meta.schema.json`](../schemas/meta.schema.json) and +[`schemas/lab.schema.json`](../schemas/lab.schema.json). + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json +id: my-lab +title: My lab +``` + +Any editor running `yaml-language-server` then completes fields and underlines +mistakes as you type. In CI, validate without installing dsoxlab: + +```bash +uvx check-jsonschema \ + --schemafile https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json \ + $(find labs -name lab.yaml) +``` + +Pin a release tag instead of `main` in the URL to freeze the schema. + +--- + +## Going further + +- [The v1 contract, field by field](./contract-v1.md) +- [Where dsoxlab writes](./files.md) +- [Infrastructure, for the trainer](./trainer.md) diff --git a/docs/commands.fr.md b/docs/commands.fr.md new file mode 100644 index 0000000..865946b --- /dev/null +++ b/docs/commands.fr.md @@ -0,0 +1,69 @@ +# Toutes les commandes dsoxlab + +**Public :** tout le monde. Cette page est une référence, pas un tutoriel : les +trois guides ([apprenant](./learner.fr.md), [auteur de +catalogue](./catalog-author.fr.md), [formateur](./trainer.fr.md)) disent quand +se servir de quoi. + +**Langue :** [English](./commands.md) · [Français](./commands.fr.md) + +La table ci-dessous est **produite par la CLI elle-même** via +`scripts/generer-doc.py`, et un test échoue dès qu'elle dérive. L'éditer à la +main ne sert à rien : la prochaine exécution l'écrase. + +Pour les options d'une commande, `dsoxlab --help`. Pour le guide +complet de la plateforme dans le terminal, `dsoxlab fullhelp`. + + + +| Commande | Rôle | +| --- | --- | +| `dsoxlab challenge` | Affiche la mission du challenge (challenge/README.md). | +| `dsoxlab check` | Exécute les tests, calcule le score (hints déduits) et enregistre le résultat. | +| `dsoxlab clean` | Supprime toutes les ressources créées par le lab. | +| `dsoxlab completion install` | Installe l'auto-complétion pour le shell courant (zsh, bash). | +| `dsoxlab completion show` | Imprime le script de complétion sur la sortie standard, sans rien écrire. | +| `dsoxlab course` | Affiche une section du cours, ou le sommaire si aucune section n'est précisée. | +| `dsoxlab demo` | Installe un catalogue de démonstration et joue un premier lab, sans rien cloner ni provisionner. | +| `dsoxlab destroy` | Détruit l'infrastructure du lab (terraform destroy), machines restées hors du state comprises. | +| `dsoxlab doctor` | Diagnostique l'environnement (runtimes, outils, labs détectés). | +| `dsoxlab fullhelp` | Affiche le guide complet de la plateforme (concepts, workflow, commandes). | +| `dsoxlab guide` | Ouvre le guide en ligne du lab dans le navigateur. | +| `dsoxlab hint` | Affiche le prochain indice du challenge (déduit des points au score final). | +| `dsoxlab install` | Déprécié : utilise « dsoxlab completion install ». Installe l'auto-complétion. | +| `dsoxlab instructor bootstrap` | Génère la clé SSH du lab (si absente) et vérifie que terraform/ansible-runner sont installés. | +| `dsoxlab list-labs` | Liste tous les labs disponibles (filtrés par contexte actif si défini). | +| `dsoxlab next` | Recommande le prochain lab ou challenge à compléter dans le contexte actif. | +| `dsoxlab progress` | Affiche la progression par bloc (labs complétés, score moyen, challenges et capstones). | +| `dsoxlab provision` | Provisionne l'infrastructure du lab (terraform apply sur le provider courant). | +| `dsoxlab reset` | Remet le lab à l'état initial (clean + redémarrage). | +| `dsoxlab run` | Prépare et démarre l'environnement du lab. | +| `dsoxlab scores` | Affiche l'historique des scores enregistrés. | +| `dsoxlab show` | Affiche le détail et le statut d'un lab. | +| `dsoxlab ssh` | Ouvre une session SSH interactive sur un hôte du lab. | +| `dsoxlab status` | Vérifie la connectivité SSH des hôtes déclarés dans meta.yml, et nomme la cause quand l'un reste muet. | +| `dsoxlab submit` | Soumission finale : lance les tests, enregistre le score, puis tapez 'exit' pour quitter la session. | +| `dsoxlab support` | Produit un rapport de diagnostic anonymisé, à coller dans une issue. | +| `dsoxlab use` | Définit le contexte actif (section et/ou niveau par défaut). Utilisez --reset pour l'effacer. | +| `dsoxlab validate-structure` | Vérifie la structure et les métadonnées de tous les labs. | + + + +## Les codes de sortie qui veulent dire quelque chose + +| Code | Sens | +| --- | --- | +| `5` | `provision` a trouvé des machines qu'un provisioning en échec a laissées hors du state Terraform. Le message nomme la commande qui les retire | +| `6` | `destroy` n'a pas pu retirer ces machines | +| `7` | Une autre commande dsoxlab tient déjà le verrou d'écriture de ce catalogue. Le message la nomme | +| `130` | La commande a été interrompue (Ctrl-C), et dit comment reprendre | + +Chacun existe pour la même raison : un échec qui ne se dit pas est pire qu'un +échec. `destroy` sortait en succès en laissant des machines debout. + +## Options globales + +`--verbose` / `-v` (répétable), `--debug` (équivalent à `-vv`) et `--version`, +toutes avant la commande. Quelle que soit la verbosité, le journal complet est +écrit dans `~/.local/state/dsoxlab/dsoxlab.log`, et jamais sur la sortie +standard : `--json` reste lisible par un programme même en mode bavard. diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..1d02ad8 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,69 @@ +# Every dsoxlab command + +**Audience:** anyone. This page is a reference, not a tutorial — the three +guides ([learner](./learner.md), [catalog author](./catalog-author.md), +[trainer](./trainer.md)) say when to reach for what. + +**Language:** [English](./commands.md) · [Français](./commands.fr.md) + +The table below is **generated from the CLI itself** by +`scripts/generer-doc.py`, and a test fails when it drifts. Editing it by hand is +pointless: the next run overwrites it. + +For the options of a command, `dsoxlab --help`. For the whole platform +guide in the terminal, `dsoxlab fullhelp`. + + + +| Command | Purpose | +| --- | --- | +| `dsoxlab challenge` | Display the challenge mission for this lab (challenge/README.md). | +| `dsoxlab check` | Run tests, calculate score (hints deducted) and record result. | +| `dsoxlab clean` | Remove all resources created by the lab. | +| `dsoxlab completion install` | Install completion for the current shell (zsh, bash). | +| `dsoxlab completion show` | Print the completion script on stdout, writing nothing. | +| `dsoxlab course` | Display a course section, or the table of contents if no section is given. | +| `dsoxlab demo` | Install a demonstration catalog and play a first lab, with nothing to clone and nothing to provision. | +| `dsoxlab destroy` | Destroy the lab infrastructure (terraform destroy), including machines left outside the state. | +| `dsoxlab doctor` | Diagnose the environment (runtimes, tools, detected labs). | +| `dsoxlab fullhelp` | Show the complete platform guide (concepts, workflow, commands). | +| `dsoxlab guide` | Open the lab's online guide in your web browser. | +| `dsoxlab hint` | Show the next challenge hint (deducts points from final score). | +| `dsoxlab install` | Deprecated: use `dsoxlab completion install`. Installs shell completion. | +| `dsoxlab instructor bootstrap` | Generate the lab SSH key (if missing) and check that terraform/ansible-runner are installed. | +| `dsoxlab list-labs` | List all available labs (filtered by active context if set). | +| `dsoxlab next` | Recommend the next lab or challenge to complete in the active context. | +| `dsoxlab progress` | Show progression by bloc (labs completed, average score, challenges and capstones). | +| `dsoxlab provision` | Provision the lab infrastructure (terraform apply on the current provider). | +| `dsoxlab reset` | Reset the lab to its initial state (clean + restart). | +| `dsoxlab run` | Prepare and start the lab environment. | +| `dsoxlab scores` | Show recorded scores history. | +| `dsoxlab show` | Show details and status of a lab. | +| `dsoxlab ssh` | Open an interactive SSH session on a lab host. | +| `dsoxlab status` | Check SSH connectivity to all hosts declared in meta.yml, and name the cause when one stays silent. | +| `dsoxlab submit` | Final submission: run tests, record score, then type 'exit' to leave the session. | +| `dsoxlab support` | Produce an anonymised diagnostic report, ready to paste into an issue. | +| `dsoxlab use` | Sets the active context (section and/or default level). Use --reset to clear it. | +| `dsoxlab validate-structure` | Check structure and metadata of all labs. | + + + +## Exit codes worth knowing + +| Code | Meaning | +| --- | --- | +| `5` | `provision` found machines a failed provisioning left outside the Terraform state. The message names the command that removes them | +| `6` | `destroy` could not remove those machines | +| `7` | Another dsoxlab command already holds this catalog's write lock. The message names it | +| `130` | The command was interrupted (Ctrl-C), and says how to resume | + +Every one of them exists because a failure that does not announce itself is +worse than a failure: `destroy` used to exit successfully while leaving machines +running. + +## Global options + +`--verbose` / `-v` (repeatable), `--debug` (same as `-vv`) and `--version`, all +before the command. Whatever the verbosity, the full log is written to +`~/.local/state/dsoxlab/dsoxlab.log`, and never to standard output — so `--json` +stays machine-readable even in verbose mode. diff --git a/docs/contract-v1.fr.md b/docs/contract-v1.fr.md index 8bae006..701d184 100644 --- a/docs/contract-v1.fr.md +++ b/docs/contract-v1.fr.md @@ -1,5 +1,8 @@ # Le contrat déclaratif, version 1 +**Public :** les auteurs de catalogue. Le mode d'emploi autour de cette +référence est [Pour l'auteur de catalogue](./catalog-author.fr.md). + **Langue :** [English](./contract-v1.md) · [Français](./contract-v1.fr.md) `meta.yml` et `lab.yaml` sont l'interface publique de dsoxlab. C'est ce diff --git a/docs/contract-v1.md b/docs/contract-v1.md index 3f64883..5a9d17e 100644 --- a/docs/contract-v1.md +++ b/docs/contract-v1.md @@ -1,5 +1,8 @@ # The declarative contract, version 1 +**Audience:** catalog authors. The workflow around this reference is +[For the catalog author](./catalog-author.md). + **Language:** [English](./contract-v1.md) · [Français](./contract-v1.fr.md) `meta.yml` and `lab.yaml` are the public interface of dsoxlab. They are what a diff --git a/docs/files.fr.md b/docs/files.fr.md new file mode 100644 index 0000000..4baeed7 --- /dev/null +++ b/docs/files.fr.md @@ -0,0 +1,119 @@ +# Où dsoxlab écrit + +**Public :** apprenants, auteurs de catalogue et formateurs. C'est la page de +référence commune aux trois, et le seul endroit où ces emplacements sont écrits. + +**Langue :** [English](./files.md) · [Français](./files.fr.md) + +dsoxlab conserve son état à deux endroits, et nulle part ailleurs : **dans le +catalogue que vous jouez**, et **sous votre répertoire personnel**. La +progression appartient au premier, elle est donc **par catalogue** : deux +catalogues côte à côte gardent des historiques séparés, et supprimer un +catalogue supprime le sien avec lui. + +--- + +## Dans le dépôt du catalogue + +| Chemin | Contenu | Écrit par | +| --- | --- | --- | +| `/.dsoxlab.db` | Base SQLite : `results` (les notes) et `hint_requests` | `check`, `submit`, `hint` | +| `/.dsoxlab-context.json` | Section, niveau, langue, lab, cible et provider actifs, plus la position de lecture du cours | `use`, `run`, `course` | + +Les deux sont à ignorer dans le `.gitignore` du catalogue. Aucun des deux ne se +déplace : ils **sont** le dépôt, et c'est ce qui fait suivre la progression au +catalogue plutôt qu'à la machine. + +Un catalogue qui déclare des labs `vm` porte aussi sa propre paire de clés SSH, +sous `/ssh/id_ed25519` et son `.pub`, produite par +`dsoxlab instructor bootstrap`. La moitié privée ne se commite jamais. + +--- + +## Sous votre répertoire personnel + +| Chemin | Contenu | Déplacé par | +| --- | --- | --- | +| `~/.local/state/dsoxlab/dsoxlab.log` | Journal complet de chaque commande, quelle que soit la verbosité | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//terraform//` | Répertoire de travail et state Terraform | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//cloud-init/` | Templates cloud-init recopiés depuis l'outil pour le provisioning | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//dsoxlab.lock` | Verrou d'écriture de ce catalogue (`flock`) | `XDG_STATE_HOME` | +| `~/.cache/dsoxlab//inventory.json` | Inventaire Ansible généré | `XDG_CACHE_HOME` | +| `~/.cache/dsoxlab//ssh_config` | Configuration OpenSSH générée pour les hôtes du lab | `XDG_CACHE_HOME` | +| `~/.cache/dsoxlab/version-check.json` | Dernière version vue sur PyPI, et sa date | `XDG_CACHE_HOME` | +| `~/.local/share/dsoxlab/demo/` | Catalogue de démonstration installé par `dsoxlab demo` | `XDG_DATA_HOME` | +| `~/.ssh/config.d/.conf` | Fragment SSH des hôtes du lab, pour que `ssh`, `scp` et votre IDE les atteignent par leur nom | aucune | + +`` est le `repo.id` du `meta.yml` du catalogue. Le verrou retombe +sur le nom du répertoire suffixé d'une empreinte de son chemin absolu quand +aucun `meta.yml` n'est lisible : deux clones du même catalogue partagent ainsi +un seul verrou et un seul state Terraform. + +Le state Terraform est délibérément **hors** du catalogue : un state posé dans +un dépôt de labs finit commité, et un state commité ment. + +Deux de ces fichiers sont des caches, et les perdre ne coûte qu'une +régénération : `inventory.json` et `ssh_config` sont reconstruits à la prochaine +commande qui en a besoin. Tout ce qui pointerait sur le `ssh_config` généré (un +`Include`, un profil d'IDE) doit donc tolérer sa disparition. + +`dsoxlab completion install` écrit en dehors de ces deux familles, une fois : +la complétion (`~/.zfunc/_dsoxlab` plus une ligne dans `~/.zshrc`, ou +`~/.bash_completion.d/dsoxlab` plus une ligne dans `~/.bashrc`). + +Jusqu'en 0.1.61, `dsoxlab install` écrivait aussi un wrapper dans +`~/.local/bin/dsoxlab`. Ce n'est plus le cas : `uv tool install` et `pipx` y +posent leur lanceur, et écrire par-dessus un lien symbolique écrit dans sa +cible. S'il en reste un d'une ancienne installation, il vous appartient de le +retirer. + +--- + +## Ce qui n'existe pas + +Ces chemins ont été documentés par le passé. Ils figurent ici pour que personne +ne les cherche à nouveau : + +- **Aucun `~/.config/dsoxlab/config.yaml`, et aucun fichier de configuration + utilisateur.** Ce qui se règle passe par le contrat (`meta.yml`), par le + contexte actif (`dsoxlab use`) ou par une variable d'environnement. La + découverte multi-catalogues par un tel fichier est prévue + ([#78](https://github.com/stephrobert/dsoxlab/issues/78)) ; tant qu'elle n'est + pas livrée, aucun code ne lit ce chemin. +- **Aucun `~/.local/share/dsoxlab/progress.db`.** Les notes vivent dans + `/.dsoxlab.db`, catalogue par catalogue. +- **`XDG_CONFIG_HOME` n'est lue nulle part.** Les trois variables ci-dessus sont + les seules variables XDG que dsoxlab honore. + +--- + +## Variables d'environnement + +| Variable | Effet | +| --- | --- | +| `LAB_HOME` | Racine du catalogue à jouer, au lieu de la détection automatique | +| `DSOXLAB_LANG` | Langue d'affichage (`en` / `fr`), prioritaire sur le fichier de contexte | +| `DSOXLAB_PROVIDER` | Provider d'infrastructure, prioritaire sur `dsoxlab use --provider` | +| `DSOXLAB_TARGET` | Cible par défaut d'un lab `vm`, quand la session n'en fixe aucune | +| `DSOXLAB_PAGER` | Pagineur de `course` et `challenge` (puis `PAGER`, défaut `less -R`) | +| `DSOXLAB_LOG` | `DSOXLAB_LOG=debug` équivaut à `-vv` | +| `DSOXLAB_HOST_READY_TIMEOUT` | Secondes d'attente d'un hôte fraîchement provisionné (défaut 180) | +| `DSOXLAB_NO_UPDATE_CHECK` | À `1`, coupe l'avis quotidien de nouvelle version | +| `DSOXLAB_OUTSCALE_PROFILE`, `DSOXLAB_AWS_PROFILE` | Profil d'identifiants de ces providers | + +Deux autres sont **posées par dsoxlab** pour que les tests d'un lab les lisent, +et ne se règlent pas à la main : `DSOXLAB_TARGET_HOST` (l'hôte que les tests +doivent inspecter, ce qui permet à un lab multi-distributions de valider la +cible choisie) et `DSOXLAB_LAB_SESSION` (l'identifiant du lab, dans la session +ouverte par `run`). + +--- + +## Cette page ne peut plus dériver + +`tests/test_documentation_synchrone.py` dérive les emplacements réels **en +appelant le code**, les mêmes fonctions que la CLI appelle, et échoue dès qu'un +chemin cité dans une page de documentation ne correspond à aucun d'eux. Les faux +`progress.db` et `config.yaml` ci-dessus sont ce qui l'a motivé : ils sont +restés documentés des mois durant, faute de quelqu'un pour lire la documentation +et le code en même temps. diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 0000000..e5ebbc1 --- /dev/null +++ b/docs/files.md @@ -0,0 +1,115 @@ +# Where dsoxlab writes + +**Audience:** learners, catalog authors and trainers. This is the reference page +the three share, and the only place these locations are written down. + +**Language:** [English](./files.md) · [Français](./files.fr.md) + +dsoxlab keeps state in two families, and nowhere else: **inside the catalog you +are playing**, and **under your home directory**. Progress belongs to the first +family, so it is **per catalog**: two catalogs side by side keep separate +histories, and deleting a catalog deletes its history with it. + +--- + +## In the catalog repository + +| Path | What it holds | Written by | +| --- | --- | --- | +| `/.dsoxlab.db` | SQLite database: `results` (scores) and `hint_requests` | `check`, `submit`, `hint` | +| `/.dsoxlab-context.json` | active section, level, language, lab, target, provider, and the course position | `use`, `run`, `course` | + +Both belong in the catalog's `.gitignore`. Neither can be moved: they *are* the +repository, which is what makes progress follow the catalog rather than the +machine. + +A catalog that declares `vm` labs also carries its own SSH key pair under +`/ssh/id_ed25519{,.pub}`, generated by `dsoxlab instructor bootstrap`. +The private half must never be committed. + +--- + +## Under your home directory + +| Path | What it holds | Moved by | +| --- | --- | --- | +| `~/.local/state/dsoxlab/dsoxlab.log` | full log of every command, whatever the verbosity | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//terraform//` | Terraform working directory and state | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//cloud-init/` | cloud-init templates copied out of the tool for provisioning | `XDG_STATE_HOME` | +| `~/.local/state/dsoxlab//dsoxlab.lock` | the per-catalog write lock (`flock`) | `XDG_STATE_HOME` | +| `~/.cache/dsoxlab//inventory.json` | generated Ansible inventory | `XDG_CACHE_HOME` | +| `~/.cache/dsoxlab//ssh_config` | generated OpenSSH config for the lab hosts | `XDG_CACHE_HOME` | +| `~/.cache/dsoxlab/version-check.json` | last version seen on PyPI, and when | `XDG_CACHE_HOME` | +| `~/.local/share/dsoxlab/demo/` | demonstration catalog installed by `dsoxlab demo` | `XDG_DATA_HOME` | +| `~/.ssh/config.d/.conf` | SSH fragment for the lab hosts, so `ssh`, `scp` and your IDE reach them by name | none | + +`` is `repo.id` from the catalog's `meta.yml`. The lock falls back +to the directory name plus a fingerprint of its absolute path when no `meta.yml` +can be read, so two clones of the same catalog share one lock and one Terraform +state. + +The Terraform state is deliberately **outside** the catalog: a state file in a +lab repository gets committed, and a committed state is a state that lies. + +Two of these are caches, and losing them costs nothing but a regeneration: +`inventory.json` and `ssh_config` are rebuilt on the next command that needs +them. Anything that points at the generated `ssh_config` (an `Include`, an IDE +profile) must tolerate its disappearance. + +`dsoxlab completion install` writes outside these families, once: the shell +completion (`~/.zfunc/_dsoxlab` plus a line in `~/.zshrc`, or +`~/.bash_completion.d/dsoxlab` plus a line in `~/.bashrc`). + +Up to 0.1.61, `dsoxlab install` also wrote a wrapper at `~/.local/bin/dsoxlab`. +It no longer does: `uv tool install` and `pipx` put their launcher exactly +there, and writing over a symlink writes into its target. If an old install left +one behind, it is yours to delete. + +--- + +## What does not exist + +These paths have been documented in the past. They are listed here so nobody +looks for them again: + +- **No `~/.config/dsoxlab/config.yaml`, and no user configuration file at all.** + What can be set goes through the contract (`meta.yml`), the active context + (`dsoxlab use`) or an environment variable. Multi-catalog discovery through + such a file is planned + ([#78](https://github.com/stephrobert/dsoxlab/issues/78)); until it lands, no + code reads that path. +- **No `~/.local/share/dsoxlab/progress.db`.** Scores live in + `/.dsoxlab.db`, per catalog. +- **`XDG_CONFIG_HOME` is read nowhere.** The three variables above are the only + XDG variables dsoxlab honours. + +--- + +## Environment variables + +| Variable | Effect | +| --- | --- | +| `LAB_HOME` | Root of the catalog to work on, instead of auto-detection | +| `DSOXLAB_LANG` | Display language (`en` / `fr`), wins over the context file | +| `DSOXLAB_PROVIDER` | Infrastructure provider, wins over `dsoxlab use --provider` | +| `DSOXLAB_TARGET` | Default target of a `vm` lab, when the session sets none | +| `DSOXLAB_PAGER` | Pager for `course` and `challenge` (then `PAGER`, default `less -R`) | +| `DSOXLAB_LOG` | `DSOXLAB_LOG=debug` is the same as `-vv` | +| `DSOXLAB_HOST_READY_TIMEOUT` | Seconds to wait for a provisioned host to answer (default 180) | +| `DSOXLAB_NO_UPDATE_CHECK` | Set to `1` to silence the daily PyPI version check | +| `DSOXLAB_OUTSCALE_PROFILE`, `DSOXLAB_AWS_PROFILE` | Credentials profile for those providers | + +Two more are **exported by dsoxlab** for the tests of a lab to read, and are not +meant to be set by hand: `DSOXLAB_TARGET_HOST` (the host the tests must inspect, +which is how a multi-distro lab tests the target you chose) and +`DSOXLAB_LAB_SESSION` (the lab id, inside the session `run` opens). + +--- + +## This page cannot drift + +`tests/test_documentation_synchrone.py` derives the real locations **by calling +the code** — the same functions the CLI calls — and fails when a path cited in +any documentation page matches none of them. The false `progress.db` and +`config.yaml` above are what motivated it: they were documented for months +because nothing read the documentation and the code at the same time. diff --git a/docs/learner.fr.md b/docs/learner.fr.md new file mode 100644 index 0000000..7e82e2f --- /dev/null +++ b/docs/learner.fr.md @@ -0,0 +1,198 @@ +# dsoxlab pour l'apprenant + +**Public :** vous voulez jouer des labs. Vous n'écrivez pas de catalogue et vous +ne montez pas une plateforme de formation : ces deux métiers ont [leurs propres +pages](./README.fr.md). + +**Langue :** [English](./learner.md) · [Français](./learner.fr.md) + +--- + +## Installer + +Python 3.11 ou plus récent, et c'est tout le prérequis. + +```bash +uv tool install dsoxlab # ou : pipx install dsoxlab +dsoxlab --version +``` + +Rien à cloner, rien à compiler. En option, `dsoxlab install` ajoute la +complétion pour bash et zsh (rechargez votre shell ensuite). + +--- + +## Votre premier lab, en cinq minutes + +Nul besoin de catalogue pour commencer. `dsoxlab demo` installe un catalogue de +démonstration d'un seul lab, dont le sujet est dsoxlab lui-même : la boucle que +vous répéterez sur tous les autres. + +```bash +dsoxlab demo # l'installe et dit quoi faire ensuite +cd ~/.local/share/dsoxlab/demo + +dsoxlab course premiers-pas # la leçon +dsoxlab run premiers-pas # vous dépose dans le répertoire de travail +dsoxlab challenge premiers-pas # la mission +dsoxlab check premiers-pas # les tests, et la note +``` + +Ni VM, ni conteneur, ni Docker : il tourne partout où dsoxlab tourne. + +--- + +## Ensuite, un vrai catalogue + +Les labs vivent dans leurs propres dépôts, publiés séparément du moteur. Clonez +en un, puis lancez `dsoxlab` depuis l'intérieur : le catalogue où vous êtes est +celui que dsoxlab sert. + +```bash +git clone https://github.com/stephrobert/linux-dsoxlab-training.git +cd linux-dsoxlab-training + +dsoxlab doctor # ce que ce catalogue exige, et ce qui manque +dsoxlab list-labs +dsoxlab show +dsoxlab run +``` + +`dsoxlab doctor` ne rapporte que ce dont *ce* catalogue a besoin : un catalogue +fait de labs shell ne réclame jamais d'hyperviseur. `dsoxlab doctor --fix` +répare ce qui peut l'être sans risque. + +--- + +## La boucle + +| Étape | Commande | Effet | +| --- | --- | --- | +| 1 | `dsoxlab list-labs` | Parcourir le catalogue. `--section`, `--level`, `--type`, `--bloc` le réduisent | +| 2 | `dsoxlab use
/` | Fixer un contexte actif, pour que les commandes suivantes cessent de demander | +| 3 | `dsoxlab show ` | Compétences, runtime, durée estimée, statut | +| 4 | `dsoxlab course ` | La leçon, section par section quand le lab les déclare | +| 5 | `dsoxlab run ` | Préparer l'environnement et y ouvrir une session | +| 6 | `dsoxlab challenge ` | La mission à accomplir | +| 7 | `dsoxlab hint ` | L'indice suivant, au prix de quelques points | +| 8 | `dsoxlab check ` | Jouer les tests, calculer la note, l'enregistrer | +| 9 | `dsoxlab submit ` | Pareil, puis clore la session pour de bon | +| 10 | `dsoxlab reset ` / `clean ` | Repartir de zéro, ou démonter l'environnement | + +Dès qu'un lab est actif dans la session, l'identifiant devient optionnel : +`dsoxlab check` sait dans quel lab vous êtes. + +`dsoxlab next` recommande la suite dans le contexte actif, `dsoxlab progress` +montre où vous en êtes bloc par bloc, et `dsoxlab scores` liste votre +historique. + +### Ce que `run` ouvre vraiment + +Un lab `shell` vous rend un sous-shell dans le répertoire de travail du lab, sur +votre propre machine. Un lab `vm` provisionne ou réutilise les machines déclarées +par le catalogue et ouvre une session SSH sur la cible. Dans les deux cas on en +sort par `exit`, et `dsoxlab check` fonctionne depuis cette session comme depuis +l'extérieur. + +--- + +## Lire le cours + +Deux commandes, deux choses différentes : + +- **`dsoxlab course`** affiche la leçon livrée avec le lab, dans le terminal. +- **`dsoxlab guide`** ouvre le guide en ligne du lab dans un onglet du + navigateur : il s'affiche exactement comme publié, avec ses images et sa + navigation. `--print` imprime l'URL à la place, ce qu'il faut en SSH. + +`course` et `challenge` passent par un pagineur dès que leur sortie dépasse la +hauteur du terminal : un cours de plusieurs centaines de lignes reste lisible +sans dépendre du scrollback. Les tubes et les redirections ne sont jamais +paginés, ils reçoivent le texte entier. + +```bash +DSOXLAB_PAGER='bat --plain' dsoxlab course # choisir son pagineur (défaut : less -R) +dsoxlab course --no-pager # tout afficher d'un coup +dsoxlab course > cours.txt # jamais paginé : texte brut +``` + +--- + +## Votre note + +La note part de **100**, ou du barème que déclare le `challenge/hints.yaml` du +lab, et chaque indice pris coûte des points. `check` calcule la note finale et +l'enregistre, `scores` affiche l'historique. + +Un lab qui déclare `exam_passing_score` est un examen : `submit` en rend un +**verdict réussi ou échoué** face à ce seuil, exprimé en pourcentage du barème +propre au lab. + +Les tests lisent l'**état du système**, pas les commandes que vous avez tapées. +Aucun crédit pour avoir tapé la bonne commande, aucune pénalité pour être arrivé +au même état autrement. + +--- + +## Langue + +Chaque message existe en anglais et en français. + +```bash +DSOXLAB_LANG=fr dsoxlab list-labs # le temps d'un appel +dsoxlab use linux --lang fr # durablement, pour ce catalogue +``` + +Priorité : `DSOXLAB_LANG` > le fichier de contexte du catalogue > le `LANG` du +système > `en`. + +--- + +## Où vit votre progression + +Dans le catalogue lui-même : `/.dsoxlab.db` pour les notes et les +indices, `/.dsoxlab-context.json` pour le contexte actif. La +progression est donc **par catalogue**, et copier le répertoire du catalogue +copie votre historique avec lui. La liste complète des emplacements est sur +[Où dsoxlab écrit](./files.fr.md). + +--- + +## Quand quelque chose se passe mal + +- **`dsoxlab doctor`** dit ce que ce catalogue exige et ce qui manque, en deux + tableaux : ce qui vous bloque ici, et ce qui n'est qu'informatif. +- **`dsoxlab support`** produit un rapport de diagnostic anonymisé, prêt à + coller dans une issue (aucun chemin personnel, aucune adresse publique). + `--json` rend le même contenu sous forme de document machine. +- **Le journal est toujours écrit**, quelle que soit la verbosité, dans + `~/.local/state/dsoxlab/dsoxlab.log`. Inutile de rejouer une commande pour + savoir ce qu'elle a fait : `-v`, `-vv` et `--debug` ne changent que ce qui + arrive à votre terminal. + +Deux codes de sortie méritent d'être reconnus : + +| Code | Sens | +| --- | --- | +| `7` | Une autre commande dsoxlab écrit déjà dans ce catalogue. Le message la nomme : l'attendre, ou fermer l'autre terminal | +| `130` | Vous avez interrompu la commande (Ctrl-C). Le message indique comment reprendre | + +--- + +## Rester à jour + +dsoxlab regarde une fois par jour si une version plus récente existe sur PyPI et +le dit en fin de commande, sur la sortie d'erreur. Hors ligne, il se tait. + +```bash +uv tool upgrade dsoxlab # ou : pipx upgrade dsoxlab +DSOXLAB_NO_UPDATE_CHECK=1 … # couper l'avis +``` + +--- + +## Pour aller plus loin + +- [Toutes les commandes, produites par la CLI elle-même](./commands.fr.md) +- [Où dsoxlab écrit](./files.fr.md) +- [Écrire son propre catalogue](./catalog-author.fr.md) diff --git a/docs/learner.md b/docs/learner.md new file mode 100644 index 0000000..a3ac46c --- /dev/null +++ b/docs/learner.md @@ -0,0 +1,195 @@ +# dsoxlab for the learner + +**Audience:** you want to play labs. You are not writing a catalog and you are +not running a training platform — those have [their own +pages](./README.md). + +**Language:** [English](./learner.md) · [Français](./learner.fr.md) + +--- + +## Install + +Python 3.11 or newer, and that is the whole prerequisite. + +```bash +uv tool install dsoxlab # or: pipx install dsoxlab +dsoxlab --version +``` + +Nothing to clone, nothing to build. Optionally, `dsoxlab install` adds shell +completion for bash and zsh (reload your shell afterwards). + +--- + +## Your first lab, in five minutes + +You do not need a catalog to start. `dsoxlab demo` installs a one-lab +demonstration catalog whose subject is dsoxlab itself: the loop you will repeat +on every other lab. + +```bash +dsoxlab demo # installs it and prints what to do next +cd ~/.local/share/dsoxlab/demo + +dsoxlab course premiers-pas # the lesson +dsoxlab run premiers-pas # drops you into the lab's work directory +dsoxlab challenge premiers-pas # the mission +dsoxlab check premiers-pas # the tests, and the score +``` + +No VM, no container, no Docker: it runs anywhere dsoxlab runs. + +--- + +## Then, a real catalog + +Labs live in their own repositories, published separately from the engine. +Clone one, then run `dsoxlab` from inside it — the catalog you are in is the +catalog dsoxlab serves. + +```bash +git clone https://github.com/stephrobert/linux-dsoxlab-training.git +cd linux-dsoxlab-training + +dsoxlab doctor # what this catalog needs, and what is missing +dsoxlab list-labs +dsoxlab show +dsoxlab run +``` + +`dsoxlab doctor` only reports what *this* catalog needs: a catalog made of +shell labs never asks for a hypervisor. `dsoxlab doctor --fix` repairs what can +be repaired safely. + +--- + +## The loop + +| Step | Command | What it does | +| --- | --- | --- | +| 1 | `dsoxlab list-labs` | Browse the catalog. `--section`, `--level`, `--type`, `--bloc` narrow it down | +| 2 | `dsoxlab use
/` | Pin an active context, so the next commands stop asking | +| 3 | `dsoxlab show ` | Skills, runtime, estimated time, status | +| 4 | `dsoxlab course ` | The lesson, one section at a time when the lab declares them | +| 5 | `dsoxlab run ` | Prepare the environment and open a session in it | +| 6 | `dsoxlab challenge ` | The mission you have to accomplish | +| 7 | `dsoxlab hint ` | The next hint, at a cost in points | +| 8 | `dsoxlab check ` | Run the tests, compute the score, record it | +| 9 | `dsoxlab submit ` | Same, then close the session for good | +| 10 | `dsoxlab reset ` / `clean ` | Start over, or tear the environment down | + +Once a lab is active in the session, the id becomes optional: `dsoxlab check` +knows which lab you are in. + +`dsoxlab next` recommends what to do next in the active context, `dsoxlab +progress` shows where you stand bloc by bloc, and `dsoxlab scores` lists your +history. + +### What `run` actually opens + +A `shell` lab hands you a sub-shell in the lab's work directory, on your own +machine. A `vm` lab provisions or reuses the machines the catalog declares and +opens an SSH session on the target. Either way you leave it by typing `exit`, +and `dsoxlab check` works from inside that session as well as from outside. + +--- + +## Reading the course + +Two commands, two different things: + +- **`dsoxlab course`** shows the lesson shipped with the lab, in the terminal. +- **`dsoxlab guide`** opens the lab's online guide in a browser tab, so it + renders exactly as published, with its images and navigation. `--print` + prints the URL instead, which is what you want over SSH. + +Both `course` and `challenge` go through a pager as soon as their output is +taller than the terminal, so a long course stays readable without depending on +the scrollback. Pipes and redirections are never paged: they receive the full +text. + +```bash +DSOXLAB_PAGER='bat --plain' dsoxlab course # pick your pager (default: less -R) +dsoxlab course --no-pager # dump everything at once +dsoxlab course > course.txt # never paged: plain text +``` + +--- + +## Your score + +The score starts at **100** — or at whatever total the lab's +`challenge/hints.yaml` declares — and every hint you take costs points. `check` +computes the final score, records it, and `scores` shows the history. + +A lab that declares `exam_passing_score` is an exam: `submit` renders a +**pass or fail verdict** against that mark, expressed as a percentage of the +lab's own total. + +Tests read the **state of the system**, not the commands you typed. There is no +credit for having run the right command, and no penalty for reaching the same +state another way. + +--- + +## Language + +Every message exists in English and French. + +```bash +DSOXLAB_LANG=fr dsoxlab list-labs # for one call +dsoxlab use linux --lang fr # persistently, for this catalog +``` + +Priority: `DSOXLAB_LANG` > the catalog's context file > the system `LANG` > `en`. + +--- + +## Where your progress lives + +In the catalog itself: `/.dsoxlab.db` for scores and hints, +`/.dsoxlab-context.json` for the active context. Progress is therefore +**per catalog**, and copying the catalog directory copies your history with it. +The full list of locations is on [Where dsoxlab writes](./files.md). + +--- + +## When something goes wrong + +- **`dsoxlab doctor`** says what this catalog needs and what is missing, in two + tables: what blocks you here, and what is merely informational. +- **`dsoxlab support`** produces an anonymised diagnostic report, ready to paste + into an issue (no personal path, no public address). `--json` for the same + content as a machine document. +- **The log is always written**, whatever the verbosity, to + `~/.local/state/dsoxlab/dsoxlab.log`. There is no need to replay a command to + find out what it did. `-v`, `-vv` and `--debug` only change what reaches your + terminal. + +Two exit codes are worth recognising: + +| Code | Meaning | +| --- | --- | +| `7` | Another dsoxlab command is already writing in this catalog. The message names it. Wait for it, or close the other terminal | +| `130` | You interrupted the command (Ctrl-C). The message says how to resume | + +--- + +## Staying up to date + +dsoxlab checks once a day whether a newer version exists on PyPI and says so at +the end of a command, on standard error. Offline, it stays silent. + +```bash +uv tool upgrade dsoxlab # or: pipx upgrade dsoxlab +DSOXLAB_NO_UPDATE_CHECK=1 … # silence the check +``` + +--- + +## Going further + +- [Every command, generated from the CLI itself](./commands.md) +- [Where dsoxlab writes](./files.md) +- [Writing your own catalog](./catalog-author.md) diff --git a/docs/trainer.fr.md b/docs/trainer.fr.md new file mode 100644 index 0000000..3f60e6e --- /dev/null +++ b/docs/trainer.fr.md @@ -0,0 +1,180 @@ +# dsoxlab pour le formateur + +**Public :** vous montez l'infrastructure dont les labs ont besoin, machines, +providers, comptes, snapshots. Écrire les labs est [une autre +page](./catalog-author.fr.md), les jouer [une troisième](./learner.fr.md). + +**Langue :** [English](./trainer.md) · [Français](./trainer.fr.md) + +--- + +## Seuls les labs `vm` demandent tout cela + +Un catalogue fait de labs `shell` n'a besoin d'aucune infrastructure : +l'exercice se joue sur la machine de l'apprenant, `dsoxlab provision` n'est +jamais appelé, et le `meta.yml` ne porte aucun bloc `infra:`. C'est un catalogue +conforme, pas un catalogue incomplet. + +Tout ce qui suit concerne les catalogues qui déclarent `runtime.type: vm`. + +--- + +## L'infrastructure est empaquetée dans l'outil + +Les modules Terraform (`kvm`, `incus`, `outscale`) et les templates cloud-init +(AlmaLinux, Ubuntu, Debian) vivent **dans dsoxlab**. Un catalogue ne livre +**aucun** Terraform ni cloud-init : il déclare `infra:` dans son `meta.yml` et +pose sa clé publique dans `ssh/id_ed25519.pub`. + +`dsoxlab provision` recopie les templates vers +`~/.local/state/dsoxlab//`, génère +`.dsoxlab.auto.tfvars.json` depuis le `meta.yml`, et lance Terraform là. Le +state n'atterrit jamais dans le dépôt de labs. + +```yaml +# meta.yml +infra: + provider: kvm # ou une liste de candidats + network: lab-linux # réseau libvirt de ce catalogue + cidr: 10.10.10.0/24 + hosts: + - name: alma-1.lab + distro: alma10 + ram_mb: 2048 + vcpu: 2 + disk_gb: 20 + extra_disk_gb: 5 # second disque (/dev/vdb), pour les labs LVM ou RAID +``` + +Ne déclarez pas d'adresses IP : elles viennent des sorties Terraform, et +l'inventaire en est dérivé. La référence champ par champ, y compris les +surcharges `infra.providers.`, est dans +[le contrat v1](./contract-v1.fr.md). + +Chaque catalogue qui provisionne des machines a intérêt à posséder son propre +réseau libvirt : deux catalogues ne se disputent alors jamais le même +sous-réseau. + +--- + +## Démarrer + +```bash +dsoxlab instructor bootstrap # génère /ssh/id_ed25519 si absente, + # et vérifie terraform + ansible-runner +dsoxlab doctor # ce que ce catalogue exige, et ce qui manque +dsoxlab provision # terraform apply sur le provider courant +dsoxlab status # atteint-on chaque hôte déclaré, et sinon pourquoi +dsoxlab ssh # une session interactive sur l'un d'eux +dsoxlab destroy # tout démonter +``` + +`provision --host ` ne vise qu'une machine, et l'option est répétable ; +sans elle, tout le plan est appliqué. Les ressources partagées (le réseau, les +images de base) sont de toute façon gérées par le graphe de dépendances de +Terraform. + +`dsoxlab doctor` range ses constats en **deux tableaux** : ce qui est *requis +pour ce catalogue*, et ce qui n'est qu'*informatif*. Le classement ne dépend que +de trois faits, jamais du domaine : le catalogue a-t-il des labs `vm`, quel +provider est actif, quels providers déclare-t-il. Un hyperviseur que ce +catalogue n'utilise pas n'apparaît jamais en rouge. + +--- + +## Choisir un provider + +Première règle qui s'applique : `DSOXLAB_PROVIDER` dans l'environnement, puis +`active_provider` du fichier de contexte (posé par `dsoxlab use --provider`), +puis un `meta.yml` qui n'en déclare qu'un. Plusieurs candidats sans choix +explicite n'est pas une erreur en soi : seules les commandes d'infrastructure +refusent d'avancer, et elles le disent. + +```bash +dsoxlab use --provider kvm # durablement, pour ce catalogue +DSOXLAB_PROVIDER=incus dsoxlab provision # le temps d'une commande +``` + +Chaque provider garde son propre state Terraform, sous +`~/.local/state/dsoxlab//terraform//`. Changer de provider +ne détruit donc pas ce que l'autre tient, ce qui est commode, et aussi la façon +dont on oublie une flotte allumée. `dsoxlab status` est l'habitude qui ne coûte +rien. + +--- + +## Deux comptes, et ce que cela change pour les labs + +cloud-init crée les deux mêmes comptes sur chaque nœud, durcis à l'identique +(membre de `wheel`/`sudo`, `sudo NOPASSWD:ALL`, clé SSH uniquement, sans mot de +passe de login, `ssh_pwauth: false`) : + +| Compte | Rôle | +| --- | --- | +| `ansible` | Le compte de **service** de l'automatisation. C'est lui que dsoxlab et les playbooks des labs utilisent pour se connecter (`ansible_user: ansible`, repris dans le `ssh_config` généré) | +| `student` | Le compte **humain**, sur la machine que pilote l'apprenant | + +La séparation est délibérée : traçabilité et révocation. La conséquence pour les +auteurs de labs est concrète : tout ce qui restreint la connexion (`AllowUsers`, +`remote_user`) doit viser **`ansible`**, jamais `student`, sous peine de voir la +commande dsoxlab suivante s'enfermer dehors. + +--- + +## Les snapshots + +`snapshot_required: true` dans le `runtime` d'un lab **engage l'outil**, il ne +l'informe pas : + +- `run` prend un point de reprise du **disque** avant de jouer `setup.yaml`, et + **échoue** s'il n'y arrive pas : un lab qui réclame un filet ne démarre pas + sans lui ; +- `reset` ramène la machine à ce point plutôt que de rejouer `cleanup.yaml` ; +- `clean` retire le point de reprise, et avec lui le fichier de recouvrement + qu'il avait créé. + +L'état mémoire n'est pas capturé : la reprise repart d'un disque cohérent, pas +de la seconde d'avant. + +--- + +## Les machines qui survivent à leur state + +Un `provision` en échec peut laisser des domaines définis sur l'hyperviseur mais +hors du state Terraform. Reprovisionner par-dessus produirait une flotte que +personne ne suit : dsoxlab refuse plutôt, et deux codes de sortie disent de quel +côté cela a lâché. + +| Code | Sens | +| --- | --- | +| `5` | `provision` a trouvé des domaines orphelins et s'est arrêté. Le message nomme la commande qui les retire | +| `6` | `destroy` n'a pas pu les retirer. Quelque chose sur l'hyperviseur les tient encore | + +`destroy` retire aussi ces orphelins, après confirmation (`--yes` la saute), et +sort en non-zéro s'il en reste un. Un `destroy` qui rapportait un succès en +laissant les machines debout est le défaut que cela a remplacé. + +--- + +## Où tout est conservé + +Le state Terraform, le verrou d'écriture, l'inventaire et le `ssh_config` +générés : tout est listé sur [Où dsoxlab écrit](./files.fr.md). Deux points +qu'un formateur a intérêt à garder en tête : + +- **Le `ssh_config` généré est un cache** + (`~/.cache/dsoxlab//`). Il se régénère à la demande, mais il se + purge aussi : ce qui pointerait dessus (un `Include`, un profil d'IDE) doit + survivre à sa disparition. Le fragment écrit dans + `~/.ssh/config.d/.conf` est celui qui est stable. +- **Un catalogue, un verrou.** Une seconde commande concurrente qui écrit sort + en code `7` et nomme la première. Deux clones du même catalogue partagent le + verrou, parce qu'ils partagent le state Terraform. + +--- + +## Pour aller plus loin + +- [Le contrat v1, champ par champ](./contract-v1.fr.md) +- [Où dsoxlab écrit](./files.fr.md) +- [Écrire les labs](./catalog-author.fr.md) diff --git a/docs/trainer.md b/docs/trainer.md new file mode 100644 index 0000000..dd73add --- /dev/null +++ b/docs/trainer.md @@ -0,0 +1,175 @@ +# dsoxlab for the trainer + +**Audience:** you run the infrastructure the labs need — machines, providers, +accounts, snapshots. Writing the labs is [another page](./catalog-author.md); +playing them is [a third one](./learner.md). + +**Language:** [English](./trainer.md) · [Français](./trainer.fr.md) + +--- + +## Only `vm` labs need any of this + +A catalog made of `shell` labs needs no infrastructure at all: the exercise runs +on the learner's own machine, `dsoxlab provision` is never called, and the +`meta.yml` carries no `infra:` block. That is a supported catalog, not an +incomplete one. + +Everything below applies to catalogs that declare `runtime.type: vm`. + +--- + +## The infrastructure is packaged in the tool + +Terraform modules (`kvm`, `incus`, `outscale`) and cloud-init templates +(AlmaLinux, Ubuntu, Debian) live **inside dsoxlab**. A catalog ships **no** +Terraform and **no** cloud-init: it declares `infra:` in its `meta.yml` and puts +its public key in `ssh/id_ed25519.pub`. + +`dsoxlab provision` copies the templates to +`~/.local/state/dsoxlab//`, generates +`.dsoxlab.auto.tfvars.json` from the `meta.yml`, and runs Terraform there. The +state never lands in the lab repository. + +```yaml +# meta.yml +infra: + provider: kvm # or a list of candidates + network: lab-linux # libvirt network of this catalog + cidr: 10.10.10.0/24 + hosts: + - name: alma-1.lab + distro: alma10 + ram_mb: 2048 + vcpu: 2 + disk_gb: 20 + extra_disk_gb: 5 # second disk (/dev/vdb), for LVM or RAID labs +``` + +Do not declare IP addresses: they come from Terraform outputs, and the inventory +is generated from them. The field-by-field reference, including the +`infra.providers.` overrides, is in +[the v1 contract](./contract-v1.md). + +Each catalog that provisions machines should own its libvirt network, so two +catalogs never collide on the same subnet. + +--- + +## Getting started + +```bash +dsoxlab instructor bootstrap # generate /ssh/id_ed25519 if missing, + # and check terraform + ansible-runner +dsoxlab doctor # what this catalog needs, and what is missing +dsoxlab provision # terraform apply on the current provider +dsoxlab status # can we reach every declared host, and if not, why +dsoxlab ssh # an interactive session on one of them +dsoxlab destroy # tear it down +``` + +`provision --host ` targets a single machine and is repeatable; without +it, the whole plan is applied. Shared resources (the network, the base images) +are handled by Terraform's dependency graph either way. + +`dsoxlab doctor` sorts its findings into **two tables**: what is *required for +this catalog*, and what is merely *informational*. The sort depends on three +facts only — does the catalog have `vm` labs, which provider is active, which +providers it declares — never on the domain. A hypervisor this catalog does not +use never shows up in red. + +--- + +## Choosing a provider + +First rule that matches wins: `DSOXLAB_PROVIDER` in the environment, then +`active_provider` in the context file (set by `dsoxlab use --provider`), then a +`meta.yml` declaring a single provider. Several candidates and no explicit +choice is not an error in itself: only the infrastructure commands refuse to +proceed, and they say so. + +```bash +dsoxlab use --provider kvm # persistent, for this catalog +DSOXLAB_PROVIDER=incus dsoxlab provision # one command only +``` + +Each provider keeps its own Terraform state, under +`~/.local/state/dsoxlab//terraform//`. Switching provider +therefore does not destroy what the other one holds — which is convenient, and +also how one forgets a running fleet. `dsoxlab status` is the cheap habit. + +--- + +## Two accounts, and why it matters to the labs + +cloud-init creates the same two accounts on every node, both hardened the same +way (member of `wheel`/`sudo`, `sudo NOPASSWD:ALL`, SSH key only, no login +password, `ssh_pwauth: false`): + +| Account | Role | +| --- | --- | +| `ansible` | The **service** account for automation. This is what dsoxlab and the labs' playbooks connect as (`ansible_user: ansible`, and the same in the generated `ssh_config`) | +| `student` | The **human** account, on the machine the learner drives | + +The separation is deliberate: traceability and revocation. The consequence for +lab authors is concrete — anything that restricts login (`AllowUsers`, +`remote_user`) must name **`ansible`**, never `student`, or the next dsoxlab +command locks itself out. + +--- + +## Snapshots + +`snapshot_required: true` in a lab's `runtime` **commits the tool**, it does not +inform it: + +- `run` takes a **disk** restore point before playing `setup.yaml`, and + **fails** if it cannot — a lab that asks for a safety net does not start + without one; +- `reset` returns the machine to that point instead of replaying + `cleanup.yaml`; +- `clean` removes the restore point, and the overlay file it created with it. + +Memory state is not captured: recovery restarts from a coherent disk, not from +the second before. + +--- + +## Machines that outlive their state + +A failed `provision` can leave domains defined on the hypervisor but outside the +Terraform state. Reprovisioning on top of them would produce a fleet nobody +tracks, so dsoxlab refuses instead, and two exit codes say which side failed: + +| Code | Meaning | +| --- | --- | +| `5` | `provision` found orphan domains and stopped. The message names the command that removes them | +| `6` | `destroy` could not remove them. Something on the hypervisor still holds them | + +`destroy` removes those orphans too, after confirmation (`--yes` skips it), and +exits non-zero if any remains. A `destroy` that reports success while machines +are still up is the failure mode this replaced. + +--- + +## Where everything is kept + +Terraform state, the write lock, the generated inventory and `ssh_config`: all +of it is listed on [Where dsoxlab writes](./files.md). Two points a trainer +should keep in mind: + +- **The generated `ssh_config` is a cache** (`~/.cache/dsoxlab//`). + It is regenerated on demand, but also purgeable: anything pointing at it (an + `Include`, an IDE profile) must survive its disappearance. The fragment + written to `~/.ssh/config.d/.conf` is the stable one. +- **One catalog, one lock.** A second concurrent command that writes exits with + code `7` and names the first. Two clones of the same catalog share the lock, + because they share the Terraform state. + +--- + +## Going further + +- [The v1 contract, field by field](./contract-v1.md) +- [Where dsoxlab writes](./files.md) +- [Writing the labs](./catalog-author.md) diff --git a/pyproject.toml b/pyproject.toml index bbd436a..11c481c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dsoxlab" -version = "0.1.62" +version = "0.1.63" description = "Turn declarative exercises into reproducible, runnable and verifiable lab environments" readme = "README.md" requires-python = ">=3.11" @@ -61,7 +61,7 @@ dsoxlab = "dsoxlab.cli:main" [project.urls] Homepage = "https://blog.stephane-robert.info/" -Documentation = "https://blog.stephane-robert.info/docs/" +Documentation = "https://github.com/stephrobert/dsoxlab/blob/main/docs/README.md" Repository = "https://github.com/stephrobert/dsoxlab" Issues = "https://github.com/stephrobert/dsoxlab/issues" Changelog = "https://github.com/stephrobert/dsoxlab/blob/main/CHANGELOG.md" diff --git a/scripts/generer-doc.py b/scripts/generer-doc.py index 08b4808..e8bf534 100755 --- a/scripts/generer-doc.py +++ b/scripts/generer-doc.py @@ -1,29 +1,44 @@ #!/usr/bin/env python3 -"""Génère depuis la CLI ce que la documentation ne doit plus recopier. +"""Confronte la documentation au code, sur ce que le code peut affirmer. -Une table de commandes écrite à la main dérive, et personne ne s'en aperçoit : -celle du README annonçait encore `dsoxlab clean` exécutant un `cleanup.sh`, -alors que le zéro-bash est un invariant du contrat depuis longtemps, et il y -manquait `demo` et `support`. Rien ne lit la documentation en même temps que le +Deux dérives, la même cause : rien ne lit la documentation en même temps que le code. -Le principe : la section vit entre deux marqueurs, elle est produite par -l'application elle-même, et un mode `--verifier` la compare à ce qu'elle devrait -être. La CI refuse alors une documentation périmée, exactement comme elle -refuserait un test rouge. +1. **La table des commandes.** Écrite à la main, celle du README annonçait + encore `dsoxlab clean` exécutant un `cleanup.sh`, alors que le zéro-bash est + un invariant du contrat depuis longtemps, et il y manquait `demo` et + `support`. Elle est désormais produite par l'application elle-même, entre + deux marqueurs. + +2. **Les emplacements de fichiers.** La section « Persistence » des deux README + annonçait `~/.local/share/dsoxlab/progress.db`, `~/.config/dsoxlab/config.yaml` + et deux variables XDG que rien ne lit : trois affirmations fausses dans le + document que lit quiconque cherche où sont ses notes (issue #86). Les + emplacements de référence sont donc obtenus **en appelant le code**, dans un + sous-processus dont le `HOME` est jetable, puis en relevant ce qui a + réellement été créé. + +Un mode `--verifier` compare sans réécrire : la CI refuse une documentation +périmée, exactement comme elle refuserait un test rouge. La table se régénère, +les chemins non : un chemin faux se corrige à la main, puisque seul l'auteur +sait ce qu'il voulait dire. Usage : python3 scripts/generer-doc.py # réécrit les sections générées - python3 scripts/generer-doc.py --verifier # sort 1 si une section a dérivé + python3 scripts/generer-doc.py --verifier # sort 1 si la doc a dérivé """ from __future__ import annotations +import fnmatch import json import os +import re import subprocess import sys +import tempfile from pathlib import Path +from typing import NamedTuple RACINE = Path(__file__).resolve().parent.parent @@ -32,8 +47,8 @@ #: Fichier de documentation et langue dans laquelle le remplir. CIBLES = { - "README.md": "en", - "README.fr.md": "fr", + "docs/commands.md": "en", + "docs/commands.fr.md": "fr", } TITRES = { @@ -94,6 +109,331 @@ def remplacer(document: Path, contenu: str) -> str | None: return avant + contenu + apres +# ── les chemins cités par la documentation existent-ils ? ───────────────────── + +#: Valeur injectée à la place de l'identifiant du catalogue et du provider. Elle +#: devient un joker dans les motifs de référence, puisque la documentation, elle, +#: écrit ``. +SENTINELLE = "catalogue-sentinelle" + +#: Chemins que la documentation cite **pour dire qu'ils n'existent pas**, et les +#: seules pages qui ont le droit de les citer. L'exemption est nominative : une +#: dispense globale rouvrirait la porte qu'elle prétend fermer, puisque n'importe +#: quelle page pourrait alors réannoncer `progress.db` comme un emplacement réel. +#: +#: `absents_devenus_reels()` tient l'autre bout : le jour où l'un d'eux devient +#: réel (la découverte multi-catalogues de #78, par exemple), la page qui +#: l'annonce absent devient fausse à son tour, et le contrôle le dit. +CHEMINS_ABSENTS = { + "~/.config/dsoxlab": {"docs/files.md", "docs/files.fr.md"}, + "~/.config/dsoxlab/config.yaml": {"docs/files.md", "docs/files.fr.md"}, + "~/.local/share/dsoxlab/progress.db": {"docs/files.md", "docs/files.fr.md"}, + # Écrit jusqu'en 0.1.61, plus depuis : les pages le disent pour qui en + # aurait un qui traîne d'une ancienne installation. + "~/.local/bin/dsoxlab": {"docs/files.md", "docs/files.fr.md"}, +} + +#: Programme joué dans un sous-processus : il appelle les fonctions que la CLI +#: appelle, sur un `HOME` jetable, et rend les emplacements obtenus **plus** ce +#: qui a été créé sur le disque. Les deux sont nécessaires : certaines fonctions +#: rendent un chemin sans le créer (le fragment `~/.ssh/config.d`), et le +#: provisioning crée des répertoires que personne ne rend (`cloud-init/`). +_CHEMINS_REELS = r""" +import json +import os +import tempfile +from pathlib import Path + +from dsoxlab import config, locking, logging_setup +from dsoxlab.discovery.repo import read_repo_metadata +from dsoxlab.infra import inventory, terraform +from dsoxlab.services import demo, update_check +from dsoxlab.sessions import store +from dsoxlab.templates import template_root + +SENTINELLE = "__SENTINELLE__" + +maison = Path(os.environ["HOME"]) +rendus = set() +noms = set() + +providers = sorted( + p.name for p in (template_root() / "terraform").iterdir() if p.is_dir() +) + +with tempfile.TemporaryDirectory() as tmp: + racine = Path(tmp) + (racine / "ssh").mkdir() + (racine / "ssh" / "id_ed25519.pub").write_text("ssh-ed25519 AAAA sentinelle\n") + + # Un provider à la fois : chacun a son work-dir, et la documentation a le + # droit de nommer celui qu'elle veut. + for provider in providers: + (racine / "meta.yml").write_text( + "repo:\n" + " id: " + SENTINELLE + "\n" + " category: domaine\n" + "infra:\n" + " provider: " + provider + "\n" + " network: reseau\n" + " cidr: 10.10.10.0/24\n" + " hosts:\n" + " - name: hote.lab\n" + " distro: alma10\n", + encoding="utf-8", + ) + meta = read_repo_metadata(racine) + rendus.update(str(c) for c in ( + terraform.workdir(meta), + terraform.write_tfvars(meta), + inventory.inventory_path(meta), + inventory.ssh_config_path(meta), + inventory.user_ssh_config_path(meta), + )) + + avant = {p.name for p in racine.iterdir()} + store.record_hint(racine, "lab-sentinelle", 0, 5) + config.set_active_lab(racine, "lab-sentinelle") + noms = {p.name for p in racine.iterdir()} - avant + + rendus.update(str(c) for c in ( + logging_setup.chemin_journal(), + locking.lock_path(racine), + demo.destination(), + update_check.cache_path(), + )) + +rendus.update(str(p) for p in maison.rglob("*")) +print(json.dumps({ + "depot": sorted(noms), + "maison": sorted( + "~/" + str(Path(c).relative_to(maison)) + for c in rendus + if str(c).startswith(str(maison) + os.sep) + ), +})) +""" + +#: `Path.home() / "a" / "b"` dans les sources : les emplacements que le code +#: compose sans passer par une fonction qu'on puisse appeler (le wrapper de +#: `install`, les fichiers d'identifiants des providers). +_CHAINE_MAISON = re.compile(r'Path\.home\(\)((?:\s*/\s*f?"[^"]*")+)') +_MORCEAU = re.compile(r'f?"([^"]*)"') + +#: Racines XDG dont le code ne construit que la valeur par défaut. Les retenir +#: comme références ouvertes rendrait acceptable n'importe quoi sous elles, +#: c'est-à-dire précisément les chemins que ce contrôle existe pour attraper. +_RACINES_XDG = frozenset({"~/.local/state", "~/.local/share", "~/.cache"}) + +_BLOC_CODE = re.compile(r"```.*?```", re.DOTALL) +_CODE_EN_LIGNE = re.compile(r"`([^`\n]+)`") +_SOUS_MAISON = re.compile(r"~/[A-Za-z0-9._/<>{}-]+") +_FICHIER_DEPOT = re.compile(r"[A-Za-z0-9._/<>{}-]*\.dsoxlab[A-Za-z0-9._-]*") + + +def documents_documentation() -> list[Path]: + """Les pages de documentation, celles qui décrivent le produit d'aujourd'hui. + + Le CHANGELOG en est exclu : il raconte le passé, où un chemin retiré depuis + a toute sa place. + """ + pages = sorted(RACINE.glob("*.md")) + sorted((RACINE / "docs").rglob("*.md")) + return [p for p in pages if not p.name.startswith("CHANGELOG")] + + +def chemins_reels() -> tuple[set[str], set[str]]: + """Les emplacements que le code produit vraiment. + + Rend deux ensembles : les noms de fichiers posés **dans le catalogue**, et + les motifs de chemins sous le répertoire personnel. + """ + with tempfile.TemporaryDirectory() as maison: + env = dict( + os.environ, + HOME=maison, + PYTHONPATH=str(RACINE / "src"), + ) + # Un XDG_* hérité de la session déplacerait les chemins hors de ce HOME + # jetable, et la mesure ne porterait plus sur rien. + for variable in ("XDG_STATE_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"): + env.pop(variable, None) + proc = subprocess.run( + [sys.executable, "-c", _CHEMINS_REELS.replace("__SENTINELLE__", SENTINELLE)], + capture_output=True, text=True, env=env, cwd=RACINE, check=True, + ) + data = json.loads(proc.stdout) + return set(data["depot"]), set(data["maison"]) + + +def chemins_du_code() -> set[str]: + """Les chemins que les sources composent depuis `Path.home()`.""" + trouves: set[str] = set() + for source in sorted((RACINE / "src").rglob("*.py")): + texte = source.read_text(encoding="utf-8") + for chaine in _CHAINE_MAISON.findall(texte): + segments = [ + "*" if "{" in morceau else morceau + for morceau in _MORCEAU.findall(chaine) + ] + chemin = "~/" + "/".join(segments) + if chemin not in _RACINES_XDG: + trouves.add(chemin) + return trouves + + +def _nettoyer(brut: str) -> str: + """Retire la ponctuation que la prose colle à la fin d'un chemin.""" + return brut.rstrip("/.,;:)").rstrip("/") + + +def chemins_cites(texte: str) -> tuple[set[str], set[str]]: + """Les chemins cités **dans du code** : blocs clôturés et accents graves. + + La prose emploie les mêmes mots sans les citer, et un contrôle qui crie au + loup sur de la grammaire finit désactivé, ce qui est pire que son absence. + """ + fragments = _BLOC_CODE.findall(texte) + fragments += _CODE_EN_LIGNE.findall(_BLOC_CODE.sub("", texte)) + + maison: set[str] = set() + depot: set[str] = set() + for fragment in fragments: + restant = fragment + for brut in _SOUS_MAISON.findall(fragment): + maison.add(_nettoyer(brut)) + restant = restant.replace(brut, " ") + # Après retrait des chemins sous ~, ce qui reste et porte `.dsoxlab` + # est un fichier posé dans le catalogue. + for brut in _FICHIER_DEPOT.findall(restant): + depot.add(_nettoyer(brut).rsplit("/", 1)[-1]) + return maison, depot + + +def _segments(chemin: str) -> list[str]: + """Un chemin découpé, chaque partie variable devenue un joker.""" + parties = [p for p in chemin.removeprefix("~/").split("/") if p] + return [ + "*" if ("<" in p or "{" in p or SENTINELLE in p) else p + for p in parties + ] + + +def _compatibles(cite: str, reference: str) -> bool: + return fnmatch.fnmatch(cite, reference) or fnmatch.fnmatch(reference, cite) + + +def _correspond(cite: list[str], reference: list[str], *, ouverte: bool) -> bool: + """Le chemin cité désigne-t-il cette référence ? + + Une référence **fermée** vient d'un appel au code : le chemin cité doit en + être un préfixe (citer un répertoire de la liste est légitime, inventer un + fichier dedans ne l'est pas). Une référence **ouverte** vient d'une lecture + des sources, où le code ajoute encore des segments : la comparaison + s'arrête alors au plus court. + """ + if not ouverte and len(cite) > len(reference): + return False + commun = min(len(cite), len(reference)) + if commun == 0: + return False + return all( + _compatibles(c, r) + for c, r in zip(cite[:commun], reference[:commun], strict=True) + ) + + +class References(NamedTuple): + """Ce que le code produit, prêt à être confronté à une page. + + Obtenu une fois (le relevé passe par un sous-processus), puis réutilisé : + les tests s'en servent pour éprouver le contrôle sur des textes fabriqués, + sans repayer la mesure à chaque cas. + """ + + noms_depot: set[str] + fermees: list[list[str]] + ouvertes: list[list[str]] + racines: set[str] + + +def references() -> References: + """Relève les emplacements réels, par appel du code puis lecture des sources.""" + noms_depot, fermees = chemins_reels() + fermees_seg = [_segments(c) for c in fermees] + ouvertes_seg = [_segments(c) for c in chemins_du_code()] + return References( + noms_depot=noms_depot | {Path(c).name for c in fermees}, + fermees=fermees_seg, + ouvertes=ouvertes_seg, + # Un chemin sous ~ n'est contrôlé que si sa première partie est une de + # celles que dsoxlab occupe : `~/Projets/mon-catalogue` est un exemple + # de répertoire de travail, pas une affirmation sur l'outil. + racines={seg[0] for seg in fermees_seg + ouvertes_seg if seg}, + ) + + +def chemins_inconnus( + texte: str, refs: References, *, dispenses: frozenset[str] = frozenset() +) -> list[str]: + """Les chemins que ce texte cite et que le code ne produit nulle part.""" + maison, depot = chemins_cites(texte) + inconnus: list[str] = [] + + for chemin in sorted(maison): + if chemin in dispenses: + continue + segments = _segments(chemin) + if not segments or segments[0] not in refs.racines: + continue + connu = any( + _correspond(segments, ref, ouverte=False) for ref in refs.fermees + ) or any( + _correspond(segments, ref, ouverte=True) for ref in refs.ouvertes + ) + if not connu: + inconnus.append(chemin) + + inconnus += [nom for nom in sorted(depot) if nom not in refs.noms_depot] + return inconnus + + +def dispenses_de(document: str) -> frozenset[str]: + """Les chemins que cette page a le droit de citer comme inexistants.""" + return frozenset( + chemin for chemin, pages in CHEMINS_ABSENTS.items() if document in pages + ) + + +def verifier_chemins(refs: References | None = None) -> list[str]: + """Rend un message par chemin cité qui ne correspond à rien dans le code.""" + refs = refs or references() + problemes: list[str] = [] + for document in documents_documentation(): + relatif = str(document.relative_to(RACINE)) + inconnus = chemins_inconnus( + document.read_text(encoding="utf-8"), + refs, + dispenses=dispenses_de(relatif), + ) + problemes += [ + f" ✘ {relatif} cite « {chemin} », que le code ne produit nulle part" + for chemin in inconnus + ] + return problemes + + +def absents_devenus_reels(refs: References | None = None) -> list[str]: + """Les chemins déclarés absents que le code produit désormais.""" + refs = refs or references() + return sorted( + chemin + for chemin in CHEMINS_ABSENTS + if any( + _correspond(_segments(chemin), ref, ouverte=False) for ref in refs.fermees + ) + ) + + def main() -> int: verifier = "--verifier" in sys.argv perimes: list[str] = [] @@ -130,8 +470,34 @@ def main() -> int: "\nLa documentation ne décrit plus la CLI. Régénère-la :\n" " python3 scripts/generer-doc.py\n" ) - return 1 - return 1 if perimes else 0 + + # Les chemins ne se régénèrent pas : seul l'auteur sait ce qu'il voulait + # écrire. Ils sont donc signalés dans les deux modes. + refs = references() + problemes = verifier_chemins(refs) + for message in problemes: + print(message) + if problemes: + print( + "\nUn chemin cité par la documentation ne correspond à aucun\n" + "emplacement que le code produit. Corrige la page, ou la liste\n" + "CHEMINS_ABSENTS de ce script si le chemin est cité pour dire\n" + "qu'il n'existe pas.\n" + ) + + reels = absents_devenus_reels(refs) + for chemin in reels: + print(f" ✘ « {chemin} » est déclaré absent, mais le code le produit désormais") + if reels: + print( + "\nLa documentation qui annonce ces chemins comme inexistants est\n" + "devenue fausse. Mets-la à jour, puis retire-les de CHEMINS_ABSENTS.\n" + ) + + if not problemes and not reels: + print(" ✔ les chemins cités existent tous dans le code") + + return 1 if (perimes or problemes or reels) else 0 if __name__ == "__main__": diff --git a/src/dsoxlab/i18n/strings/en.py b/src/dsoxlab/i18n/strings/en.py index 4dfee81..b449efa 100644 --- a/src/dsoxlab/i18n/strings/en.py +++ b/src/dsoxlab/i18n/strings/en.py @@ -6,7 +6,7 @@ # ── Global options ──────────────────────────────────────────────────────── "opt_help": "Show this message and exit.", - "opt_lab_home": "Root of the linux-training repo (default: auto-detected).", + "opt_lab_home": "Root of the lab catalog (default: auto-detected).", "opt_json": "JSON output, meant for programs (editor extension, dashboard). Nothing else is printed.", "opt_level": "Filter by level (l1, l2, lfcs, rhcsa)", "opt_section": "Filter by section (linux, ansible, terraform, docker…)", @@ -321,7 +321,7 @@ [cyan]show [/cyan] Full details of a lab (skills, runtime, links …). - [cyan]run [/cyan] Start the lab environment (shell, incus or KVM). + [cyan]run [/cyan] Start the lab environment (a shell, or a provisioned vm). [cyan]course[/cyan] [dim][][/dim] Display the course: one section at a time when the lab declares them (course.yaml), otherwise scenario + README. @@ -435,10 +435,11 @@ [bold]Runtimes[/bold] [bold]shell[/bold] Simple exercises in the current shell — no VM required. - [bold]incus[/bold] Container-based labs — lightweight, fast to start. - [bold]kvm[/bold] Full virtual machine — required for persistence, services, storage. + [bold]vm[/bold] Full machine — required for persistence, services, storage. + Which backend serves it (KVM/libvirt, Incus, Outscale) is declared by + the catalog in [bold]meta.yml: infra.provider[/bold], not by the lab. -Use [bold]dsoxlab doctor[/bold] to check which runtimes are available on your machine.""", +Use [bold]dsoxlab doctor[/bold] to check what is available on your machine.""", "fullhelp_language": """\ [bold]Language[/bold] diff --git a/src/dsoxlab/i18n/strings/fr.py b/src/dsoxlab/i18n/strings/fr.py index fc7acee..7caad42 100644 --- a/src/dsoxlab/i18n/strings/fr.py +++ b/src/dsoxlab/i18n/strings/fr.py @@ -6,7 +6,7 @@ # ── Options globales ────────────────────────────────────────────────────── "opt_help": "Affiche ce message et quitte.", - "opt_lab_home": "Racine du dépôt linux-training (défaut : auto-détecté).", + "opt_lab_home": "Racine du catalogue de labs (défaut : détecté automatiquement).", "opt_json": "Sortie JSON, destinée aux programmes (extension d'éditeur, tableau de bord). Aucun autre affichage.", "opt_level": "Filtre par niveau (l1, l2, lfcs, rhcsa)", "opt_section": "Filtre par section (linux, ansible, terraform, docker…)", @@ -326,7 +326,7 @@ [cyan]show [/cyan] Détail complet d'un lab (compétences, runtime, liens…). - [cyan]run [/cyan] Démarre l'environnement du lab (shell, incus ou KVM). + [cyan]run [/cyan] Démarre l'environnement du lab (un shell, ou une vm provisionnée). [cyan]course[/cyan] [dim][][/dim] Affiche le cours : une section à la fois si le lab en déclare (course.yaml), sinon le scenario et le README. @@ -437,11 +437,12 @@ "fullhelp_runtimes": """\ [bold]Runtimes[/bold] - [bold]shell[/bold] Exercices simples dans le shell courant — aucune VM nécessaire. - [bold]incus[/bold] Labs en conteneurs — léger, démarrage rapide. - [bold]kvm[/bold] Machine virtuelle complète — requis pour la persistance, les services, le stockage. + [bold]shell[/bold] Exercices simples dans le shell courant, aucune VM nécessaire. + [bold]vm[/bold] Machine complète, requise pour la persistance, les services, le stockage. + Quel backend la sert (KVM/libvirt, Incus, Outscale) est déclaré par le + catalogue dans [bold]meta.yml: infra.provider[/bold], pas par le lab. -Utilisez [bold]dsoxlab doctor[/bold] pour vérifier quels runtimes sont disponibles sur votre machine.""", +Utilisez [bold]dsoxlab doctor[/bold] pour vérifier ce qui est disponible sur votre machine.""", "fullhelp_language": """\ [bold]Langue[/bold] diff --git a/tests/test_documentation_synchrone.py b/tests/test_documentation_synchrone.py index e6419a8..6507d29 100644 --- a/tests/test_documentation_synchrone.py +++ b/tests/test_documentation_synchrone.py @@ -5,21 +5,30 @@ documentation qui décrit un outil qui n'existe plus. Personne ne s'en aperçoit, parce que rien ne lit la documentation en même temps que le code. -Ce module ferme les deux sens : +Ce module ferme trois sens : 1. **Toute commande citée dans la documentation existe** dans la CLI. 2. **Toute commande de la CLI est décrite** dans `fullhelp`, en anglais comme en français. C'est la règle que le projet s'était donnée sans pouvoir la tenir : « ne jamais laisser le fullhelp décrire une commande qui n'existe plus ». - -Il attrape donc aussi bien la commande oubliée que la commande fantôme. +3. **Tout emplacement de fichier cité par la documentation existe** dans le + code. La section « Persistence » des deux README a annoncé des mois durant + une base `~/.local/share/dsoxlab/progress.db` et un + `~/.config/dsoxlab/config.yaml` que rien ne lit (issue #86) : les + emplacements de référence sont donc relevés **en appelant le code**, jamais + recopiés ici. + +Il attrape donc aussi bien la commande oubliée que la commande fantôme, et le +chemin inventé. """ from __future__ import annotations +import importlib.util import json import re from pathlib import Path +from typing import Any import pytest @@ -38,6 +47,18 @@ "CONTRIBUTING.md", "CONTRIBUTING.fr.md", "RELEASING.md", + "docs/README.md", + "docs/README.fr.md", + "docs/learner.md", + "docs/learner.fr.md", + "docs/catalog-author.md", + "docs/catalog-author.fr.md", + "docs/trainer.md", + "docs/trainer.fr.md", + "docs/files.md", + "docs/files.fr.md", + "docs/commands.md", + "docs/commands.fr.md", "docs/contract-v1.md", "docs/contract-v1.fr.md", ] @@ -149,8 +170,8 @@ def test_le_catalogue_de_demonstration_ne_cite_que_des_commandes_reelles() -> No assert not inconnues, f"commandes inexistantes citées : {inconnues}" -def test_la_table_des_commandes_du_readme_est_a_jour() -> None: - """La table du README est produite par la CLI, et doit le rester. +def test_la_table_des_commandes_est_a_jour() -> None: + """La table de `docs/commands.md` est produite par la CLI, et doit le rester. Écrite à la main, elle dérivait sans bruit : elle annonçait encore `dsoxlab clean` exécutant un `cleanup.sh`, alors que le zéro-bash est un @@ -234,3 +255,209 @@ def test_les_reglages_kvm_du_contrat_sont_documentes() -> None: for document in ("docs/contract-v1.md", "docs/contract-v1.fr.md"): texte = (RACINE / document).read_text(encoding="utf-8") assert f"`{cle}`" in texte, f"« {cle} » n'est décrit nulle part dans {document}" + + +# ── les emplacements de fichiers cités existent-ils ? ───────────────────────── +# +# Le mécanisme vit dans `scripts/generer-doc.py`, avec la table des commandes : +# c'est le même principe (comparer la documentation à ce que le code FAIT) et +# le même point d'entrée, celui que joue aussi le hook pre-commit. Ces tests +# l'appellent directement, pour dire *quoi* est faux plutôt que *qu'un* truc +# l'est, et pour éprouver le contrôle sur des textes fabriqués. + + +def _generateur() -> Any: + """Charge `scripts/generer-doc.py` comme un module (son nom porte un tiret).""" + chemin = RACINE / "scripts" / "generer-doc.py" + spec = importlib.util.spec_from_file_location("generer_doc", chemin) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def generateur() -> Any: + generateur_py = RACINE / "scripts" / "generer-doc.py" + if not generateur_py.is_file(): + pytest.skip("générateur absent de ce dépôt") + return _generateur() + + +@pytest.fixture(scope="module") +def refs(generateur: Any) -> Any: + """Les emplacements réels, relevés une seule fois (ils coûtent un processus).""" + return generateur.references() + + +def test_les_references_sont_relevees_sur_le_code(generateur: Any, refs: Any) -> None: + """Garde-fou : un relevé cassé rendrait tous les contrôles suivants verts à vide. + + Il vaut aussi documentation exécutable des quatre emplacements que le code + produit et que la documentation décrit. + """ + assert ".dsoxlab.db" in refs.noms_depot + assert ".dsoxlab-context.json" in refs.noms_depot + + fermees = {"/".join(seg) for seg in refs.fermees} + for attendu in ( + "dsoxlab/dsoxlab.log", + "dsoxlab/*/dsoxlab.lock", + "dsoxlab/*/ssh_config", + ): + assert any(chemin.endswith(attendu) for chemin in fermees), ( + f"aucun emplacement réel ne finit par « {attendu} » : {sorted(fermees)}" + ) + + +def test_aucune_page_ne_cite_un_chemin_inexistant(generateur: Any, refs: Any) -> None: + """Le contrôle sur le dépôt réel, avec le nom de la page et du chemin fautif.""" + problemes = generateur.verifier_chemins(refs) + assert not problemes, "\n" + "\n".join(problemes) + + +def test_le_controle_refuse_les_chemins_de_l_issue_86( + generateur: Any, refs: Any +) -> None: + """La preuve par mutation : les trois affirmations fausses sont rejetées. + + Elles sont réintroduites ici telles qu'elles étaient écrites dans les deux + README. Un contrôle qui n'a jamais rien refusé ne prouve rien. + """ + texte = """ + - **Scores and hints:** `~/.local/share/dsoxlab/progress.db` (XDG). + - **User config:** `~/.config/dsoxlab/config.yaml` (optional). + - Session: `/.dsoxlab-session.json` + """ + inconnus = generateur.chemins_inconnus(texte, refs) + assert inconnus == [ + "~/.config/dsoxlab/config.yaml", + "~/.local/share/dsoxlab/progress.db", + ".dsoxlab-session.json", + ], inconnus + + +def test_la_dispense_ne_vaut_que_pour_la_page_qui_la_porte( + generateur: Any, refs: Any +) -> None: + """Une page peut citer un chemin POUR DIRE qu'il n'existe pas. Une seule. + + Sans cette restriction, la dispense rouvrirait la porte qu'elle ferme : + n'importe quelle page pourrait réannoncer `progress.db` comme un + emplacement réel, et le contrôle se tairait. + """ + texte = "`~/.local/share/dsoxlab/progress.db`" + + dispensee = generateur.dispenses_de("docs/files.md") + assert generateur.chemins_inconnus(texte, refs, dispenses=dispensee) == [] + assert generateur.chemins_inconnus( + texte, refs, dispenses=generateur.dispenses_de("README.md") + ) == ["~/.local/share/dsoxlab/progress.db"] + + +def test_le_controle_accepte_les_emplacements_reels(generateur: Any, refs: Any) -> None: + """L'autre sens : ce que le code produit vraiment doit passer. + + Y compris écrit avec les paramètres que la documentation emploie + (``, ``), qui n'existent dans aucun chemin réel. + """ + texte = """ + `/.dsoxlab.db` `/.dsoxlab-context.json` + `~/.local/state/dsoxlab/dsoxlab.log` + `~/.local/state/dsoxlab//terraform//` + `~/.local/state/dsoxlab//dsoxlab.lock` + `~/.cache/dsoxlab//inventory.json` + `~/.cache/dsoxlab//ssh_config` + `~/.cache/dsoxlab/version-check.json` + `~/.local/share/dsoxlab/demo/` + `~/.ssh/config.d/.conf` + """ + # `~/.local/bin/dsoxlab` a quitté cette liste en 0.1.62 : `install` ne + # l'écrit plus. Il n'est donc plus un emplacement réel, mais un chemin que + # les pages citent pour dire qu'il n'existe pas, et c'est la dispense + # nominative qui l'autorise. Le contrôle a d'ailleurs signalé la dérive + # tout seul, le lendemain de sa pose. + assert generateur.chemins_inconnus(texte, refs) == [] + + +def test_un_repertoire_de_travail_reste_hors_perimetre( + generateur: Any, refs: Any +) -> None: + """Un exemple de chemin utilisateur n'affirme rien sur l'outil. + + `~/Projets/mon-catalogue` est un endroit où l'on a cloné un catalogue, pas + un emplacement que dsoxlab produit. Un contrôle qui crie au loup dessus + finirait désactivé. + """ + assert generateur.chemins_inconnus("`cd ~/Projets/mon-catalogue`", refs) == [] + + +def test_les_chemins_declares_absents_le_sont_toujours( + generateur: Any, refs: Any +) -> None: + """L'autre bout de la dispense, et il compte autant. + + Le jour où `~/.config/dsoxlab/config.yaml` deviendra réel (issue #78), la + page qui l'annonce inexistant deviendra fausse à son tour. Ce test le dit + ce jour-là, au lieu de laisser la dispense couvrir un nouveau mensonge. + """ + devenus_reels = generateur.absents_devenus_reels(refs) + assert not devenus_reels, ( + f"le code produit désormais {devenus_reels} : mets à jour la page qui " + "les annonce absents, puis retire-les de CHEMINS_ABSENTS" + ) + + +def test_toute_page_de_documentation_est_controlee(generateur: Any) -> None: + """Une page ajoutée sans être contrôlée est une page qui pourra mentir.""" + pages = {str(p.relative_to(RACINE)) for p in generateur.documents_documentation()} + assert "docs/files.md" in pages and "docs/files.fr.md" in pages + assert not any(p.startswith("CHANGELOG") for p in pages), ( + "le CHANGELOG raconte le passé : un chemin retiré depuis y a sa place" + ) + + +# ── une page publiée s'adresse à quelqu'un, dans les deux langues ───────────── + + +def _pages_docs() -> list[Path]: + return sorted(p for p in (RACINE / "docs").glob("*.md") if not p.name.endswith(".fr.md")) + + +def test_chaque_page_existe_dans_les_deux_langues() -> None: + """La parité EN/FR est une promesse du projet, pas une intention. + + Une page traduite d'un seul côté se dégrade en silence : le lecteur français + tombe sur l'anglais sans savoir si c'est un oubli ou un choix. + """ + manquantes = [ + page.name + for page in _pages_docs() + if not page.with_suffix("").with_suffix(".fr.md").is_file() + and not (page.parent / f"{page.stem}.fr.md").is_file() + ] + assert not manquantes, f"pages sans version française : {manquantes}" + + +@pytest.mark.parametrize("langue", ["", ".fr"]) +def test_chaque_page_nomme_son_public(langue: str) -> None: + """« Chaque page nomme son public en tête » (issue #86). + + Une documentation qui répond à l'apprenant, à l'auteur et au formateur dans + le même paragraphe ne répond à aucun des trois. Le contrôle ne juge pas la + prose : il exige la ligne qui déclare le destinataire, dans les vingt + premières lignes. + """ + marqueurs = ("**Audience:**", "**Public :**") + sans_public = [] + for page in _pages_docs(): + chemin = page if langue == "" else page.parent / f"{page.stem}.fr.md" + if not chemin.is_file(): + continue + entete = "\n".join(chemin.read_text(encoding="utf-8").splitlines()[:20]) + if not any(marqueur in entete for marqueur in marqueurs): + sans_public.append(chemin.name) + assert not sans_public, ( + f"pages qui ne nomment pas leur public : {sans_public}\n" + "Ajoute une ligne « **Audience:** … » (ou « **Public :** … ») en tête." + ) diff --git a/uv.lock b/uv.lock index 2bdaacd..702bbfb 100644 --- a/uv.lock +++ b/uv.lock @@ -313,7 +313,7 @@ wheels = [ [[package]] name = "dsoxlab" -version = "0.1.62" +version = "0.1.63" source = { editable = "." } dependencies = [ { name = "ansible-core", version = "2.19.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },