Un cours dsoxlab est un dépôt git avec un meta.yml à la racine et un
lab.yaml par lab sous labs/ : rien d'autre ne le lie au moteur, aucune
dépendance à installer, aucun plugin à écrire. Cette leçon suit la création
d'un catalogue de bout en bout, du squelette posé par dsoxlab new jusqu'aux
tests qui prouvent la réussite, en passant par la mission et son
barème, les indices et leur coût, et le validateur qui refuse un lab avant
qu'un apprenant ne le subisse. Elle décrit le contrat de la version 0.2.5
et s'adresse à qui a déjà joué un lab et veut en écrire.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Partir d'une capacité démontrable, jamais d'un chapitre à couvrir.
- Poser un catalogue et un lab conformes avec
dsoxlab new. - Déclarer le contrat dans
meta.ymletlab.yaml, et savoir ce que chacun impose. - Rédiger le cours, le scénario, la mission notée et les indices payants.
- Prouver la réussite par des tests pytest sur l'état du système.
- Valider avec
validate-structure, dans l'éditeur et en CI.
Partir d'une capacité, jamais d'un guide
Section intitulée « Partir d'une capacité, jamais d'un guide »Un lab existe pour faire démontrer une capacité, c'est-à-dire un résultat
observable sur un système : créer un utilisateur avec accès SSH par clé
uniquement, étendre un volume logique et prouver que le montage survit au
redémarrage, corriger un refus SELinux sans désactiver SELinux. Un lab par
chapitre serait absurde, personne ne monte une machine virtuelle pour
apprendre cut, et si la capacité ne se formule pas avec un verbe et un
résultat vérifiable, le lab n'est pas prêt à être écrit.
Le runtime n'est pas un choix de confort : il est imposé par le sujet. Ce
qui touche sshd, systemd, un pare-feu, SELinux, le démarrage ou le stockage
persistant se prouve dans une machine virtuelle, parce qu'un conteneur n'a
ni vrai cycle de démarrage ni politique SELinux propre. Ce qui touche des
fichiers, du texte, des permissions ou un outil qui tourne sur le poste, comme
Terraform, se prouve en shell, et ne coûte presque rien.
Enfin, la validation prouve, elle ne fait pas confiance. Vérifier qu'une
commande a été tapée est interdit : l'apprenant peut taper la bonne commande
et rater l'objectif. On vérifie l'état du système, et dès que le sujet le
justifie, la persistance après redémarrage : une règle de pare-feu sans
--permanent, un montage absent de /etc/fstab, un service sans enable.
C'est le piège qui fait échouer les candidats RHCSA, et le test doit le
prendre en défaut.
La structure d'un catalogue
Section intitulée « La structure d'un catalogue »Un catalogue est un dépôt ordinaire, et retirer dsoxlab doit laisser ses
labs jouables à la main, avec 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. Voici ce que porte un lab complet :
ma-formation/├── meta.yml # catalogue : identité, topologie, ordre des sections├── meta.fr.yml # optionnel : titres et descriptions en français├── conftest.py # optionnel, mais indispensable dès qu'un test lit un hôte├── ssh/id_ed25519.pub # produite par instructor bootstrap, jamais commitée└── labs/ └── mon-domaine/l1/premier-lab/ ├── lab.yaml # obligatoire ├── README.md # obligatoire : le cours ├── scenario.md # obligatoire : la situation ├── 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 exactchallenge/tests/test_functional.py est le seul nom de fichier de test que le
validateur exige ; rien n'interdit d'en poser d'autres à côté, pytest collecte
le répertoire. Le zéro-bash est imposé : cleanup.sh, runtime/kvm.sh,
runtime/shell.sh et Makefile sont refusés dans un répertoire de lab.
La préparation est déclarative, dans lab.yaml, ou Ansible, dans
setup.yaml.
Poser le squelette avec dsoxlab new
Section intitulée « Poser le squelette avec dsoxlab new »Deux commandes créent des fichiers conformes sans retouche, et c'est le bon
point de départ, parce qu'un lab.yaml écrit de mémoire oublie toujours un
champ. new catalog pose meta.yml, labs/, un .gitignore et ssh/ ;
new lab pose un lab découvert dès le prochain list-labs.
dsoxlab new catalog ma-formation --in ~/Projets # le cataloguecd ~/Projets/ma-formationdsoxlab new lab premier-lab --runtime shell # ou --runtime vmdsoxlab list-labs # le lab est déjà découvertnew lab écrit lab.yaml, README.md, scenario.md et
challenge/tests/test_functional.py, plus setup.yaml et cleanup.yaml pour
un runtime vm. Il n'écrit pas le fichier challenge/README.md, la mission, ni
conftest.py : ces deux fichiers sont à vous, et la suite dit ce qu'on y met.
Le meta.yml : identité, topologie, ordre
Section intitulée « Le meta.yml : identité, topologie, ordre »Seul repo.id est obligatoire. repo.category est exigé par le validateur
dès qu'un lab.yaml existe, parce qu'il devient la section par défaut de
chaque lab, et qu'un lab sans section resterait introuvable par
list-labs --section. Le bloc infra: n'est requis que par les labs vm, et
la leçon suivante le détaille ; un catalogue sans lui est conforme, pas
incomplet.
# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/meta.schema.jsonschema_version: 1repo: id: ma-formation category: mon-domaine title: "Ma formation" blog_url: "https://blog.stephane-robert.info/docs/" issues_url: "https://github.com/mon-compte/ma-formation/issues"
sections: - id: getting-started title: "Discover the tool" labs: - mon-domaine/l1/premier-labDeux règles du contrat surprennent tout auteur la première fois. La
découverte se fait par chemin, jamais par id : un lab existe si et
seulement si labs/**/lab.yaml existe, et sections[].labs[] ne fait
qu'ordonner les labs et nommer les blocs, en comparant le chemin relatif
depuis labs/. Et issues_url mérite d'être déclaré : c'est là que
dsoxlab support --issue dépose le rapport d'un apprenant, au lieu de deviner
le remote origin.
Les fichiers de base portent l'anglais, langue par défaut de l'outil. Un
meta.fr.yml posé à côté surcharge repo.title, repo.description et les
titres et descriptions de sections, appariés par id et jamais par
position, et rien d'autre : l'ordre pédagogique ne se traduit pas.
Le lab.yaml : ce que le parseur exige, ce que le validateur exige
Section intitulée « Le lab.yaml : ce que le parseur exige, ce que le validateur exige »Trois champs sont exigés par le parseur, id, title et level : sans
eux, le fichier n'est pas un lab, et il disparaît du catalogue. Trois autres
sont exigés par validate-structure, skills, distros et doc_url : le
fichier se lit sans eux, mais le lab n'est pas publiable. doc_url est le
seul lien entre le lab et le guide en ligne, celui que dsoxlab guide ouvre :
il doit être en http(s) et pointer une page réelle.
# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.jsonid: premier-labtitle: "Create a user with key-only SSH access"level: l2skills: [users, ssh]distros: [alma10]doc_url: https://blog.stephane-robert.info/docs/admin-serveurs/linux/description: "Create the account, deploy the key, forbid password login."lab_type: lab # lab, challenge ou capstoneestimated_time: "20m"runtime: type: vm # shell ou vm ; jamais kvm ni incus dans un lab neuf targets: - name: rhel host: alma-rhcsa-1.lab # doit figurer dans infra.hosts[].name du meta.yml label_fr: "AlmaLinux 10" default: rhel snapshot_required: falsevalidation: functional: true persistence_after_reboot: trueUn lab shell remplace le bloc targets par un workdir, challenge/work
par défaut, et peut déclarer des fixtures, copiées dans ce répertoire au
run, et des services, des conteneurs dont le lab a besoin le temps de
l'exercice. Le bloc validation est purement déclaratif : dsoxlab ne le
lit jamais pour décider, ce sont les tests qui prouvent. Un lab_type: capstone avec un exam_passing_score entre 1 et 100 devient un examen
blanc, et submit rend un verdict en pourcentage du barème.
Le cours, le scénario, la mission et son barème
Section intitulée « Le cours, le scénario, la mission et son barème »Trois fichiers Markdown, et le moteur lit chacun à un moment différent.
README.md est le cours : ce que le lab enseigne, et ce qu'il suppose
connu ; dsoxlab course l'affiche d'un bloc avec scenario.md, la
situation, c'est-à-dire où se trouve l'apprenant et ce qui ne va pas, sans les
gestes qui le corrigent. challenge/README.md est la mission, affichée par
dsoxlab challenge, et par run avant l'ouverture de la session SSH d'un lab
vm, l'hôte n'ayant pas dsoxlab.
La mission porte le barème, et le validateur le tient en accord avec les
tests. dsoxlab note par test : cinq fonctions test_ dans
test_functional.py, c'est vingt points chacune, les indices déduits ensuite.
Un titre ### portant (N pts) ou (N points) est une tâche notée, une
ligne annonçant N tâches et M points est l'annonce, et les titres ## ne
sont pas comptés, ce qui permet à un examen blanc de grouper ses tâches sans
doubler son total.
5 tâches, 100 points, 20 minutes
### Tâche 1 : créer le groupe (20 pts)
### Tâche 2 : créer l'utilisateur dans ce groupe (20 pts)validate-structure échoue quand les tâches ne totalisent pas le barème
annoncé, quand leur nombre diffère de celui annoncé, ou du nombre de tests.
Ajouter un test à un lab décale silencieusement tout son barème, et ce
contrôle est la seule chose qui s'en aperçoit. Une mission qui n'annonce aucun
point par tâche n'est pas contrôlée du tout : un examen blanc qui vérifie
plusieurs choses par tâche a fait un autre choix, tout aussi valable.
challenge/README.fr.md est lu en premier lorsqu'il existe.
Les indices, et leur coût
Section intitulée « Les indices, et leur coût »challenge/hints.yaml porte le barème du lab et les indices, dans l'ordre où
ils seront donnés. Un bon indice oriente le regard, « que dit
journalctl -u <service> ? », et ne donne jamais la commande finale. Le coût
de chacun est déduit de la note, et il vaut 10 points quand il n'est pas
écrit.
points: 100 # le barème du lab ; 100 quand le fichier est absenthints: - text_en: "The group must exist before the user does." text_fr: "Le groupe doit exister avant l'utilisateur." cost: 10 # points retirés quand l'apprenant le prend (défaut 10)Une clé unique text, héritée, est encore lue, et une valeur en base64
est décodée pour qu'un indice ne ressorte pas dans un git grep ; ni l'une
ni l'autre n'est nécessaire dans un lab neuf. Les catalogues du site encodent
leurs indices, et ce n'est pas du chiffrement : seulement de quoi éviter
qu'un indice se lise par accident en ouvrant le fichier.
Des tests qui prouvent
Section intitulée « Des tests qui prouvent »Un lab est noté par pytest avec pytest-testinfra, tous deux embarqués
dans dsoxlab : un catalogue n'installe aucun outillage de test. pytest
s'exécute depuis la racine du catalogue, donc un conftest.py posé là est
collecté pour tous les labs. C'est là que va l'import qui construit les hôtes
testinfra depuis l'inventaire que dsoxlab génère, et là qu'un catalogue de
labs shell résout <lab>/challenge/work depuis l'emplacement du fichier de
test.
# conftest.py, à la racine du catalogue : le seul import qu'un catalogue fait de dsoxlabfrom dsoxlab.infra.inventory import build_inventory, read_terraform_outputs, write_ssh_configCet import 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, lab_target, la cible résolue que les playbooks
d'un lab doivent viser, et un lab_<role> par entrée de roles.
Les assertions portent sur l'état : le service tourne, le port écoute, le
montage est présent, le fichier a les bonnes permissions. Avec testinfra,
une telle assertion tient en une ligne, et le corrigé n'y apparaît jamais.
dsoxlab new catalog n'écrit aucun conftest.py ; les catalogues Linux et
Terraform du site en portent chacun un à leur racine, et l'un ou l'autre est
un bon point de départ.
# challenge/tests/test_functional.py : l'état du système, jamais l'historiquedef test_service_actif_et_persistant(host): service = host.service("chronyd") assert service.is_running assert service.is_enabledValider avec validate-structure
Section intitulée « Valider avec validate-structure »dsoxlab validate-structure vérifie tous les labs du catalogue, hors
ligne par défaut, et sort en 1 dès qu'un lab échoue. Il joue trois familles
de contrôles, et nomme chaque anomalie par une clé stable : c'est
là-dessus qu'un script filtre, compte et compare. Il rapporte aussi les labs
que le moteur ne voit pas, ce qui est le cas le plus déroutant : un lab.yaml présent
sur le disque mais illisible, YAML cassé ou champ requis manquant, avec la
ligne où regarder, et un lab déclaré dans sections[].labs[] sans lab.yaml
à cet emplacement.
| Famille | Ce qui est vérifié |
|---|---|
| Structure | lab.yaml, README.md, scenario.md, challenge/tests/test_functional.py ; pour vm, setup.yaml, cleanup.yaml, des targets non vides et un default cohérent ; pour shell, un workdir non vide ; aucun fichier interdit |
| Métadonnées | id, title, level et doc_url non vides, skills et distros non vides, doc_url en http(s), lab_type dans l'énuméré, exam_passing_score entre 1 et 100 |
| Contenu | Tout lien relatif d'un Markdown pointe sur un fichier existant ; le barème annoncé correspond aux tests ; un document traduit d'un seul côté est signalé ; les hôtes des targets et des roles existent dans infra.hosts ; aucun fichier de solution/ n'est lisible en clair ; fixtures/ et runtime.fixtures disent la même chose |
--check-urls ajoute le seul contrôle réseau, chaque doc_url doit
répondre, et --json rend chaque anomalie avec sa clé, son lab et son chemin.
Le contrôle des fixtures vaut d'être compris : le runtime shell itère sur
runtime.fixtures, pas sur le répertoire fixtures/. Une fixture déclarée
mais absente fait échouer run en code 2 en nommant toutes les fautives d'un
coup ; une fixture présente mais non déclarée est signalée ici. Ce défaut
avait rendu sept labs injouables le 2026-07-28, tous marqués faits, parce
que les outils de vérification des corrigés copiaient, eux, le répertoire
entier.
Découper le cours et traduire
Section intitulée « Découper le cours et traduire »Sans course.yaml, dsoxlab course affiche scenario.md et README.md
d'un bloc, ce qui explique la longueur des cours longs. Un course.yaml à
côté du lab.yaml permet d'afficher une section à la fois, avec --next,
--prev et --section, et de retenir où l'apprenant s'est arrêté :
sections: - id: navigation title: Se déplacer dans l'arborescence file: course/01-navigation.mdLes traductions suivent une seule convention, par fichier : lab.fr.yaml
surcharge title et description du lab, meta.fr.yml les titres du
catalogue et de ses sections, course.fr.yaml les titres de sections du
cours. Un suffixe de langue par champ, comme title_en:, ne fait pas partie
du contrat, n'est lu par personne, et validate-structure le signale.
Les schémas, dans l'éditeur et en CI
Section intitulée « Les schémas, dans l'éditeur et en CI »Deux schémas JSON décrivent le même contrat, schemas/meta.schema.json et
schemas/lab.schema.json, et un test du projet les confronte au parseur dans
les deux sens pour qu'ils ne dérivent pas. La ligne de commentaire
yaml-language-server posée en tête des exemples ci-dessus suffit à tout
éditeur qui fait tourner ce serveur pour compléter les champs et
souligner skils: pendant qu'on l'écrit.
En CI, la validation se fait sans installer dsoxlab, parce que les URL des schémas sont de simples fichiers que n'importe quel validateur JSON Schema sait récupérer :
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 :
main suit le contrat au fil de ses évolutions, un tag ne bouge jamais sous
vos pieds. Les clés inconnues sont ignorées par le parseur, refusées par les
schémas et signalées par validate-structure, et cette combinaison est
voulue : le moteur reste tolérant pour qu'un outil ancien survive à un
catalogue plus récent, l'éditeur et le validateur, eux, ne laissent rien
passer.
Jouer son lab avant de le publier
Section intitulée « Jouer son lab avant de le publier »Un lab jamais joué n'est pas un lab. Avant toute publication, la séquence est
la même que celle de l'apprenant, plus un retour à zéro pour prouver la
reproductibilité : validate-structure, puis run, résoudre, check,
puis reset et rejouer. Le corrigé, s'il en existe un dans solution/, doit
rester illisible en clair, et le validateur le vérifie.
dsoxlab validate-structuredsoxlab run premier-lab # résoudre la mission dans la sessiondsoxlab check premier-lab # le barème annoncé doit être atteintdsoxlab reset premier-lab # puis rejouer depuis zéroReste à brancher le lab à son guide, dans les deux sens : le doc_url du
lab pointe la page qui enseigne la capacité, et cette page annonce le lab.
Sur ce site, chaque guide jumelé porte un encadré « Lab pratique » avec
l'identifiant exact du lab.yaml, et la leçon passe en type lab dans le
parcours de sa formation. Un lab que personne ne voit n'est joué par
personne.
Dépannage
Section intitulée « Dépannage »Ces quatre cas sont ceux que tout auteur rencontre au premier catalogue. Aucun n'est une panne du moteur : chacun vient d'un écart entre ce qu'on croyait déclarer et ce que le contrat lit.
| Symptôme | Cause | Solution |
|---|---|---|
Le lab n'apparaît pas dans list-labs | Son lab.yaml ne se charge pas : YAML cassé ou id, title, level manquant | dsoxlab validate-structure le nomme avec la ligne ; le détail complet est dans ~/.local/state/dsoxlab/dsoxlab.log |
content_scoring_tasks_vs_tests | Le nombre de tâches notées de la mission diffère du nombre de fonctions test_ | Aligner la mission sur les tests, ou l'inverse, puis relancer le validateur |
run sort en 2 et nomme une fixture | Une entrée de runtime.fixtures n'a pas de fichier sous fixtures/ | Poser le fichier, ou retirer la déclaration ; c'est tout ou rien avant la moindre copie |
dsoxlab challenge affiche « Aucun fichier challenge/README.md » | new lab ne crée pas la mission | L'écrire, avec son en-tête de barème et ses titres ### notés |
À retenir
Section intitulée « À retenir »- Un catalogue est un dépôt avec
meta.ymlet unlab.yamlpar lab ; retirer dsoxlab doit laisser les labs jouables à la main. - On part d'une capacité observable, le runtime est imposé par le sujet, et les tests prouvent l'état du système.
- La découverte se fait par chemin,
sections[].labs[]ne fait qu'ordonner ;runtime.typevautvm, etruntime.hostn'existe pas. - La mission annonce un barème par tâche, un point par test, et
validate-structurerefuse tout écart entre les deux. - Un indice coûte 10 points par défaut, et oriente le regard sans donner la commande.
conftest.pyvit à la racine du catalogue, etDSOXLAB_TARGET_HOSTdit aux tests quel hôte inspecter.- Un lab jamais joué n'est pas un lab :
validate-structure,run,check,reset, puis rejouer.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Valider ses compétences Linux : Comment le catalogue Linux, 86 labs, est rattaché aux leçons du site, avec ses drills et ses examens blancs.
- Formation Kubernetes : Un catalogue de 64 labs
vmsur cluster kubeadm, jumelé leçon par leçon aux blueprints CKA, CKAD et CKS. - Formation Terraform : Le catalogue de 88 labs
shell, sans blocinfra:, qui sert de contre-exemple au moteur.