Skip to content
Français
Français
medium

Creating a dsoxlab course and its challenges from scratch

35 min read

Read this page in French

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.yml and lab.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 name

challenge/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.

Terminal window
dsoxlab new catalog my-training --in ~/Projects # the catalogue
cd ~/Projects/my-training
dsoxlab new lab first-lab --runtime shell # or --runtime vm
dsoxlab list-labs # the lab is already discovered

new 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.json
schema_version: 1
repo:
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-lab

Two 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.json
id: first-lab
title: "Create a user with key-only SSH access"
level: l2
skills: [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 capstone
estimated_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: false
validation:
functional: true
persistence_after_reboot: true

A 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 absent
hints:
- 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 dsoxlab
from dsoxlab.infra.inventory import build_inventory, read_terraform_outputs, write_ssh_config

That 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 history
def test_service_running_and_persistent(host):
service = host.service("chronyd")
assert service.is_running
assert service.is_enabled

Validating 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.

FamilyWhat is checked
Structurelab.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
Metadataid, 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
ContentEvery 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.md

Translations 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:

Terminal window
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.

Terminal window
dsoxlab validate-structure
dsoxlab run first-lab # solve the mission inside the session
dsoxlab check first-lab # the announced scale must be reached
dsoxlab reset first-lab # then replay from scratch

What 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.

SymptomCauseSolution
The lab does not show up in list-labsIts lab.yaml does not load: broken YAML, or id, title, level missingdsoxlab validate-structure names it with the line; the full detail is in ~/.local/state/dsoxlab/dsoxlab.log
content_scoring_tasks_vs_testsThe number of graded tasks in the mission differs from the number of test_ functionsAlign the mission with the tests, or the reverse, then run the validator again
run exits 2 and names a fixtureAn 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 missionWrite it, with its scale header and its graded ### headings

Key points

  • A catalogue is a repository with a meta.yml and one lab.yaml per 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.type is vm, and runtime.host does not exist.
  • The mission announces a scale per task, one point per test, and validate-structure refuses any gap between the two.
  • A hint costs 10 points by default, and directs the eye without giving the command.
  • conftest.py lives at the catalogue root, and DSOXLAB_TARGET_HOST tells 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_url lands.
  • 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain this site for free, with no ads, no ad profiling and no account to create. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn