Hands-on Ansible training, driven by the
dsoxlab CLI. This repository is the
lab catalog for the Ansible track of
www.stephane-robert.info,
aimed at the RHCE (EX294) certification, with idempotency as its common
thread.
ansible-training is a content repository, not an application. It provides:
- guided labs, with precise instructions,
- challenges with no walkthrough, to check you can work on your own,
- an EX294 mock exam that pulls everything together,
- automated validation that proves the state of the managed nodes (rather than that a playbook was written),
- scoring with cost-weighted hints.
The dsoxlab CLI is the single entry point: it prepares a lab, shows the
mission, validates, scores and reports. It lives in its own repository and
is installed separately: it is not part of this repo.
- Python 3.11+ and
uv misefor the Ansible toolchain (see below)git- KVM/libvirt: the labs need 4 AlmaLinux 9 VMs (1 control node, 3 managed nodes). Budget around 6 GB of RAM and 55 GB of disk.
# 1. The dsoxlab CLI (external tool, stays out of this repo)
uv tool install dsoxlab # or: pipx install dsoxlab
# 2. This lab catalog
git clone https://github.com/stephrobert/ansible-training.git
cd ansible-training
# 3. The Ansible toolchain, at exam versions
mise install # ansible-core 2.18, ansible-lint, molecule, yamllint
# 4. The 4 VMs, prepared (~5 min)
mise run provision
# 5. Browse and play
dsoxlab list-labs
dsoxlab run <lab-id>
dsoxlab check <lab-id>Start with the decouvrir section. bootstrap prepares the
infrastructure; it is not the pedagogical entry point.
dsoxlab use decouvrir # starting section
dsoxlab next # → decouvrir-declaratif-vs-imperatifThen, for this lab and every other one, the same four-step cycle:
dsoxlab course <id> # 1. the context, then the course
dsoxlab challenge <id> # 2. what is asked of you
dsoxlab run <id> # 3. prepares your workspace and puts you in it
dsoxlab check <id> # 4. validate and scorerun is the step people skip: it creates the files you work on, and for a vm
lab it puts the managed nodes into the state the scenario describes. A check
run without run fails by reporting that nothing is done — true, but
misleading.
The 25 shell labs need no virtual machine at all: writing YAML, a Jinja2
template, an inventory, running Molecule or ansible-lint all happen on your
own box. You can start without provisioning anything, and only bring the 4 VMs
up when you reach the vm labs.
⚠️ mise run provision, notdsoxlab provisionon its own. The CLI brings the VMs up, but it delivers them bare: cloud-init sets the account and the key, nothing more. The labs, however, target equipped managed nodes (firewalld,python3-firewall,chrony). Without that preparation, any lab touching the firewall fails with "Failed to import the required Python library (firewall)". Themisetask chains provisioning and bootstrap.To replay the preparation alone:
mise run bootstrap-nodes.
dsoxlab doctor checks your environment (Python, pytest, runtimes, detected
labs). mise run setup-hosts and mise run setup-ssh make the lab names
resolvable and configure SSH to use the repository key.
Why mise on top of dsoxlab: the ansible-core version is part of the
exercise. A playbook that passes on 2.18 may fail on 2.19, and the EX294 is sat
on a specific version. mise pins it, along with the matching lint toolchain.
dsoxlab only drives the labs.
Labs land in this repository, the CLI evolves on its own. Update each separately:
git pull # new labs
uv tool upgrade dsoxlab # the CLI (or: pipx upgrade dsoxlab)
mise install # the Ansible toolchain if versions movedYour work in progress lives in each lab's challenge/ and is not versioned:
git pull brings in new labs without ever touching it.
The catalog is described by data, not code: the dsoxlab engine stays
domain-agnostic and reads two levels of files.
meta.ymlat the root declares the repository identity, the infrastructure topology (network, hosts, provider) and the order of the sections shown bylist-labs.lab.yamlper lab (underlabs/<section>/<lab>/) declares itsskills, itslevel, itsruntime(vmorshell, with the hosts it targets), itsdistros, itsdoc_urland avalidationblock. An optionallab.fr.yamloverridestitleanddescriptionin French.
dsoxlab validate-structure checks the contract of the labs it discovered:
required files present, complete metadata, consistent targets. Careful though,
it does not report a lab declared in meta.yml but missing from disk:
discovery is a glob over labs/**/lab.yaml, and an invalid lab.yaml
disappears from the catalog silently. Hence the rule: dsoxlab list-labs
first, validate-structure second.
The learner drives everything from the CLI:
dsoxlab doctor # check the environment
dsoxlab list-labs # browse the catalog
dsoxlab show <id> # metadata and status of a lab
dsoxlab run <id> # prepare and start the environment
dsoxlab challenge <id> # read the mission (no walkthrough)
dsoxlab hint <id> # reveal a hint (deducted from the score)
dsoxlab check <id> # run the tests, compute and score
dsoxlab submit <id> # final submission, closes the session
dsoxlab progress # progress per section, average scorerun is when the environment comes up. For a shell lab, the CLI creates the
workdir and copies the declared fixtures. For a vm lab, it plays the lab's
setup.yaml on the managed nodes and opens access to the control node, where
you write your playbooks.
A dedicated lab-ansible libvirt network (10.10.20.0/24), so it coexists with
the other labs without subnet collisions.
| Host | Role | RAM | vCPU |
|---|---|---|---|
control-node.lab |
control node: where you write your playbooks | 2048 | 2 |
web1.lab |
managed node | 1024 | 1 |
web2.lab |
managed node | 1024 | 1 |
db1.lab |
managed node | 1536 | 1 |
IPs are not declared: Terraform assigns them and the inventory reads them. At
run time dsoxlab injects the groups that lab playbooks target: lab_target
(the control node), lab_<role> (one per managed node in use) and labenv
(all of them). A lab never hardcodes a FQDN.
cloud-init sets up two accounts on every node, both hardened the same way:
SSH key only (ssh_pwauth: false), no login password, and
sudo NOPASSWD:ALL.
ansibleis the service account through which all automation connects,dsoxlabas well as the lab playbooks. It is the inventory'sansible_userand the user in the generatedssh_config. Connecting through a dedicated service account, separate from the human one, is the good practice: automation actions are attributable and the account can be revoked independently. ItsNOPASSWD:ALLis deliberate: RHCE automation touches everything (dnf, systemd, LVM, SELinux, firewalld); security rests on the account being dedicated, not on a crippledsudothat would break automation.studentis the human account, the one you rundsoxlabandansiblefrom on the control node. It also exists on the managed nodes for debugging, but it is never the one driving automation.
Practical consequence: when a lab restricts SSH access (AllowUsers) or sets a
remote_user, it targets ansible; restricting it to any other account
would cut automation off. To inspect a node by hand, dsoxlab ssh <host>
connects you as ansible.
| Runtime | What it brings |
|---|---|
vm |
Terraform + libvirt. Real managed nodes: services, packages, users, storage, and persistence across reboot. 88 labs. |
shell |
What stays local to your box: writing YAML, a Jinja2 template, an inventory, running Molecule or ansible-lint. 25 labs. |
VMs are provisioned once with dsoxlab provision and torn down with
dsoxlab destroy.
Validation proves the state of the system, it does not take your word for
it. Every lab ships pytest / pytest-testinfra tests under
challenge/tests/ that check facts on the machine: the service runs and is
enabled, the deployed file has the right content and the right owner. A test
that merely checks a command was typed is rejected.
Second requirement, specific to Ansible: idempotency. A lab whose solution
still reports changed when replayed is a broken lab. That is the trap RHCE
candidates fall into, so the tests prove it whenever the subject warrants it.
- In instructor mode, a fixture in the root
conftest.pyreplays the reference solution before the tests, to prove the solution itself is correct. - In
dsoxlab check(the learner's path), that replay is disabled (LAB_NO_REPLAY=1): the tests validate their own work.
check records a score (tests passed out of total, minus the cost of any hints
used). Hints are cost-weighted: revealing one removes points, which is why
they are opt-in. History lives in a SQLite database local to the repository
(.dsoxlab.db, not versioned); dsoxlab scores and dsoxlab progress read it.
Reference solutions live in solution/, encrypted with ansible-vault. A
plain-text solution spoils the lab for everyone, and git history keeps it even
after deletion. A pre-commit hook checks the encryption header on every commit
rather than taking it on trust.
mise run solutions-status # check everything is encrypted
mise run solve <section>/<lab> # apply the official solution (instructor)Labs live under labs/ and are ordered by meta.yml. The list below is
generated: run python3 scripts/render-readme.py to refresh it.
113 labs across 23 sections (source of truth: meta.yml).
System preparation of the managed nodes, played by dsoxlab provision.
bootstrap-prepare-managed-nodes: Prepare Ansible managed nodes
First contact: declarative versus imperative, installation, the CLI, configuration.
decouvrir-declaratif-vs-imperatif: Declarative vs imperative: why Ansible does not driftdecouvrir-installation-ansible: Ansible installation: verify your control nodedecouvrir-configuration-ansible: ansible.cfg: precedence and critical optionsdecouvrir-prise-en-main-cli: Ansible CLI: the 8 everyday commands
A first playbook, and first vault secrets.
premiers-pas-premier-playbook: First playbook: install nginx on the webserverspremiers-pas-ansible-vault: First steps with ansible-vault
The anatomy of a play, execution control, variables, Jinja2, conditionals, loops, error handling.
ecrire-code-plays-et-tasks: Plays and tasks: anatomy and execution orderecrire-code-handlers: handlers: the restart-on-config-change patternecrire-code-tags: tags: target or skip a subset of tasksecrire-code-checkmode-diff: check mode and diff: dry-run and change previewecrire-code-variables-base: Variables: declaration and scopeecrire-code-types-collections: Collection types: lists, dicts, nested structuresecrire-code-facts-magic-vars: facts and magic vars: ansible_facts, hostvarsecrire-code-custom-facts: Custom facts: facts.d and ansible_localecrire-code-precedence-variables: Variable precedence: the 22 levelsecrire-code-register-set-fact: register and set_fact: capture and create variablesecrire-code-parallelisme-strategies: Parallelism: forks, serial, throttle, strategyecrire-code-async-poll: async and poll: long tasks without blocking SSHecrire-code-delegation: Delegation: delegate_to, run_once, local_actionecrire-code-lookups: lookups: fetch external data at runtimeecrire-code-jinja2-base: Jinja2: interpolation, logic and whitespaceecrire-code-filtres-jinja-essentiels: Essential Jinja2 filters: default, combine, selectattrecrire-code-conditions-when: when conditions: operators and Jinja testsecrire-code-boucles-loop: loop: iterate over lists and dictsecrire-code-boucles-with-deprecated: Legacy with_* loops: migrating to loopecrire-code-block-rescue-always: block, rescue, always: try/catch/finallyecrire-code-failed-when-changed-when: failed_when and changed_when: redefine success and changeecrire-code-ignore-errors: ignore_errors: legitimate use vs anti-patternecrire-code-any-errors-fatal: any_errors_fatal: stop the play on the first errorecrire-code-filtres-jinja-avances: Advanced Jinja2 filters: regex, b64, password_hashecrire-code-tests-jinja: Jinja2 tests: is defined, is mapping, is sequenceecrire-code-module-template: template module: validate, backup, whitespaceecrire-code-lineinfile-vs-template: lineinfile vs template: which module whenecrire-code-import-include: import vs include: static or dynamic
Handling files: copy, file, blockinfile, lineinfile, replace, fetch, archive.
modules-fichiers-copy: copy module: transfer files and inline contentmodules-fichiers-file: file module: states, permissions and symlinksmodules-fichiers-blockinfile: blockinfile module: idempotent multi-line blockmodules-fichiers-lineinfile: lineinfile module: edit a line in an existing filemodules-fichiers-replace: replace module: substitute a pattern in a filemodules-fichiers-fetch: fetch module: retrieve files from managed nodesmodules-fichiers-archive-unarchive: archive and unarchive modules: compress and extract
Distribution-agnostic package management (package), dnf options, RPM repositories.
modules-paquets-package: package module: distro-agnostic installationmodules-paquets-dnf-options: dnf module: enablerepo, security, exclude, autoremovemodules-paquets-yum-repository: yum_repository module: declare an RPM repository
Managing systemd services and cron jobs.
modules-services-systemd: systemd_service module: manage systemd servicesmodules-services-cron: cron module: schedule idempotent jobs
Managing users, groups, SSH keys and sudoers.
modules-utilisateurs-user: user module: create, modify and remove accountsmodules-utilisateurs-group: group module: manage Linux groupsmodules-utilisateurs-authorized-key: authorized_key module: user SSH keysmodules-utilisateurs-sudoers: sudoers module: manage sudo rights safely
RHEL specifics: firewalld, SELinux, sysctl, mount, parted, filesystem, LVM.
modules-rhel-firewalld: firewalld module: manage the RHEL firewallmodules-rhel-sysctl: sysctl module: persisted kernel parametersmodules-rhel-selinux: selinux module: modes, booleans and contextsmodules-rhel-mount: mount module: manage fstab and mount pointsmodules-rhel-parted: parted module: create a disk partitionmodules-rhel-filesystem: filesystem module: create a filesystemmodules-rhel-lvm-storage: LVM: chaining lvg, lvol, filesystem and mount
Downloads and HTTP calls: get_url, uri.
modules-reseau-get-url: get_url module: download a file over HTTP/HTTPSmodules-reseau-uri: uri module: REST API calls
Inspection and synchronisation: stat, find, assert/fail, wait_for/pause.
modules-diagnostic-stat: stat module: inspect files and directoriesmodules-diagnostic-find: find module: multi-file searchmodules-diagnostic-assert-fail: assert and fail modules: defensive validationmodules-diagnostic-wait-for-pause: wait_for and pause modules: synchronization
group_vars and host_vars, host patterns, the dynamic libvirt inventory.
inventaires-statiques: Write a static inventory from scratch: groups, children and group varsinventaires-group-vars-host-vars: group_vars and host_vars: structured inventory variablesinventaires-patterns-hotes: Host patterns: wildcards and the :, & and ! operatorsinventaires-dynamique-kvm: Dynamic KVM inventory with community.libvirt
The anatomy of a role, variables, handlers, argument_specs, consuming a role, dependencies, RHEL system roles.
roles-creer-premier-role: Create your first role with ansible-galaxy role initroles-variables-defaults-vars: Role variables: defaults/ vs vars/ and precedenceroles-handlers-meta: Role handlers and meta: notify and galaxy_inforoles-argument-specs: argument_specs: validate a role's input variablesroles-consommer-role: Consume a role: roles:, import_role, include_roleroles-dependencies: Role dependencies via meta/main.ymlroles-system-roles: RHEL system roles: converge time synchronisation with timesync
The TDD cycle with Molecule, multi-distribution scenarios.
molecule-introduction: Molecule: anatomy of a test scenariomolecule-installation-config: Molecule: enrich a scenario beyond the defaultsmolecule-tdd-cycle: Molecule: build a users role test-firstmolecule-scenarios-multi-distro: Molecule: test a role across distributions
testinfra, tox across versions, ansible-lint on the production profile.
tests-testinfra: Molecule + testinfra: Python assertions on a roletests-tox-multiversion: tox: test a role against several ansible-core versionstests-ansible-lint-production: ansible-lint production profile with pre-commit
GitHub Actions and GitLab CI pipelines for roles and collections.
ci-github-actions: GitHub Actions: CI workflow for a roleci-gitlab: GitLab CI: parallel:matrix pipeline for a role
The ansible-galaxy CLI, requirements.yml, auditing third-party roles, versioning and publishing.
galaxy-ansible-galaxy-cli: ansible-galaxy CLI: init, install, list, build, publishgalaxy-installer-roles: Install roles and collections from Galaxy or Gitgalaxy-auditer-role-existant: Audit a Galaxy role before adopting itgalaxy-versionner-publier: Version and publish a role: semver, Git tags, Galaxy
Vault in full: encrypt_string, multiple vault-ids, mixed files, vault inside roles, integration with HashiCorp and Passbolt.
vault-introduction: Ansible Vault: encrypt your first secrets filevault-chiffrer-fichier-variable: encrypt_string vs whole-file encryptionvault-id-multiples: Multiple vault-ids: isolate dev, staging and prodvault-playbooks-mixtes: Mixed playbooks: main.yml plus vault.yml per groupvault-dans-roles: Vault inside a role: plaintext defaults, encrypted varsvault-integration-hashicorp: Fetch secrets from HashiCorp Vault or OpenBaovault-integration-passbolt: Fetch secrets from Passbolt
ansible-navigator, ansible-builder, a custom EE, the CI pipeline, debugging.
ee-hello: First run inside an Execution Environmentee-inspection: Inspect an Execution Environment and pick the right oneee-builder-custom: Build a custom EE with ansible-builder v3ee-ci-pipeline: CI/CD pipeline for Execution Environmentsee-debug: Debug a broken Execution Environment
Verbosity, the interactive debugger, idempotence and performance.
troubleshooting-verbosite: Verbosity levels and callback pluginstroubleshooting-debugger: Interactive debugger: debugger: on_failedtroubleshooting-idempotence-perfs: Fix broken idempotence and tune performance
Discovery, requirements, authoring, CI tests, migrating from a role.
collections-decouvrir: Explore Ansible collections: FQCN and structurecollections-navigator: Automation content navigator: discover a module in a collection and use itcollections-requirements: requirements.yml: multi-source install with GPG signaturescollections-creer-custom: Build your own collection with a custom Python modulecollections-ci-tests: CI matrix for a collection: ansible-core x Pythoncollections-migration-role: Migrate a standalone role to a collection
Versioning your playbooks with Git, ansible-pull in GitOps mode.
pratiques-versionner-git: Version your playbooks with Gitpratiques-ansible-pull-gitops: ansible-pull: GitOps and Edge pattern
Full four-hour exam mocks, 19 tasks each, graded by pytest.
rhce-mock-ex294: RHCE EX294 mock exam: 19 tasks in 4 hoursrhce-mock-ex294-2: Mock RHCE EX294 #2
| Symptom | Where to look |
|---|---|
UNREACHABLE on a managed node |
dsoxlab status; are the VMs running (virsh list --all)? |
dsoxlab list-labs does not show your lab |
its lab.yaml raises while parsing: it vanishes with no message |
| A test passes "for no reason" | facts cache: rm -rf .ansible_facts/ |
| A lab fails right after another | inherited state: dsoxlab clean <lab-id> |
| Risky lab to play | mise run snapshot before, mise run restore after |
- Contributions: see CONTRIBUTING.
- Conduct: Code of Conduct · Security: SECURITY.
- Publishing: RELEASING (tar.gz bundles, no PyPI).
A lab is proven in both directions before it is merged,
dsoxlab validate-structure is green, and the statement exists in English and
in French.
Copyright (c) 2026 Stéphane Robert, https://www.stephane-robert.info
This catalogue is published under the Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license. You may share and adapt it, including commercially, on two conditions: credit Stéphane Robert with a link to the blog and state what you changed, and distribute your contributions under the same license.
The attribution lives here rather than at the top of
LICENSE, and that is deliberate: GitHub only identifies a license from its bare official text. A custom header above it makes the whole file unrecognisable, the repository is reported asNOASSERTION, and Scorecard's License check drops from 10 to 9.