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'" },