A dsoxlab course is a git repository with a meta.yml at the root and one
lab.yaml per lab under labs/: nothing else ties it to the engine, no
dependency to install, no plugin to write. This lesson follows the creation
of a catalogue from end to end, from the skeleton laid down by dsoxlab new to
the tests that prove success, through the mission and its scale, the
hints and their cost, and the validator that refuses a lab before a learner
suffers it. It describes the contract of version 0.2.5 and is written for
anyone who has already played a lab and wants to write some.
What you will learn
- Start from a demonstrable capability, never from a chapter to cover.
- Lay down a conforming catalogue and lab with
dsoxlab new. - Declare the contract in
meta.ymlandlab.yaml, and know what each one imposes. - Write the course, the scenario, the graded mission and the hints that cost points.
- Prove success through pytest tests on the state of the system.
- Validate with
validate-structure, in the editor and in CI.
Start from a capability, never from a guide
A lab exists to have someone demonstrate a capability, that is, an
observable result on a system: create a user with key-only SSH access, extend a
logical volume and prove the mount survives a reboot, fix an SELinux denial
without disabling SELinux. One lab per chapter would be absurd, nobody brings
up a virtual machine to learn cut, and if the capability cannot be phrased
with a verb and a verifiable result, the lab is not ready to be
written.
The runtime is not a comfort choice: the subject imposes it. Anything that
touches sshd, systemd, a firewall, SELinux, the boot or persistent storage
is proved in a virtual machine, because a container has neither a real boot
cycle nor an SELinux policy of its own. Anything that touches files, text,
permissions or a tool that runs on the workstation, such as Terraform, is
proved in shell, and costs almost nothing.
Finally, validation proves, it does not trust. Checking that a command was
typed is forbidden: the learner can type the right command and miss the goal.
You check the state of the system, and as soon as the subject warrants it,
persistence after a reboot: a firewall rule without --permanent, a mount
absent from /etc/fstab, a service without enable. That is the trap that
fails RHCSA candidates, and the test has to catch it.
The structure of a catalogue
A catalogue is an ordinary repository, and removing dsoxlab must leave its
labs playable by hand, with ansible-playbook setup.yaml then pytest. That
is the non-coupling test, and it is what keeps the engine neutral with
respect to the domain. Here is what a complete lab carries:
my-training/├── meta.yml # catalogue: identity, topology, section order├── meta.fr.yml # optional: French titles and descriptions├── conftest.py # optional, but essential as soon as a test reads a host├── ssh/id_ed25519.pub # generated by instructor bootstrap, never committed└── labs/ └── my-domain/l1/first-lab/ ├── lab.yaml # required ├── README.md # required: the course ├── scenario.md # required: the situation ├── 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 namechallenge/tests/test_functional.py is the only test file name the
validator requires; nothing forbids putting others next to it, pytest collects
the directory. Zero bash is enforced: cleanup.sh, runtime/kvm.sh,
runtime/shell.sh and Makefile are refused in a lab directory.
Preparation is declarative, in lab.yaml, or Ansible, in setup.yaml.
Laying down the skeleton with dsoxlab new
Two commands create files that are conforming without any touch-up, and
that is the right starting point, because a lab.yaml written from memory
always forgets a field. new catalog lays down meta.yml, labs/, a
.gitignore and ssh/; new lab lays down a lab discovered by the next
list-labs.
dsoxlab new catalog my-training --in ~/Projects # the cataloguecd ~/Projects/my-trainingdsoxlab new lab first-lab --runtime shell # or --runtime vmdsoxlab list-labs # the lab is already discoverednew lab writes lab.yaml, README.md, scenario.md and
challenge/tests/test_functional.py, plus setup.yaml and cleanup.yaml for
a vm runtime. It does not write the challenge/README.md file, the
mission, nor conftest.py: those two files are yours, and what follows
says what goes into them.
The meta.yml: identity, topology, order
Only repo.id is required. repo.category is required by the
validator as soon as a lab.yaml exists, because it becomes the default
section of every lab, and a lab without a section would stay unreachable
through list-labs --section. The infra: block is only required by vm
labs, and the next lesson details it; a catalogue without it is conforming,
not incomplete.
# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/meta.schema.jsonschema_version: 1repo: id: my-training category: my-domain title: "My training" blog_url: "https://blog.stephane-robert.info/docs/" issues_url: "https://github.com/my-account/my-training/issues"
sections: - id: getting-started title: "Discover the tool" labs: - my-domain/l1/first-labTwo rules of the contract surprise every author the first time. Discovery
goes by path, never by id: a lab exists if and only if labs/**/lab.yaml
exists, and sections[].labs[] only orders the labs and names the blocks,
by comparing the path relative to labs/. And issues_url deserves to be
declared: that is where dsoxlab support --issue files a learner's report,
instead of guessing the origin remote.
The base files carry English, the tool's default language. A
meta.fr.yml placed next to it overrides repo.title, repo.description
and the titles and descriptions of sections, matched by id and never by
position, and nothing else: the teaching order is not translated.
The lab.yaml: what the parser requires, what the validator requires
Three fields are required by the parser, id, title and level:
without them, the file is not a lab, and it disappears from the catalogue.
Three others are required by validate-structure, skills, distros and
doc_url: the file reads without them, but the lab is not publishable.
doc_url is the only link between the lab and the online guide, the one
dsoxlab guide opens: it must be http(s) and point to a real page.
# yaml-language-server: $schema=https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.jsonid: first-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 or capstoneestimated_time: "20m"runtime: type: vm # shell or vm; never kvm nor incus in a new lab targets: - name: rhel host: alma-rhcsa-1.lab # must appear in infra.hosts[].name of the meta.yml label_fr: "AlmaLinux 10" default: rhel snapshot_required: falsevalidation: functional: true persistence_after_reboot: trueA shell lab replaces the targets block with a workdir, challenge/work
by default, and can declare fixtures, copied into that directory at run,
and services, containers the lab needs for the duration of the exercise.
The validation block is purely declarative: dsoxlab never reads it to
decide, the tests are what proves. A lab_type: capstone with an
exam_passing_score between 1 and 100 becomes a mock exam, and submit
renders a verdict as a percentage of the scale.
The course, the scenario, the mission and its scale
Three Markdown files, and the engine reads each at a different moment.
README.md is the course: what the lab teaches, and what it assumes
known; dsoxlab course prints it in one block with scenario.md, the
situation, that is, where the learner is and what is wrong, without the
gestures that fix it. challenge/README.md is the mission, printed by
dsoxlab challenge, and by run before the SSH session of a vm lab opens,
since the host has no dsoxlab.
The mission carries the scale, and the validator keeps it in step with the
tests. dsoxlab grades per test: five test_ functions in
test_functional.py means twenty points each, hints deducted afterwards. A
### heading carrying (N pts) or (N points) is a graded task, a line
announcing N tasks and M points is the announcement, and ## headings are
not counted, which lets a mock exam group its tasks without doubling its
total.
5 tasks, 100 points, 20 minutes
### Task 1: create the group (20 pts)
### Task 2: create the user in that group (20 pts)validate-structure fails when the tasks do not add up to the announced
scale, when their count differs from the announced one, or from the number of
tests. Adding a test to a lab silently reweights its whole scale, and that
check is the only thing that notices. A mission that announces no points per
task is not checked at all: a mock exam that verifies several things per task
made another choice, just as valid. challenge/README.fr.md is read first
when it exists.
The hints, and their cost
challenge/hints.yaml carries the lab's scale and the hints, in the order
they will be handed out. A good hint directs the eye, "what does
journalctl -u <service> say?", and never gives the final command. The cost of
each one is deducted from the score, and it is worth 10 points when it is
not written.
points: 100 # the lab's scale; 100 when the file is absenthints: - text_en: "The group must exist before the user does." text_fr: "Le groupe doit exister avant l'utilisateur." cost: 10 # points removed when the learner takes it (default 10)A single legacy text key is still read, and a base64 value is decoded so
that a hint does not surface in a git grep; neither is needed in a new
lab. The site's catalogues encode their hints, and that is not encryption:
only enough to avoid a hint being read by accident when opening the file.
Tests that prove
A lab is graded by pytest with pytest-testinfra, both bundled in
dsoxlab: a catalogue installs no test tooling. pytest runs from the
catalogue root, so a conftest.py placed there is collected for every lab.
That is where the import that builds the testinfra hosts from the inventory
dsoxlab generates goes, and where a catalogue of shell labs resolves
<lab>/challenge/work from the location of the test file.
# conftest.py, at the catalogue root: the only import a catalogue makes from dsoxlabfrom dsoxlab.infra.inventory import build_inventory, read_terraform_outputs, write_ssh_configThat import exists so that no lab hardcodes an IP address. The host to
inspect is named by DSOXLAB_TARGET_HOST, which dsoxlab check --target
sets: without reading it, a multi-distribution lab only ever tests its default
host. Three Ansible groups are injected at run time: labenv, every host
of the meta.yml, lab_target, the resolved target a lab's playbooks must
address, and one lab_<role> per entry of roles.
The assertions bear on the state: the service runs, the port listens, the
mount is present, the file has the right permissions. With testinfra, such
an assertion fits in one line, and the answer key never appears in it.
dsoxlab new catalog writes no conftest.py; the site's Linux and
Terraform catalogues each carry one at their root, and either is a good
starting point.
# challenge/tests/test_functional.py: the state of the system, never the historydef test_service_running_and_persistent(host): service = host.service("chronyd") assert service.is_running assert service.is_enabledValidating with validate-structure
dsoxlab validate-structure checks every lab of the catalogue, offline by
default, and exits 1 as soon as one lab fails. It plays three families of
checks, and names every anomaly by a stable key: that is what a script
filters, counts and compares on. It also reports the labs the engine cannot
see, which is the most confusing case: a lab.yaml present on disk but
unreadable, broken YAML or a required field missing, with the line to look at,
and a lab declared in sections[].labs[] with no lab.yaml at that location.
| Family | What is checked |
|---|---|
| Structure | lab.yaml, README.md, scenario.md, challenge/tests/test_functional.py; for vm, setup.yaml, cleanup.yaml, non-empty targets and a consistent default; for shell, a non-empty workdir; no forbidden file |
| Metadata | id, title, level and doc_url non-empty, skills and distros non-empty, doc_url in http(s), lab_type within the enumeration, exam_passing_score between 1 and 100 |
| Content | Every relative link of a Markdown file points to an existing file; the announced scale matches the tests; a document translated on one side only is reported; the hosts of targets and roles exist in infra.hosts; no file of solution/ is readable in clear text; fixtures/ and runtime.fixtures say the same thing |
--check-urls adds the only network check, every doc_url must
answer, and --json renders every anomaly with its key, its lab and its path.
The fixtures check is worth understanding: the shell runtime iterates over
runtime.fixtures, not over the fixtures/ directory. A fixture declared but
absent makes run fail with code 2 naming every offender at once; a fixture
present but undeclared is reported here. That defect had made seven labs
unplayable on 2026-07-28, all marked done, because the tooling that checks
the answer keys copied the whole directory.
Splitting the course and translating
Without course.yaml, dsoxlab course prints scenario.md and
README.md in one block, which is why long courses are long. A
course.yaml next to the lab.yaml lets it show one section at a time,
with --next, --prev and --section, and remember where the learner
stopped:
sections: - id: navigation title: Moving around the tree file: course/01-navigation.mdTranslations follow a single convention, per file: lab.fr.yaml
overrides the lab's title and description, meta.fr.yml the titles of
the catalogue and its sections, course.fr.yaml the section titles of the
course. A language suffix per field, such as title_en:, is not part of the
contract, is read by nobody, and validate-structure reports it.
The schemas, in the editor and in CI
Two JSON Schemas describe the same contract, schemas/meta.schema.json
and schemas/lab.schema.json, and a test of the project confronts them with
the parser in both directions so that they do not drift. The
yaml-language-server comment line placed at the top of the examples above is
enough for any editor running that server to complete the fields and
underline skils: as you type it.
In CI, validation happens without installing dsoxlab, because the schema URLs are plain files that any JSON Schema validator knows how to fetch:
uvx check-jsonschema \ --schemafile https://raw.githubusercontent.com/stephrobert/dsoxlab/main/schemas/lab.schema.json \ $(find labs -name lab.yaml)Replace main with a release tag in the URL to freeze the schema:
main follows the contract as it evolves, a tag never moves under your feet.
Unknown keys are ignored by the parser, refused by the schemas and reported
by validate-structure, and that combination is deliberate: the engine stays
tolerant so that an older tool survives a newer catalogue, while the editor and
the validator let nothing through.
Playing your lab before publishing it
A lab never played is not a lab. Before any publication, the sequence is
the learner's, plus a return to zero to prove reproducibility:
validate-structure, then run, solve, check, then reset and replay. The
answer key, if one exists under solution/, must stay unreadable in clear
text, and the validator checks it.
dsoxlab validate-structuredsoxlab run first-lab # solve the mission inside the sessiondsoxlab check first-lab # the announced scale must be reacheddsoxlab reset first-lab # then replay from scratchWhat remains is to wire the lab to its guide, in both directions: the
lab's doc_url points to the page that teaches the capability, and that
page announces the lab. On this site, every paired guide carries a "Hands-on
lab" box with the exact identifier of the lab.yaml, and the lesson switches
to the lab type in its training path. A lab nobody sees is played by nobody.
Troubleshooting
These four cases are the ones every author meets on their first catalogue. None is an engine failure: each comes from a gap between what you thought you declared and what the contract reads.
| Symptom | Cause | Solution |
|---|---|---|
The lab does not show up in list-labs | Its lab.yaml does not load: broken YAML, or id, title, level missing | dsoxlab validate-structure names it with the line; the full detail is in ~/.local/state/dsoxlab/dsoxlab.log |
content_scoring_tasks_vs_tests | The number of graded tasks in the mission differs from the number of test_ functions | Align the mission with the tests, or the reverse, then run the validator again |
run exits 2 and names a fixture | An entry of runtime.fixtures has no file under fixtures/ | Add the file, or remove the declaration; it is all or nothing before the slightest copy |
dsoxlab challenge prints No challenge/README.md file found for this lab. | new lab does not create the mission | Write it, with its scale header and its graded ### headings |
Key points
- A catalogue is a repository with a
meta.ymland onelab.yamlper lab; removing dsoxlab must leave the labs playable by hand. - You start from an observable capability, the runtime is imposed by the subject, and the tests prove the state of the system.
- Discovery goes by path,
sections[].labs[]only orders;runtime.typeisvm, andruntime.hostdoes not exist. - The mission announces a scale per task, one point per test, and
validate-structurerefuses any gap between the two. - A hint costs 10 points by default, and directs the eye without giving the command.
conftest.pylives at the catalogue root, andDSOXLAB_TARGET_HOSTtells the tests which host to inspect.- A lab never played is not a lab:
validate-structure,run,check,reset, then replay.
Next steps
- How to read this documentation: how a lab appears inside a guide on this site, which is where a
doc_urllands. - The English documentation: which training paths exist in English with their labs; the Linux, Kubernetes and Terraform paths that host the published catalogues are in French for now.