# Source of "Valider chaque changement en CI"

The files of this tutorial, as they are in the repository. The authoring brief that defines the format follows them.

````yaml title="content/netdevops-lab/series.yaml"
title:
  en: NetDevOps lab
  fr: Lab NetDevOps
summary:
  en: >-
    A network lab you can rebuild in minutes: a containerlab topology, NetBox as the source
    of truth, configuration generated and pushed from it, and a CI pipeline that proves every
    change before it reaches the lab.
  fr: >-
    Un lab réseau que tu reconstruis en quelques minutes : une topologie containerlab, NetBox
    comme source de vérité, la configuration générée et poussée depuis lui, et un pipeline CI
    qui prouve chaque changement avant qu'il n'atteigne le lab.
order:
  - containerlab-topology
  - netbox-as-source-of-truth
  - render-and-push-configs
  - validate-in-ci

# Shared by every page: the reader fills these once for the whole series.
groups:
  - id: lab
    label: { en: Lab machine, fr: Machine de lab }
    desc: { en: The Linux host that runs Docker and containerlab., fr: L'hôte Linux qui fait tourner Docker et containerlab. }
  - id: sot
    label: { en: Source of truth, fr: Source de vérité }
    desc: { en: The NetBox instance the lab is modelled in., fr: L'instance NetBox dans laquelle le lab est modélisé. }

vars:
  - key: LAB_DIR
    kind: path
    group: lab
    default: /home/lab/netlab
    label: { en: Lab directory, fr: Répertoire du lab }
    hint:
      en: Where the git repository and the topology file live on the lab machine.
      fr: Là où vivent le dépôt git et le fichier de topologie sur la machine de lab.
    impact:
      en: Every path on every page starts here. containerlab creates its working folder next to the topology file, and the CI runner checks the repository out with the same layout.
      fr: Tous les chemins de toutes les pages partent d'ici. containerlab crée son dossier de travail à côté du fichier de topologie, et le runner CI récupère le dépôt avec la même arborescence.
  - key: LAB_NAME
    kind: text
    group: lab
    default: netlab
    label: { en: Lab name, fr: Nom du lab }
    hint:
      en: The containerlab lab name. Lowercase, no spaces.
      fr: Le nom du lab containerlab. Minuscules, sans espace.
    impact:
      en: Prefixes every container (clab-<name>-spine1), the working folder (clab-<name>/) and the management network. Two labs with the same name on one host collide.
      fr: Préfixe chaque conteneur (clab-<nom>-spine1), le dossier de travail (clab-<nom>/) et le réseau de management. Deux labs du même nom sur un hôte entrent en collision.
  - key: MGMT_SUBNET
    kind: cidr
    group: lab
    default: 172.20.20.0/24
    label: { en: Management subnet, fr: Sous-réseau de management }
    hint:
      en: The Docker network containerlab creates for the nodes' management interfaces. Must not overlap anything the lab host already routes.
      fr: Le réseau Docker que containerlab crée pour les interfaces de management des nœuds. Ne doit rien chevaucher de ce que l'hôte route déjà.
    impact:
      en: The nodes' management IPs are taken from it, pinned in the topology and recorded in NetBox as primary IPs. Change it and all three must change together.
      fr: Les IP de management des nœuds en sont tirées, fixées dans la topologie et enregistrées dans NetBox comme IP primaires. La changer oblige à changer les trois ensemble.
  - key: NETBOX_URL
    kind: url
    group: sot
    default: https://netbox.example.com
    label: { en: NetBox URL, fr: URL de NetBox }
    hint:
      en: The base URL of your NetBox, without a trailing slash. The NetBox series of this site gets you one.
      fr: L'URL de base de ton NetBox, sans slash final. La série NetBox de ce site t'en donne un.
    impact:
      en: Used by the seed script, the inventory plugin and the CI pipeline. It must be reachable from the lab machine, and from the runner.
      fr: Utilisée par le script de seed, le plugin d'inventaire et le pipeline CI. Elle doit être joignable depuis la machine de lab, et depuis le runner.
  - key: NETBOX_TOKEN
    kind: secret
    group: sot
    default: ""
    label: { en: NetBox API token, fr: Jeton d'API NetBox }
    hint:
      en: A token of a user allowed to read and write DCIM, IPAM and config contexts. On this site it only ever goes into an environment variable.
      fr: Un jeton d'un utilisateur autorisé à lire et écrire DCIM, IPAM et les config contexts. Sur ce site il ne va jamais ailleurs que dans une variable d'environnement.
    impact:
      en: The seed script creates objects with it; the render step only reads. In CI it becomes a masked secret, never a file in the repository.
      fr: Le script de seed crée des objets avec ; l'étape de rendu ne fait que lire. En CI il devient un secret masqué, jamais un fichier du dépôt.
  - key: SITE_NAME
    kind: text
    group: sot
    default: Lab
    label: { en: NetBox site, fr: Site NetBox }
    hint:
      en: The site the lab devices are created under. Its slug is derived from it (lowercase, dashes).
      fr: Le site sous lequel les équipements du lab sont créés. Son slug en est dérivé (minuscules, tirets).
    impact:
      en: Every query on the following pages filters on this site, so the lab never touches production devices in the same NetBox.
      fr: Chaque requête des pages suivantes filtre sur ce site, pour que le lab ne touche jamais aux équipements de production du même NetBox.

choices:
  - key: NOS
    type: select
    label: { en: Network OS, fr: OS réseau }
    hint: { en: SR Linux needs no registration; cEOS needs an Arista account to download the image., fr: SR Linux ne demande aucune inscription ; cEOS demande un compte Arista pour télécharger l'image. }
    default: srlinux
    options:
      - { value: srlinux, label: { en: Nokia SR Linux (free image), fr: Nokia SR Linux (image libre) } }
      - { value: ceos, label: { en: Arista cEOS (image to import), fr: Arista cEOS (image à importer) } }
  - key: AUTOMATION
    type: select
    label: { en: Automation tool, fr: Outil d'automatisation }
    hint: { en: Nornir is Python you read and debug; Ansible is YAML your team may already know., fr: Nornir, c'est du Python que tu lis et débogues ; Ansible, du YAML que ton équipe connaît peut-être déjà. }
    default: nornir
    options:
      - { value: nornir, label: { en: Nornir + scrapli (Python), fr: Nornir + scrapli (Python) } }
      - { value: ansible, label: { en: Ansible, fr: Ansible } }
  - key: CI
    type: select
    label: { en: CI, fr: CI }
    default: github
    options:
      - { value: github, label: { en: GitHub Actions, fr: GitHub Actions } }
      - { value: gitlab, label: { en: GitLab CI, fr: GitLab CI } }
````

````yaml title="content/netdevops-lab/validate-in-ci/tuto.yaml"
# Inherits from ../series.yaml: LAB_DIR, LAB_NAME, MGMT_SUBNET, NETBOX_URL, NETBOX_TOKEN, SITE_NAME, NOS, AUTOMATION, CI.
# LOOPBACK_PREFIX is also declared by netbox-as-source-of-truth and render-and-push-configs: same key, filled once per series.

title:
  en: Validate every change in CI
  fr: Valider chaque changement en CI
summary:
  en: >-
    A lint stage on a hosted runner, a lab stage on a self-hosted runner that deploys the
    topology, renders, pushes, runs a pytest with scrapli and always tears down, artifacts,
    and a protected main branch that only merges what the pipeline proved.
  fr: >-
    Une étape de lint sur un runner hébergé, une étape lab sur un runner auto-hébergé qui
    déploie la topologie, rend, pousse, lance un pytest avec scrapli et démonte toujours, des
    artefacts, et une branche main protégée qui ne fusionne que ce que le pipeline a prouvé.
difficulty: advanced
tags: [ci, github-actions, gitlab-ci, pytest, scrapli, containerlab, netdevops]
authors: [thudal]
created: 2026-09-25
minutes: 40
validated: containerlab 0.60 · GitHub Actions runner 2.3xx · GitLab Runner 17 · pytest 8
status: draft             # not yet run end to end by its author

groups:
  - id: ci
    label: { en: CI, fr: CI }
    desc: { en: The forge that hosts the repository and the runner on the lab machine., fr: La forge qui héberge le dépôt et le runner sur la machine de lab. }

vars:
  - key: REPO_URL
    kind: text
    group: ci
    default: https://github.com/you/netlab.git
    label: { en: Repository URL, fr: URL du dépôt }
    hint:
      en: The remote the lab repository is pushed to, on GitHub or GitLab. HTTPS or SSH form, as you clone it.
      fr: Le dépôt distant vers lequel le dépôt du lab est poussé, sur GitHub ou GitLab. Forme HTTPS ou SSH, comme tu le clones.
    impact:
      en: Added as origin on the lab machine; the runner registration points at the same project.
      fr: Ajouté comme origin sur la machine de lab ; l'enregistrement du runner pointe sur le même projet.
  - key: RUNNER_LABEL
    kind: text
    group: ci
    default: netlab
    label: { en: Runner label, fr: Label du runner }
    hint:
      en: The label (GitHub) or tag (GitLab) that sends the lab job to the runner on the lab machine, and nowhere else.
      fr: Le label (GitHub) ou tag (GitLab) qui envoie le job lab au runner de la machine de lab, et nulle part ailleurs.
    impact:
      en: Written in runs-on / tags in the pipeline file and given to the runner at registration. A mismatch leaves the job queued forever.
      fr: Écrit dans runs-on / tags du fichier de pipeline et donné au runner à l'enregistrement. Un écart laisse le job en attente pour toujours.
  - key: LOOPBACK_PREFIX
    kind: cidr
    group: sot
    default: 10.0.0.0/24
    label: { en: Loopback prefix, fr: Préfixe des loopbacks }
    hint:
      en: As on the previous pages; the tests derive the leaves' loopbacks from it.
      fr: Comme sur les pages précédentes ; les tests en dérivent les loopbacks des leaves.
    impact:
      en: The ping test uses the second and third host addresses (leaf1, leaf2).
      fr: Le test de ping utilise les deuxième et troisième adresses d'hôte (leaf1, leaf2).
````

````mdx title="content/netdevops-lab/validate-in-ci/page-en.mdx"
{/* First pass — to be validated against docs.github.com (self-hosted runners, workflow syntax, branch protection), docs.gitlab.com (runner registration, .gitlab-ci.yml, protected branches), containerlab.dev and scrapli.github.io before publishing. */}

Three pages of tooling and still one weak point: a human runs it, on a lab that may or may not be in the state they think. This page makes the machine do it, on every change: a pipeline lints the repository, deploys a fresh lab on a runner installed on the lab machine, renders and pushes from NetBox, checks BGP and the pings with pytest, publishes the rendered configs and the test report, and tears the lab down whatever happened. Then `main` is protected, so nothing reaches it that the pipeline has not proved.

<Run>

The script adds the Makefile, the lint configuration, the tests and the pipeline file to <V name="LAB_DIR" />, runs the whole thing once locally, and pushes to <V name="REPO_URL" />. Run it **on the lab machine**, with `NETBOX_TOKEN` in the environment. The runner registration and the branch protection need the forge's UI and are left to the steps.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetDevOps lab — CI for ${LAB_NAME}, runner label ${RUNNER_LABEL}
cd ${LAB_DIR}
mkdir -p tests
cat > .yamllint <<'EOF'
extends: default
rules:
  line-length: { max: 200 }
  truthy: { check-keys: false }
  document-start: disable
  comments: { min-spaces-from-content: 1 }
EOF
```

<When is="AUTOMATION" equals="nornir">

```bash
printf '%s\n' nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox pytest pyyaml yamllint ruff > requirements.txt
printf 'line-length = 160\n[lint]\nignore = ["E401", "E702", "E731"]\n' > ruff.toml
cat > Makefile <<'EOF'
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
lint:
> $(VENV)/yamllint . && $(VENV)/ruff check .
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/python render.py
push:
> $(VENV)/python push.py --commit
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
EOF
```

</When>

<When is="AUTOMATION" equals="ansible">

```bash
printf '%s\n' ansible pynetbox netaddr scrapli scrapli-community pytest pyyaml yamllint ansible-lint > requirements.txt
printf 'collections:\n  - netbox.netbox\n  - ansible.netcommon\n  - ansible.utils\n  - arista.eos\n  - nokia.srlinux\n' > requirements.yml
printf 'profile: min\n' > .ansible-lint
cat > Makefile <<'EOF'
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
> $(VENV)/ansible-galaxy collection install -q -r requirements.yml
lint:
> $(VENV)/yamllint . && $(VENV)/ansible-lint
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/ansible-playbook render.yml
push:
> $(VENV)/ansible-playbook push.yml
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
EOF
```

</When>

```bash
cat > tests/test_lab.py <<'EOF'
import ipaddress
import time

import pytest
import yaml
from scrapli import Scrapli

NODES = yaml.safe_load(open("topology.clab.yml"))["topology"]["nodes"]
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
PEERS = {"spine1": 2, "leaf1": 1, "leaf2": 1}
EOF
```

<When is="NOS" equals="srlinux">

```bash
cat >> tests/test_lab.py <<'EOF'
PLATFORM, AUTH = "nokia_srl", ("admin", "NokiaSrl1!")
BGP = ("info from state network-instance default protocols bgp neighbor * session-state", "session-state established")
PING = "ping -c 3 {dst} -I {src} network-instance default"
EOF
```

</When>

<When is="NOS" equals="ceos">

```bash
cat >> tests/test_lab.py <<'EOF'
PLATFORM, AUTH = "arista_eos", ("admin", "admin")
BGP = ("show ip bgp summary", "Estab")
PING = "ping {dst} source {src} repeat 3"
EOF
```

</When>

```bash
cat >> tests/test_lab.py <<'EOF'


def run(node, command):
    with Scrapli(host=NODES[node]["mgmt-ipv4"], platform=PLATFORM, auth_username=AUTH[0],
                 auth_password=AUTH[1], auth_strict_key=False) as conn:
        return conn.send_command(command).result


def eventually(check, tries=12, wait=5):
    for _ in range(tries):
        if check():
            return True
        time.sleep(wait)
    return False


@pytest.mark.parametrize("node", list(NODES))
def test_bgp_established(node):
    assert eventually(lambda: run(node, BGP[0]).count(BGP[1]) >= PEERS[node])


def test_loopback_ping():
    assert "3 received" in run("leaf1", PING.format(dst=LOOPBACKS[2], src=LOOPBACKS[1]))
EOF
```

<When is="CI" equals="github">

```bash
mkdir -p .github/workflows
cat > .github/workflows/lab.yml <<'EOF'
name: lab
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * *"
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: make deps lint
  lab:
    needs: lint
    runs-on: [self-hosted, ${RUNNER_LABEL}]
    timeout-minutes: 30
    concurrency: { group: lab-${LAB_NAME}, cancel-in-progress: false }
    env: { NETBOX_TOKEN: "${{ secrets.NETBOX_TOKEN }}" }
    steps:
      - uses: actions/checkout@v4
      - run: make deps deploy
      - run: make render push
      - run: make test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: "lab-${{ github.sha }}", path: "configs/\nreport.xml" }
      - if: always()
        run: make destroy
EOF
```

</When>

<When is="CI" equals="gitlab">

```bash
cat > .gitlab-ci.yml <<'EOF'
stages: [lint, lab]
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "schedule"
lint:
  stage: lint
  image: python:3.12-slim
  before_script: [apt-get update -qq && apt-get install -y -qq make git]
  script: [make deps lint]
lab:
  stage: lab
  tags: [${RUNNER_LABEL}]
  timeout: 30m
  resource_group: lab-${LAB_NAME}
  script:
    - make deps deploy
    - make render push
    - make test
  after_script: [make destroy]
  artifacts:
    when: always
    paths: [configs/]
    reports: { junit: report.xml }
EOF
```

</When>

```bash
make deps lint deploy render push test; make destroy
git add -A && git -c user.name=lab -c user.email=lab@localhost commit -qm "ci: lint, lab and tests" || true
git remote get-url origin >/dev/null 2>&1 || git remote add origin ${REPO_URL}
git branch -M main && git push -u origin main
```

<Warn>The lab job runs on the lab machine with sudo rights on containerlab. Only that machine, only that command: keep the sudoers rule to `containerlab`, and never register this runner on a public repository, where anyone's pull request could run code on it.</Warn>

</Run>

## Before you start

<Guided>The repository from the previous pages, an empty project created on <When is="CI" equals="github">GitHub</When><When is="CI" equals="gitlab">GitLab</When> at <V name="REPO_URL" /> (private), and admin rights on it to register a runner, add a secret and protect a branch. The lab machine needs outbound HTTPS to the forge; nothing inbound. Every command runs on the lab machine, in <V name="LAB_DIR" />.</Guided>

```bash
cd ${LAB_DIR}
git remote add origin ${REPO_URL}
git branch -M main
git push -u origin main
```

<Check cmd="git -C ${LAB_DIR} remote get-url origin" expect="${REPO_URL}" />

<Deep>Why push the repository before the pipeline exists? Because the runner registration is per project and the secret lives in the project: both need it to exist. Pushing the token-free repository is safe; the only secret in this series is `NETBOX_TOKEN`, which has never been in a file. Check with `git grep -i token` before the first push all the same.</Deep>

## The repository layout

<Guided>The pipeline must run the same commands you run by hand, or it tests something else. A `Makefile` names them once: `deps`, `lint`, `deploy`, `render`, `push`, `test`, `destroy`. The pipeline file then reads as a list of targets, and a colleague reads the Makefile before the pipeline.</Guided>

```text
${LAB_DIR}/
├── topology.clab.yml        # page 1
├── sot/seed.py              # page 2
├── templates/, configs/     # page 3
├── render.py · push.py      # page 3 (or render.yml · push.yml, inventory/, group_vars/)
├── tests/test_lab.py        # this page
├── Makefile · requirements.txt · .yamllint
└── .github/workflows/lab.yml  or  .gitlab-ci.yml
```

<When is="AUTOMATION" equals="nornir">

<Tabs group="layout">

<Tab label="Makefile">

```makefile title="${LAB_DIR}/Makefile"
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
lint:
> $(VENV)/yamllint . && $(VENV)/ruff check .
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/python render.py
push:
> $(VENV)/python push.py --commit
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
```

</Tab>

<Tab label="requirements.txt">

```text title="${LAB_DIR}/requirements.txt"
nornir
nornir-netbox
nornir-scrapli
nornir-jinja2
nornir-utils
scrapli-community
pynetbox
pytest
pyyaml
yamllint
ruff
```

</Tab>

<Tab label="ruff.toml">

```toml title="${LAB_DIR}/ruff.toml"
line-length = 160
[lint]
ignore = ["E401", "E702", "E731"]
```

</Tab>

</Tabs>

</When>

<When is="AUTOMATION" equals="ansible">

<Tabs group="layout">

<Tab label="Makefile">

```makefile title="${LAB_DIR}/Makefile"
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
> $(VENV)/ansible-galaxy collection install -q -r requirements.yml
lint:
> $(VENV)/yamllint . && $(VENV)/ansible-lint
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/ansible-playbook render.yml
push:
> $(VENV)/ansible-playbook push.yml
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
```

</Tab>

<Tab label="requirements">

```text title="${LAB_DIR}/requirements.txt"
ansible
pynetbox
netaddr
scrapli
scrapli-community
pytest
pyyaml
yamllint
ansible-lint
```

```yaml title="${LAB_DIR}/requirements.yml"
collections:
  - netbox.netbox
  - ansible.netcommon
  - ansible.utils
  - arista.eos
  - nokia.srlinux
```

</Tab>

<Tab label=".ansible-lint">

```yaml title="${LAB_DIR}/.ansible-lint"
profile: min
```

</Tab>

</Tabs>

</When>

<Note>`.RECIPEPREFIX = >` lets recipes start with `>` instead of a tab, which survives copy-paste and web pages; it needs GNU make 4.0 or newer, which every current distribution has. `--reconfigure` on deploy destroys a lab of the same name first, so a job that crashed before its `destroy` step does not block the next one.</Note>

<Deep>`configs/` is committed on purpose: a pull request that changes a template or a NetBox value shows the resulting configuration diff in the review, which is the single most useful thing a reviewer of network automation can look at. The pipeline renders again and would fail `test` if the committed files lied. `report.xml` and `.venv/` are not committed; add them to `.gitignore`.</Deep>

```bash
printf 'report.xml\n' >> .gitignore
```

## Lint locally

<Guided>Lint is the cheap stage: seconds, no lab, no NetBox. It catches the YAML with a tab in it and the Python with an unused import before a runner spends five minutes booting three switches for nothing. Run it locally until it is green, and keep it green.</Guided>

```yaml title="${LAB_DIR}/.yamllint"
extends: default
rules:
  line-length: { max: 200 }
  truthy: { check-keys: false }
  document-start: disable
  comments: { min-spaces-from-content: 1 }
```

```bash
make deps
make lint
```

<When is="AUTOMATION" equals="nornir">

<Deep>`ruff` is the fast Python linter; its default rules are syntax errors, undefined names and unused imports, which is exactly the class of mistakes that makes a render fail at the last line. Three style rules are relaxed in `ruff.toml` because the scripts of the earlier pages are deliberately compact (`import a, b`, `a(); b()`, `slug = lambda …`); run `ruff format` once and drop the ignores if that bothers you. `truthy: check-keys: false` is for GitHub's `on:` key, which yamllint otherwise reads as a boolean.</Deep>

</When>

<When is="AUTOMATION" equals="ansible">

<Deep>`ansible-lint` with the `min` profile checks that the playbooks parse and load; the default `production` profile also wants fully qualified module names (`ansible.builtin.set_fact`), named plays and a few dozen style rules. Start with `min`, run `ansible-lint --profile production` by hand, fix what it lists, and raise the profile in `.ansible-lint` when it passes. `truthy: check-keys: false` is for the `on:` key of GitHub workflows, which yamllint otherwise reads as a boolean; harmless here.</Deep>

</When>

<Check cmd="cd ${LAB_DIR} && make lint >/dev/null && echo lint OK" expect="lint OK" />

## A runner on the lab machine

<Guided>The lab stage cannot run on the forge's shared runners: it needs Docker with root, the network OS images, and a path to NetBox. So a runner is installed on the lab machine itself, labelled <V name="RUNNER_LABEL" />, and only jobs asking for that label land on it. It needs to run containerlab with sudo, and nothing else with sudo.</Guided>

```bash
echo "$USER ALL=(ALL) NOPASSWD: /usr/bin/containerlab" | sudo tee /etc/sudoers.d/containerlab
sudo chmod 440 /etc/sudoers.d/containerlab
```

<When is="CI" equals="github">

In the repository: **Settings → Actions → Runners → New self-hosted runner**, Linux x64. Copy the download and configure commands from that page (they carry a one-time token), and add the label:

```bash
mkdir -p ~/actions-runner && cd ~/actions-runner
# curl … | tar xzf …   ← the two lines from the UI, with the current version
./config.sh --url ${REPO_URL} --token <one-time token from the UI> --labels ${RUNNER_LABEL} --unattended
sudo ./svc.sh install && sudo ./svc.sh start
```

<Note>The token on the runner page expires within the hour; if `config.sh` refuses it, reload the page. `--url` takes the repository URL without `.git`. The runner appears as *Idle* in the list once the service is up.</Note>

<Deep>The runner is a service that long-polls GitHub over HTTPS and runs jobs as the user who installed it: your lab user, member of the `docker` group, with the sudoers rule above. It checks the repository out under `~/actions-runner/_work/`, so `topology.clab.yml` and the lab folder live there during a job, not in <V name="LAB_DIR" />. Repository-level runners are simplest; an organisation-level runner with a runner group would let several lab repositories share the machine. Never on a public repository: pull requests from forks can run arbitrary code on your machine.</Deep>

<Check cmd="systemctl list-units 'actions.runner.*' --no-legend | grep -c running" expect="1" />

</When>

<When is="CI" equals="gitlab">

In the project: **Settings → CI/CD → Runners → New project runner**, tag <V name="RUNNER_LABEL" />, then on the lab machine:

```bash
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install -y gitlab-runner
sudo gitlab-runner register --non-interactive --url https://gitlab.com --token <glrt-… from the UI> \
  --executor shell --description "lab ${LAB_NAME}"
sudo usermod -aG docker gitlab-runner
echo "gitlab-runner ALL=(ALL) NOPASSWD: /usr/bin/containerlab" | sudo tee /etc/sudoers.d/containerlab-runner
```

<Note>Since GitLab 16 the tags are set in the UI when creating the runner, and `register` takes the `glrt-` authentication token; `--tag-list` on the command line is ignored for such tokens. Self-managed GitLab: replace the URL. The runner shows a green dot in the list once registered.</Note>

<Deep>The `shell` executor runs the job as the `gitlab-runner` user directly on the machine, which is what containerlab needs; the `docker` executor would put the job in a container and containerlab in a container needs the host's Docker socket and privileges, doable but a page of its own. The checkout lands under `/home/gitlab-runner/builds/`, so the lab runs there during a job, not in <V name="LAB_DIR" />. `resource_group` in the pipeline file serialises lab jobs: one lab on the machine at a time.</Deep>

<Check cmd="systemctl is-active gitlab-runner" expect="active" />

</When>

## The tests

<Guided>Tests state what "working" means for this lab: every node has its BGP sessions established, and leaf1's loopback reaches leaf2's. pytest runs them, scrapli asks the nodes, and BGP gets up to a minute to converge after the push. The management IPs come from the topology file, so a renamed or added node is tested without touching the tests.</Guided>

<Annotated>

```python title="${LAB_DIR}/tests/test_lab.py" {8,10-11,20,28-30,33-34}
import ipaddress
import time

import pytest
import yaml
from scrapli import Scrapli

NODES = yaml.safe_load(open("topology.clab.yml"))["topology"]["nodes"]   # (1)
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
PEERS = {"spine1": 2, "leaf1": 1, "leaf2": 1}                            # (2)
PLATFORM, AUTH, BGP, PING = …                                             # (3)


def run(node, command):
    with Scrapli(host=NODES[node]["mgmt-ipv4"], platform=PLATFORM, auth_username=AUTH[0],
                 auth_password=AUTH[1], auth_strict_key=False) as conn:
        return conn.send_command(command).result


def eventually(check, tries=12, wait=5):                                  # (4)
    for _ in range(tries):
        if check():
            return True
        time.sleep(wait)
    return False


@pytest.mark.parametrize("node", list(NODES))                             # (5)
def test_bgp_established(node):
    assert eventually(lambda: run(node, BGP[0]).count(BGP[1]) >= PEERS[node])


def test_loopback_ping():                                                 # (6)
    assert "3 received" in run("leaf1", PING.format(dst=LOOPBACKS[2], src=LOOPBACKS[1]))
```

1. The same file containerlab deploys: node names and pinned management IPs. One source for the topology, even in the tests.
2. What "established" means per node: the spine has two neighbours, each leaf one. The one place the tests know the shape of the fabric.
3. The platform block, per network OS, shown below: the scrapli driver, the credentials, the command that shows BGP state and the string to count, the ping command.
4. Retry up to a minute: the push returns before BGP converges, and a test that fails on timing is a test nobody trusts.
5. One test per node, named after it in the report: `test_bgp_established[leaf2]` failing tells you where to look.
6. Ping from leaf1's loopback to leaf2's, through the spine: both loopbacks advertised, both /31s up, in one line.

</Annotated>

<When is="NOS" equals="srlinux">

```python
PLATFORM, AUTH = "nokia_srl", ("admin", "NokiaSrl1!")
BGP = ("info from state network-instance default protocols bgp neighbor * session-state", "session-state established")
PING = "ping -c 3 {dst} -I {src} network-instance default"
```

</When>

<When is="NOS" equals="ceos">

```python
PLATFORM, AUTH = "arista_eos", ("admin", "admin")
BGP = ("show ip bgp summary", "Estab")
PING = "ping {dst} source {src} repeat 3"
```

</When>

```bash
make deploy render push
make test
```

<Deep>The tests scrape CLI output, which is the honest minimum: a `count()` on a state string. The next step is structured state: SR Linux answers gNMI and JSON-RPC with the same YANG paths as the `info from state` command, EOS answers eAPI with JSON for any `show` command, and scrapli's `textfsm_parse_output()` turns tables into dicts. Then a test asserts `state == "established"` per neighbour rather than counting words. What the tests deliberately do not check: anything the source of truth does not say. If a leaf gains a third interface in the topology but not in NetBox, no test fails; the model is the spec.</Deep>

<Check cmd={"cd ${LAB_DIR} && .venv/bin/pytest -q tests 2>/dev/null | tail -1 | grep -o '[0-9]* passed'"} expect="4 passed" />

<Details summary="If the BGP test times out">
Run `make test` a second time: if it passes, the fabric was still converging, and a `tries=24` is the fix. If it fails again, the state on the node is what it is: <When is="NOS" equals="srlinux">`docker exec clab-${LAB_NAME}-spine1 sr_cli 'show network-instance default protocols bgp neighbor'`</When><When is="NOS" equals="ceos">`docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'show ip bgp summary'`</When>, then the troubleshooting box of the previous page. A test that cannot connect at all (`ScrapliAuthenticationFailed`, timeouts) is a management IP that does not match the topology, or a node still booting: `make deploy` returns before <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> accepts SSH.
</Details>

## The pipeline

<Guided>Two jobs. *lint* runs on the forge's hosted runner in seconds; *lab* runs on yours only if lint passed, and always ends with `destroy`, whether the tests passed or not. It runs on every pull request, on every push to `main`, and once a night. The NetBox token is a secret of the project, injected as an environment variable.</Guided>

<When is="CI" equals="github">

In the repository: **Settings → Secrets and variables → Actions → New repository secret**, name `NETBOX_TOKEN`.

<Annotated>

```yaml title="${LAB_DIR}/.github/workflows/lab.yml" {3-7,17-18,20-21,29-31}
name: lab
on:
  pull_request:                                            # (1)
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * *"
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: make deps lint
  lab:
    needs: lint                                            # (2)
    runs-on: [self-hosted, ${RUNNER_LABEL}]                # (3)
    timeout-minutes: 30
    concurrency: { group: lab-${LAB_NAME}, cancel-in-progress: false }   # (4)
    env: { NETBOX_TOKEN: "${{ secrets.NETBOX_TOKEN }}" }   # (5)
    steps:
      - uses: actions/checkout@v4
      - run: make deps deploy
      - run: make render push
      - run: make test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: "lab-${{ github.sha }}", path: "configs/\nreport.xml" }   # (6)
      - if: always()
        run: make destroy                                  # (7)
```

1. Three triggers: every pull request (the review), every push to `main` (the record), and 03:17 every night (the drift check against the real NetBox, see below).
2. `lab` waits for `lint` and does not start if it failed: no runner time on a file that does not parse.
3. Both labels must match: `self-hosted` and yours. A job asking for a label no runner has stays queued until its timeout.
4. One lab job at a time on the machine, others wait: two topologies with the same name cannot coexist. `cancel-in-progress: false` lets a running job finish its `destroy`.
5. The secret, masked in the logs, present only in this job. The same variable name the scripts read on the previous pages.
6. Rendered configs and the JUnit report, kept whether the tests passed or not: a red run is the one whose artifacts you want.
7. `if: always()` is the whole point of the step: a failed test must not leave three containers eating the runner's memory until tomorrow.

</Annotated>

```bash
git add -A && git commit -m "ci: lint, lab and tests" && git push
```

<Note>The `on:` key and `${{ … }}` expressions are GitHub's syntax; the `${RUNNER_LABEL}` and `${LAB_NAME}` values are yours, filled in by this page. Confirm the action versions (`checkout@v4`, `upload-artifact@v4`) against the marketplace, they move.</Note>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; print(sorted(yaml.safe_load(open(\".github/workflows/lab.yml\"))[\"jobs\"]))'"} expect="['lab', 'lint']" />

<Check cmd={"gh run list --workflow lab.yml -L 1 --json conclusion -q '.[0].conclusion'"} expect="success" />

</When>

<When is="CI" equals="gitlab">

In the project: **Settings → CI/CD → Variables → Add variable**, key `NETBOX_TOKEN`, *masked*, *protected*.

<Annotated>

```yaml title="${LAB_DIR}/.gitlab-ci.yml" {3-6,14,16,21,23-25}
stages: [lint, lab]
workflow:
  rules:                                                   # (1)
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "schedule"
lint:
  stage: lint
  image: python:3.12-slim
  before_script: [apt-get update -qq && apt-get install -y -qq make git]
  script: [make deps lint]
lab:
  stage: lab                                               # (2)
  tags: [${RUNNER_LABEL}]                                  # (3)
  timeout: 30m
  resource_group: lab-${LAB_NAME}                          # (4)
  script:
    - make deps deploy
    - make render push
    - make test
  after_script: [make destroy]                             # (5)
  artifacts:
    when: always                                           # (6)
    paths: [configs/]
    reports: { junit: report.xml }
```

1. Three triggers: merge requests (the review), pushes to `main` (the record), and a schedule you create under **Build → Pipeline schedules** for the nightly drift check. Anything else, feature branches without an MR for instance, runs nothing.
2. Stages run in order; `lab` does not start if `lint` failed.
3. Only a runner carrying this tag takes the job. A tag no runner has leaves the job *stuck*.
4. One lab job at a time on the machine; others queue. Two topologies with the same name cannot coexist.
5. `after_script` runs whether `script` succeeded or not: the lab is always torn down.
6. Rendered configs and the JUnit report, kept on failure too, and the report shows as a test tab on the merge request.

</Annotated>

```bash
git add -A && git commit -m "ci: lint, lab and tests" && git push
```

<Note>`$CI_PIPELINE_SOURCE` and `$CI_COMMIT_BRANCH` are GitLab's predefined variables; `${RUNNER_LABEL}` and `${LAB_NAME}` are yours, filled in by this page. `make` and `git` are not in `python:3.12-slim`, hence the `before_script`; a small image of your own would save the twenty seconds.</Note>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; print(yaml.safe_load(open(\".gitlab-ci.yml\"))[\"stages\"])'"} expect="['lint', 'lab']" />

<Check cmd="glab ci list --per-page 1 | grep -o success" expect="success" />

</When>

<Deep>Three refinements once the first run is green. **Run only on paths that matter**: <When is="CI" equals="github">`on.pull_request.paths` with `topology.clab.yml`, `templates/**`, `tests/**`, the scripts and the workflow file</When><When is="CI" equals="gitlab">`rules: changes:` on the `lab` job with the same list</When>, so a README edit does not deploy a lab; keep lint on everything. **Cache the images**: the runner is the lab machine, Docker already has the <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> image from the first page and a deploy pulls nothing; that, and the cEOS licence which forbids putting the image in a registry, is the second reason for a self-hosted runner after Docker itself. **The nightly run** is the one that matters most: nothing changed in the repository, so if it goes red, NetBox changed, or the image tag moved, or the machine did. It is the drift detector between the source of truth and the lab, and the reason the token in CI is read-only.</Deep>

## Protect main

<Guided>The pipeline proves; the branch protection enforces. From now on `main` only moves by pull request, and a pull request only merges when *lint* and *lab* are green. Nobody, you included, pushes to `main` directly: a change that skipped the lab is a change nobody tested.</Guided>

<When is="CI" equals="github">

**Settings → Branches → Add branch ruleset** (or *Add classic branch protection rule*) on `main`: *Require a pull request before merging*, *Require status checks to pass* with `lint` and `lab` as required checks, *Do not allow bypassing*. With the CLI:

```bash
gh api -X PUT repos/{owner}/{repo}/branches/main/protection --input - <<'EOF'
{"required_status_checks": {"strict": true, "contexts": ["lint", "lab"]},
 "enforce_admins": true, "required_pull_request_reviews": {"required_approving_review_count": 0},
 "restrictions": null}
EOF
```

<Note>Status checks appear in the picker only after they have run once, which is why the pipeline came first. `enforce_admins: true` applies the rule to you too; that is the point.</Note>

<Check cmd={"gh api repos/{owner}/{repo}/branches/main/protection -q '.required_status_checks.contexts | sort | join(\",\")'"} expect="lab,lint" />

</When>

<When is="CI" equals="gitlab">

**Settings → Repository → Protected branches**: `main`, *Allowed to push and merge*: No one, *Allowed to merge*: Maintainers. Then **Settings → Merge requests**: *Pipelines must succeed* and *Enable merged results pipelines*. With the CLI:

```bash
glab api -X POST projects/:id/protected_branches -f name=main -f push_access_level=0 -f merge_access_level=40
glab api -X PUT projects/:id -f only_allow_merge_if_pipeline_succeeds=true
```

<Note>`:id` is resolved by `glab` from the current repository. The `NETBOX_TOKEN` variable was marked *protected*, so it is only available to pipelines on protected branches and merge requests from the project itself, not from forks.</Note>

<Check cmd={"glab api projects/:id | grep -o '\"only_allow_merge_if_pipeline_succeeds\":true'"} expect='"only_allow_merge_if_pipeline_succeeds":true' />

</When>

<Deep>The merge is now the deployment gate, which changes how the team works: a change to a template, to a test or to the topology is a branch, a pull request, a pipeline run on a fresh lab, a review with the rendered diff in front of the reviewer, then a merge. NetBox itself is outside this loop: a value changed there is picked up by the next run, which is what the nightly is for. The next step, beyond this series, is the same pipeline against staging hardware, with the push behind a manual approval; the lab stage then becomes the thing that has to be green before a human is even asked.</Deep>

## Done

Every change to the lab is now proved before it lands: lint on the hosted runner, a fresh lab on <V name="RUNNER_LABEL" /> with configuration rendered from <V name="NETBOX_URL" />, four tests, artifacts, teardown, and a `main` branch that only accepts what passed. The nightly run watches the source of truth for drift. From one Linux machine and one YAML file on the first page, this is a network you can rebuild, test and review like software, which is what NetDevOps means.

```bash
git checkout -b try-it && sed -i 's/tries=12/tries=24/' tests/test_lab.py && git commit -am "tests: wait longer for BGP" && git push -u origin try-it
```

Open the pull request and watch the runner do in five minutes what the first three pages did by hand.
````

````mdx title="content/netdevops-lab/validate-in-ci/page-fr.mdx"
{/* Première passe — à valider contre docs.github.com (runners auto-hébergés, syntaxe des workflows, protection de branche), docs.gitlab.com (enregistrement d'un runner, .gitlab-ci.yml, branches protégées), containerlab.dev et scrapli.github.io avant publication. */}

Trois pages d'outillage et toujours un point faible : un humain le lance, sur un lab qui est, ou pas, dans l'état qu'il croit. Cette page confie ça à la machine, à chaque changement : un pipeline linte le dépôt, déploie un lab neuf sur un runner installé sur la machine de lab, rend et pousse depuis NetBox, vérifie BGP et les pings avec pytest, publie les configs rendues et le rapport de tests, et démonte le lab quoi qu'il arrive. Puis `main` est protégée, pour que rien n'y arrive que le pipeline n'ait prouvé.

<Run>

Le script ajoute le Makefile, la configuration de lint, les tests et le fichier de pipeline à <V name="LAB_DIR" />, lance le tout une fois en local, et pousse vers <V name="REPO_URL" />. Lance-le **sur la machine de lab**, avec `NETBOX_TOKEN` dans l'environnement. L'enregistrement du runner et la protection de branche passent par l'interface de la forge et sont laissés aux étapes.

```bash
#!/usr/bin/env bash
set -euo pipefail
# Lab NetDevOps — CI pour ${LAB_NAME}, label de runner ${RUNNER_LABEL}
cd ${LAB_DIR}
mkdir -p tests
cat > .yamllint <<'EOF'
extends: default
rules:
  line-length: { max: 200 }
  truthy: { check-keys: false }
  document-start: disable
  comments: { min-spaces-from-content: 1 }
EOF
```

<When is="AUTOMATION" equals="nornir">

```bash
printf '%s\n' nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox pytest pyyaml yamllint ruff > requirements.txt
printf 'line-length = 160\n[lint]\nignore = ["E401", "E702", "E731"]\n' > ruff.toml
cat > Makefile <<'EOF'
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
lint:
> $(VENV)/yamllint . && $(VENV)/ruff check .
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/python render.py
push:
> $(VENV)/python push.py --commit
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
EOF
```

</When>

<When is="AUTOMATION" equals="ansible">

```bash
printf '%s\n' ansible pynetbox netaddr scrapli scrapli-community pytest pyyaml yamllint ansible-lint > requirements.txt
printf 'collections:\n  - netbox.netbox\n  - ansible.netcommon\n  - ansible.utils\n  - arista.eos\n  - nokia.srlinux\n' > requirements.yml
printf 'profile: min\n' > .ansible-lint
cat > Makefile <<'EOF'
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
> $(VENV)/ansible-galaxy collection install -q -r requirements.yml
lint:
> $(VENV)/yamllint . && $(VENV)/ansible-lint
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/ansible-playbook render.yml
push:
> $(VENV)/ansible-playbook push.yml
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
EOF
```

</When>

```bash
cat > tests/test_lab.py <<'EOF'
import ipaddress
import time

import pytest
import yaml
from scrapli import Scrapli

NODES = yaml.safe_load(open("topology.clab.yml"))["topology"]["nodes"]
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
PEERS = {"spine1": 2, "leaf1": 1, "leaf2": 1}
EOF
```

<When is="NOS" equals="srlinux">

```bash
cat >> tests/test_lab.py <<'EOF'
PLATFORM, AUTH = "nokia_srl", ("admin", "NokiaSrl1!")
BGP = ("info from state network-instance default protocols bgp neighbor * session-state", "session-state established")
PING = "ping -c 3 {dst} -I {src} network-instance default"
EOF
```

</When>

<When is="NOS" equals="ceos">

```bash
cat >> tests/test_lab.py <<'EOF'
PLATFORM, AUTH = "arista_eos", ("admin", "admin")
BGP = ("show ip bgp summary", "Estab")
PING = "ping {dst} source {src} repeat 3"
EOF
```

</When>

```bash
cat >> tests/test_lab.py <<'EOF'


def run(node, command):
    with Scrapli(host=NODES[node]["mgmt-ipv4"], platform=PLATFORM, auth_username=AUTH[0],
                 auth_password=AUTH[1], auth_strict_key=False) as conn:
        return conn.send_command(command).result


def eventually(check, tries=12, wait=5):
    for _ in range(tries):
        if check():
            return True
        time.sleep(wait)
    return False


@pytest.mark.parametrize("node", list(NODES))
def test_bgp_established(node):
    assert eventually(lambda: run(node, BGP[0]).count(BGP[1]) >= PEERS[node])


def test_loopback_ping():
    assert "3 received" in run("leaf1", PING.format(dst=LOOPBACKS[2], src=LOOPBACKS[1]))
EOF
```

<When is="CI" equals="github">

```bash
mkdir -p .github/workflows
cat > .github/workflows/lab.yml <<'EOF'
name: lab
on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * *"
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: make deps lint
  lab:
    needs: lint
    runs-on: [self-hosted, ${RUNNER_LABEL}]
    timeout-minutes: 30
    concurrency: { group: lab-${LAB_NAME}, cancel-in-progress: false }
    env: { NETBOX_TOKEN: "${{ secrets.NETBOX_TOKEN }}" }
    steps:
      - uses: actions/checkout@v4
      - run: make deps deploy
      - run: make render push
      - run: make test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: "lab-${{ github.sha }}", path: "configs/\nreport.xml" }
      - if: always()
        run: make destroy
EOF
```

</When>

<When is="CI" equals="gitlab">

```bash
cat > .gitlab-ci.yml <<'EOF'
stages: [lint, lab]
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "schedule"
lint:
  stage: lint
  image: python:3.12-slim
  before_script: [apt-get update -qq && apt-get install -y -qq make git]
  script: [make deps lint]
lab:
  stage: lab
  tags: [${RUNNER_LABEL}]
  timeout: 30m
  resource_group: lab-${LAB_NAME}
  script:
    - make deps deploy
    - make render push
    - make test
  after_script: [make destroy]
  artifacts:
    when: always
    paths: [configs/]
    reports: { junit: report.xml }
EOF
```

</When>

```bash
make deps lint deploy render push test; make destroy
git add -A && git -c user.name=lab -c user.email=lab@localhost commit -qm "ci: lint, lab and tests" || true
git remote get-url origin >/dev/null 2>&1 || git remote add origin ${REPO_URL}
git branch -M main && git push -u origin main
```

<Warn>Le job lab tourne sur la machine de lab avec des droits sudo sur containerlab. Cette machine seulement, cette commande seulement : garde la règle sudoers limitée à `containerlab`, et n'enregistre jamais ce runner sur un dépôt public, où la pull request de n'importe qui pourrait y exécuter du code.</Warn>

</Run>

## Avant de commencer

<Guided>Le dépôt des pages précédentes, un projet vide créé sur <When is="CI" equals="github">GitHub</When><When is="CI" equals="gitlab">GitLab</When> à <V name="REPO_URL" /> (privé), et les droits d'admin dessus pour enregistrer un runner, ajouter un secret et protéger une branche. La machine de lab a besoin de HTTPS sortant vers la forge ; rien en entrée. Toutes les commandes tournent sur la machine de lab, dans <V name="LAB_DIR" />.</Guided>

```bash
cd ${LAB_DIR}
git remote add origin ${REPO_URL}
git branch -M main
git push -u origin main
```

<Check cmd="git -C ${LAB_DIR} remote get-url origin" expect="${REPO_URL}" />

<Deep>Pourquoi pousser le dépôt avant que le pipeline existe ? Parce que l'enregistrement du runner se fait par projet et que le secret vit dans le projet : les deux ont besoin qu'il existe. Pousser un dépôt sans jeton ne risque rien ; le seul secret de cette série est `NETBOX_TOKEN`, qui n'a jamais été dans un fichier. Vérifie quand même avec `git grep -i token` avant le premier push.</Deep>

## L'arborescence du dépôt

<Guided>Le pipeline doit lancer les mêmes commandes que toi à la main, sinon il teste autre chose. Un `Makefile` les nomme une fois : `deps`, `lint`, `deploy`, `render`, `push`, `test`, `destroy`. Le fichier de pipeline se lit alors comme une liste de cibles, et un collègue lit le Makefile avant le pipeline.</Guided>

```text
${LAB_DIR}/
├── topology.clab.yml        # page 1
├── sot/seed.py              # page 2
├── templates/, configs/     # page 3
├── render.py · push.py      # page 3 (ou render.yml · push.yml, inventory/, group_vars/)
├── tests/test_lab.py        # cette page
├── Makefile · requirements.txt · .yamllint
└── .github/workflows/lab.yml  ou  .gitlab-ci.yml
```

<When is="AUTOMATION" equals="nornir">

<Tabs group="layout">

<Tab label="Makefile">

```makefile title="${LAB_DIR}/Makefile"
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
lint:
> $(VENV)/yamllint . && $(VENV)/ruff check .
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/python render.py
push:
> $(VENV)/python push.py --commit
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
```

</Tab>

<Tab label="requirements.txt">

```text title="${LAB_DIR}/requirements.txt"
nornir
nornir-netbox
nornir-scrapli
nornir-jinja2
nornir-utils
scrapli-community
pynetbox
pytest
pyyaml
yamllint
ruff
```

</Tab>

<Tab label="ruff.toml">

```toml title="${LAB_DIR}/ruff.toml"
line-length = 160
[lint]
ignore = ["E401", "E702", "E731"]
```

</Tab>

</Tabs>

</When>

<When is="AUTOMATION" equals="ansible">

<Tabs group="layout">

<Tab label="Makefile">

```makefile title="${LAB_DIR}/Makefile"
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
> $(VENV)/ansible-galaxy collection install -q -r requirements.yml
lint:
> $(VENV)/yamllint . && $(VENV)/ansible-lint
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/ansible-playbook render.yml
push:
> $(VENV)/ansible-playbook push.yml
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
```

</Tab>

<Tab label="requirements">

```text title="${LAB_DIR}/requirements.txt"
ansible
pynetbox
netaddr
scrapli
scrapli-community
pytest
pyyaml
yamllint
ansible-lint
```

```yaml title="${LAB_DIR}/requirements.yml"
collections:
  - netbox.netbox
  - ansible.netcommon
  - ansible.utils
  - arista.eos
  - nokia.srlinux
```

</Tab>

<Tab label=".ansible-lint">

```yaml title="${LAB_DIR}/.ansible-lint"
profile: min
```

</Tab>

</Tabs>

</When>

<Note>`.RECIPEPREFIX = >` permet de commencer les recettes par `>` au lieu d'une tabulation, ce qui survit au copier-coller et aux pages web ; il faut GNU make 4.0 ou plus, ce que toute distribution actuelle a. `--reconfigure` au déploiement détruit d'abord un lab du même nom, donc un job qui a planté avant son `destroy` ne bloque pas le suivant.</Note>

<Deep>`configs/` est commité exprès : une pull request qui change un template ou une valeur NetBox montre le diff de configuration résultant dans la revue, ce qui est la chose la plus utile qu'un relecteur d'automatisation réseau puisse regarder. Le pipeline rend à nouveau et ferait échouer `test` si les fichiers commités mentaient. `report.xml` et `.venv/` ne sont pas commités ; ajoute-les au `.gitignore`.</Deep>

```bash
printf 'report.xml\n' >> .gitignore
```

## Linter en local

<Guided>Le lint est l'étape pas chère : quelques secondes, pas de lab, pas de NetBox. Il attrape le YAML avec une tabulation dedans et le Python avec un import inutilisé avant qu'un runner passe cinq minutes à démarrer trois switches pour rien. Lance-le en local jusqu'à ce qu'il soit vert, et garde-le vert.</Guided>

```yaml title="${LAB_DIR}/.yamllint"
extends: default
rules:
  line-length: { max: 200 }
  truthy: { check-keys: false }
  document-start: disable
  comments: { min-spaces-from-content: 1 }
```

```bash
make deps
make lint
```

<When is="AUTOMATION" equals="nornir">

<Deep>`ruff` est le linter Python rapide ; ses règles par défaut sont les erreurs de syntaxe, les noms non définis et les imports inutilisés, exactement la classe d'erreurs qui fait échouer un rendu à la dernière ligne. Trois règles de style sont relâchées dans `ruff.toml` parce que les scripts des pages précédentes sont volontairement compacts (`import a, b`, `a(); b()`, `slug = lambda …`) ; lance `ruff format` une fois et retire les exceptions si ça te gêne. `truthy: check-keys: false` est pour la clé `on:` de GitHub, que yamllint lit sinon comme un booléen.</Deep>

</When>

<When is="AUTOMATION" equals="ansible">

<Deep>`ansible-lint` avec le profil `min` vérifie que les playbooks se parsent et se chargent ; le profil `production` par défaut veut aussi des noms de modules pleinement qualifiés (`ansible.builtin.set_fact`), des plays nommés et quelques dizaines de règles de style. Commence avec `min`, lance `ansible-lint --profile production` à la main, corrige ce qu'il liste, et monte le profil dans `.ansible-lint` quand ça passe. `truthy: check-keys: false` est pour la clé `on:` des workflows GitHub, que yamllint lit sinon comme un booléen ; sans effet ici.</Deep>

</When>

<Check cmd="cd ${LAB_DIR} && make lint >/dev/null && echo lint OK" expect="lint OK" />

## Un runner sur la machine de lab

<Guided>L'étape lab ne peut pas tourner sur les runners partagés de la forge : il lui faut Docker avec root, les images d'OS réseau, et un chemin vers NetBox. Donc un runner est installé sur la machine de lab elle-même, avec le label <V name="RUNNER_LABEL" />, et seuls les jobs qui demandent ce label y atterrissent. Il doit pouvoir lancer containerlab avec sudo, et rien d'autre avec sudo.</Guided>

```bash
echo "$USER ALL=(ALL) NOPASSWD: /usr/bin/containerlab" | sudo tee /etc/sudoers.d/containerlab
sudo chmod 440 /etc/sudoers.d/containerlab
```

<When is="CI" equals="github">

Dans le dépôt : **Settings → Actions → Runners → New self-hosted runner**, Linux x64. Copie les commandes de téléchargement et de configuration de cette page (elles portent un jeton à usage unique), et ajoute le label :

```bash
mkdir -p ~/actions-runner && cd ~/actions-runner
# curl … | tar xzf …   ← les deux lignes de l'interface, avec la version du moment
./config.sh --url ${REPO_URL} --token <jeton à usage unique de l'interface> --labels ${RUNNER_LABEL} --unattended
sudo ./svc.sh install && sudo ./svc.sh start
```

<Note>Le jeton de la page du runner expire dans l'heure ; si `config.sh` le refuse, recharge la page. `--url` prend l'URL du dépôt sans `.git`. Le runner apparaît en *Idle* dans la liste une fois le service démarré.</Note>

<Deep>Le runner est un service qui interroge GitHub en HTTPS et exécute les jobs sous l'utilisateur qui l'a installé : ton utilisateur de lab, membre du groupe `docker`, avec la règle sudoers ci-dessus. Il récupère le dépôt sous `~/actions-runner/_work/`, donc `topology.clab.yml` et le dossier du lab vivent là pendant un job, pas dans <V name="LAB_DIR" />. Un runner au niveau du dépôt est le plus simple ; un runner au niveau de l'organisation avec un groupe de runners permettrait à plusieurs dépôts de lab de partager la machine. Jamais sur un dépôt public : les pull requests venues de forks peuvent exécuter du code arbitraire sur ta machine.</Deep>

<Check cmd="systemctl list-units 'actions.runner.*' --no-legend | grep -c running" expect="1" />

</When>

<When is="CI" equals="gitlab">

Dans le projet : **Settings → CI/CD → Runners → New project runner**, tag <V name="RUNNER_LABEL" />, puis sur la machine de lab :

```bash
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install -y gitlab-runner
sudo gitlab-runner register --non-interactive --url https://gitlab.com --token <glrt-… de l'interface> \
  --executor shell --description "lab ${LAB_NAME}"
sudo usermod -aG docker gitlab-runner
echo "gitlab-runner ALL=(ALL) NOPASSWD: /usr/bin/containerlab" | sudo tee /etc/sudoers.d/containerlab-runner
```

<Note>Depuis GitLab 16 les tags se définissent dans l'interface à la création du runner, et `register` prend le jeton d'authentification `glrt-` ; `--tag-list` en ligne de commande est ignoré avec ces jetons. GitLab auto-hébergé : remplace l'URL. Le runner affiche un point vert dans la liste une fois enregistré.</Note>

<Deep>L'exécuteur `shell` lance le job sous l'utilisateur `gitlab-runner` directement sur la machine, ce dont containerlab a besoin ; l'exécuteur `docker` mettrait le job dans un conteneur, et containerlab dans un conteneur a besoin du socket Docker de l'hôte et de privilèges, faisable mais c'est une page à part. Le dépôt est récupéré sous `/home/gitlab-runner/builds/`, donc le lab tourne là pendant un job, pas dans <V name="LAB_DIR" />. `resource_group` dans le fichier de pipeline sérialise les jobs lab : un lab à la fois sur la machine.</Deep>

<Check cmd="systemctl is-active gitlab-runner" expect="active" />

</When>

## Les tests

<Guided>Les tests disent ce que « ça marche » veut dire pour ce lab : chaque nœud a ses sessions BGP établies, et la loopback de leaf1 atteint celle de leaf2. pytest les exécute, scrapli interroge les nœuds, et BGP a jusqu'à une minute pour converger après le push. Les IP de management viennent du fichier de topologie, donc un nœud renommé ou ajouté est testé sans toucher aux tests.</Guided>

<Annotated>

```python title="${LAB_DIR}/tests/test_lab.py" {8,10-11,20,28-30,33-34}
import ipaddress
import time

import pytest
import yaml
from scrapli import Scrapli

NODES = yaml.safe_load(open("topology.clab.yml"))["topology"]["nodes"]   # (1)
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
PEERS = {"spine1": 2, "leaf1": 1, "leaf2": 1}                            # (2)
PLATFORM, AUTH, BGP, PING = …                                             # (3)


def run(node, command):
    with Scrapli(host=NODES[node]["mgmt-ipv4"], platform=PLATFORM, auth_username=AUTH[0],
                 auth_password=AUTH[1], auth_strict_key=False) as conn:
        return conn.send_command(command).result


def eventually(check, tries=12, wait=5):                                  # (4)
    for _ in range(tries):
        if check():
            return True
        time.sleep(wait)
    return False


@pytest.mark.parametrize("node", list(NODES))                             # (5)
def test_bgp_established(node):
    assert eventually(lambda: run(node, BGP[0]).count(BGP[1]) >= PEERS[node])


def test_loopback_ping():                                                 # (6)
    assert "3 received" in run("leaf1", PING.format(dst=LOOPBACKS[2], src=LOOPBACKS[1]))
```

1. Le même fichier que containerlab déploie : noms des nœuds et IP de management fixées. Une seule source pour la topologie, même dans les tests.
2. Ce que « établi » veut dire par nœud : le spine a deux voisins, chaque leaf un. Le seul endroit où les tests connaissent la forme de la fabric.
3. Le bloc plateforme, par OS réseau, montré plus bas : le driver scrapli, les identifiants, la commande qui montre l'état BGP et la chaîne à compter, la commande de ping.
4. Réessayer jusqu'à une minute : le push rend la main avant que BGP converge, et un test qui échoue sur un délai est un test auquel personne ne fait confiance.
5. Un test par nœud, nommé d'après lui dans le rapport : `test_bgp_established[leaf2]` en échec te dit où regarder.
6. Ping de la loopback de leaf1 vers celle de leaf2, à travers le spine : les deux loopbacks annoncées, les deux /31 montés, en une ligne.

</Annotated>

<When is="NOS" equals="srlinux">

```python
PLATFORM, AUTH = "nokia_srl", ("admin", "NokiaSrl1!")
BGP = ("info from state network-instance default protocols bgp neighbor * session-state", "session-state established")
PING = "ping -c 3 {dst} -I {src} network-instance default"
```

</When>

<When is="NOS" equals="ceos">

```python
PLATFORM, AUTH = "arista_eos", ("admin", "admin")
BGP = ("show ip bgp summary", "Estab")
PING = "ping {dst} source {src} repeat 3"
```

</When>

```bash
make deploy render push
make test
```

<Deep>Les tests grattent la sortie CLI, ce qui est le minimum honnête : un `count()` sur une chaîne d'état. L'étape suivante, c'est l'état structuré : SR Linux répond en gNMI et JSON-RPC avec les mêmes chemins YANG que la commande `info from state`, EOS répond en eAPI avec du JSON pour n'importe quel `show`, et le `textfsm_parse_output()` de scrapli transforme les tableaux en dictionnaires. Un test affirme alors `state == "established"` par voisin plutôt que de compter des mots. Ce que les tests ne vérifient volontairement pas : tout ce que la source de vérité ne dit pas. Si une leaf gagne une troisième interface dans la topologie mais pas dans NetBox, aucun test n'échoue ; le modèle est la spec.</Deep>

<Check cmd={"cd ${LAB_DIR} && .venv/bin/pytest -q tests 2>/dev/null | tail -1 | grep -o '[0-9]* passed'"} expect="4 passed" />

<Details summary="Si le test BGP expire">
Relance `make test` : s'il passe, la fabric convergeait encore, et un `tries=24` est la correction. S'il échoue encore, l'état sur le nœud est ce qu'il est : <When is="NOS" equals="srlinux">`docker exec clab-${LAB_NAME}-spine1 sr_cli 'show network-instance default protocols bgp neighbor'`</When><When is="NOS" equals="ceos">`docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'show ip bgp summary'`</When>, puis la boîte de dépannage de la page précédente. Un test qui n'arrive pas à se connecter du tout (`ScrapliAuthenticationFailed`, timeouts), c'est une IP de management qui ne correspond pas à la topologie, ou un nœud qui démarre encore : `make deploy` rend la main avant que <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> accepte SSH.
</Details>

## Le pipeline

<Guided>Deux jobs. *lint* tourne sur le runner hébergé de la forge en quelques secondes ; *lab* tourne sur le tien seulement si lint est passé, et finit toujours par `destroy`, que les tests aient réussi ou non. Il tourne à chaque pull request, à chaque push sur `main`, et une fois par nuit. Le jeton NetBox est un secret du projet, injecté comme variable d'environnement.</Guided>

<When is="CI" equals="github">

Dans le dépôt : **Settings → Secrets and variables → Actions → New repository secret**, nom `NETBOX_TOKEN`.

<Annotated>

```yaml title="${LAB_DIR}/.github/workflows/lab.yml" {3-7,17-18,20-21,29-31}
name: lab
on:
  pull_request:                                            # (1)
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * *"
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: make deps lint
  lab:
    needs: lint                                            # (2)
    runs-on: [self-hosted, ${RUNNER_LABEL}]                # (3)
    timeout-minutes: 30
    concurrency: { group: lab-${LAB_NAME}, cancel-in-progress: false }   # (4)
    env: { NETBOX_TOKEN: "${{ secrets.NETBOX_TOKEN }}" }   # (5)
    steps:
      - uses: actions/checkout@v4
      - run: make deps deploy
      - run: make render push
      - run: make test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: "lab-${{ github.sha }}", path: "configs/\nreport.xml" }   # (6)
      - if: always()
        run: make destroy                                  # (7)
```

1. Trois déclencheurs : chaque pull request (la revue), chaque push sur `main` (la trace), et 03 h 17 chaque nuit (le contrôle de dérive contre le vrai NetBox, voir plus bas).
2. `lab` attend `lint` et ne démarre pas s'il a échoué : pas de temps de runner pour un fichier qui ne se parse pas.
3. Les deux labels doivent correspondre : `self-hosted` et le tien. Un job qui demande un label qu'aucun runner n'a reste en attente jusqu'à son timeout.
4. Un job lab à la fois sur la machine, les autres attendent : deux topologies du même nom ne peuvent pas coexister. `cancel-in-progress: false` laisse un job en cours finir son `destroy`.
5. Le secret, masqué dans les logs, présent seulement dans ce job. Le même nom de variable que les scripts lisent aux pages précédentes.
6. Les configs rendues et le rapport JUnit, conservés que les tests aient réussi ou non : un passage rouge est celui dont tu veux les artefacts.
7. `if: always()` est tout l'intérêt de l'étape : un test en échec ne doit pas laisser trois conteneurs manger la mémoire du runner jusqu'à demain.

</Annotated>

```bash
git add -A && git commit -m "ci: lint, lab and tests" && git push
```

<Note>La clé `on:` et les expressions `${{ … }}` sont la syntaxe de GitHub ; les valeurs `${RUNNER_LABEL}` et `${LAB_NAME}` sont les tiennes, remplies par cette page. Confirme les versions des actions (`checkout@v4`, `upload-artifact@v4`) sur la marketplace, elles bougent.</Note>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; print(sorted(yaml.safe_load(open(\".github/workflows/lab.yml\"))[\"jobs\"]))'"} expect="['lab', 'lint']" />

<Check cmd={"gh run list --workflow lab.yml -L 1 --json conclusion -q '.[0].conclusion'"} expect="success" />

</When>

<When is="CI" equals="gitlab">

Dans le projet : **Settings → CI/CD → Variables → Add variable**, clé `NETBOX_TOKEN`, *masked*, *protected*.

<Annotated>

```yaml title="${LAB_DIR}/.gitlab-ci.yml" {3-6,14,16,21,23-25}
stages: [lint, lab]
workflow:
  rules:                                                   # (1)
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_PIPELINE_SOURCE == "schedule"
lint:
  stage: lint
  image: python:3.12-slim
  before_script: [apt-get update -qq && apt-get install -y -qq make git]
  script: [make deps lint]
lab:
  stage: lab                                               # (2)
  tags: [${RUNNER_LABEL}]                                  # (3)
  timeout: 30m
  resource_group: lab-${LAB_NAME}                          # (4)
  script:
    - make deps deploy
    - make render push
    - make test
  after_script: [make destroy]                             # (5)
  artifacts:
    when: always                                           # (6)
    paths: [configs/]
    reports: { junit: report.xml }
```

1. Trois déclencheurs : les merge requests (la revue), les pushes sur `main` (la trace), et une planification que tu crées dans **Build → Pipeline schedules** pour le contrôle de dérive nocturne. Tout le reste, les branches sans MR par exemple, ne lance rien.
2. Les stages s'enchaînent dans l'ordre ; `lab` ne démarre pas si `lint` a échoué.
3. Seul un runner portant ce tag prend le job. Un tag qu'aucun runner n'a laisse le job *stuck*.
4. Un job lab à la fois sur la machine ; les autres font la queue. Deux topologies du même nom ne peuvent pas coexister.
5. `after_script` tourne que `script` ait réussi ou non : le lab est toujours démonté.
6. Les configs rendues et le rapport JUnit, conservés aussi en cas d'échec, et le rapport s'affiche comme onglet de tests sur la merge request.

</Annotated>

```bash
git add -A && git commit -m "ci: lint, lab and tests" && git push
```

<Note>`$CI_PIPELINE_SOURCE` et `$CI_COMMIT_BRANCH` sont des variables prédéfinies de GitLab ; `${RUNNER_LABEL}` et `${LAB_NAME}` sont les tiennes, remplies par cette page. `make` et `git` ne sont pas dans `python:3.12-slim`, d'où le `before_script` ; une petite image à toi économiserait les vingt secondes.</Note>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; print(yaml.safe_load(open(\".gitlab-ci.yml\"))[\"stages\"])'"} expect="['lint', 'lab']" />

<Check cmd="glab ci list --per-page 1 | grep -o success" expect="success" />

</When>

<Deep>Trois raffinements une fois le premier passage vert. **Ne tourner que sur les chemins qui comptent** : <When is="CI" equals="github">`on.pull_request.paths` avec `topology.clab.yml`, `templates/**`, `tests/**`, les scripts et le fichier de workflow</When><When is="CI" equals="gitlab">`rules: changes:` sur le job `lab` avec la même liste</When>, pour qu'une retouche du README ne déploie pas un lab ; garde le lint sur tout. **Mettre les images en cache** : le runner est la machine de lab, Docker a déjà l'image <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> de la première page et un déploiement ne tire rien ; ça, et la licence cEOS qui interdit de mettre l'image dans un registre, est la deuxième raison d'un runner auto-hébergé après Docker lui-même. **Le passage nocturne** est celui qui compte le plus : rien n'a changé dans le dépôt, donc s'il passe au rouge, c'est NetBox qui a changé, ou le tag de l'image qui a bougé, ou la machine. C'est le détecteur de dérive entre la source de vérité et le lab, et la raison pour laquelle le jeton en CI est en lecture seule.</Deep>

## Protéger main

<Guided>Le pipeline prouve ; la protection de branche impose. À partir de maintenant `main` ne bouge que par pull request, et une pull request ne fusionne que quand *lint* et *lab* sont verts. Personne, toi compris, ne pousse directement sur `main` : un changement qui a sauté le lab est un changement que personne n'a testé.</Guided>

<When is="CI" equals="github">

**Settings → Branches → Add branch ruleset** (ou *Add classic branch protection rule*) sur `main` : *Require a pull request before merging*, *Require status checks to pass* avec `lint` et `lab` comme checks requis, *Do not allow bypassing*. En ligne de commande :

```bash
gh api -X PUT repos/{owner}/{repo}/branches/main/protection --input - <<'EOF'
{"required_status_checks": {"strict": true, "contexts": ["lint", "lab"]},
 "enforce_admins": true, "required_pull_request_reviews": {"required_approving_review_count": 0},
 "restrictions": null}
EOF
```

<Note>Les status checks n'apparaissent dans le sélecteur qu'après avoir tourné une fois, c'est pour ça que le pipeline est venu d'abord. `enforce_admins: true` t'applique la règle à toi aussi ; c'est le but.</Note>

<Check cmd={"gh api repos/{owner}/{repo}/branches/main/protection -q '.required_status_checks.contexts | sort | join(\",\")'"} expect="lab,lint" />

</When>

<When is="CI" equals="gitlab">

**Settings → Repository → Protected branches** : `main`, *Allowed to push and merge* : No one, *Allowed to merge* : Maintainers. Puis **Settings → Merge requests** : *Pipelines must succeed* et *Enable merged results pipelines*. En ligne de commande :

```bash
glab api -X POST projects/:id/protected_branches -f name=main -f push_access_level=0 -f merge_access_level=40
glab api -X PUT projects/:id -f only_allow_merge_if_pipeline_succeeds=true
```

<Note>`:id` est résolu par `glab` depuis le dépôt courant. La variable `NETBOX_TOKEN` a été marquée *protected*, donc elle n'est disponible qu'aux pipelines des branches protégées et des merge requests du projet lui-même, pas des forks.</Note>

<Check cmd={"glab api projects/:id | grep -o '\"only_allow_merge_if_pipeline_succeeds\":true'"} expect='"only_allow_merge_if_pipeline_succeeds":true' />

</When>

<Deep>La fusion est maintenant la porte du déploiement, ce qui change la façon de travailler de l'équipe : un changement de template, de test ou de topologie, c'est une branche, une pull request, un passage du pipeline sur un lab neuf, une revue avec le diff rendu sous les yeux du relecteur, puis une fusion. NetBox lui-même est hors de cette boucle : une valeur changée là-bas est prise en compte au passage suivant, c'est à ça que sert le nocturne. L'étape d'après, au-delà de cette série, c'est le même pipeline contre du matériel de préproduction, avec le push derrière une approbation manuelle ; l'étape lab devient alors ce qui doit être vert avant même qu'on demande à un humain.</Deep>

## Terminé

Chaque changement du lab est maintenant prouvé avant d'atterrir : lint sur le runner hébergé, un lab neuf sur <V name="RUNNER_LABEL" /> avec la configuration rendue depuis <V name="NETBOX_URL" />, quatre tests, des artefacts, le démontage, et une branche `main` qui n'accepte que ce qui est passé. Le passage nocturne surveille la dérive de la source de vérité. D'une machine Linux et d'un fichier YAML à la première page, voilà un réseau que tu reconstruis, testes et relis comme du logiciel, et c'est ce que NetDevOps veut dire.

```bash
git checkout -b try-it && sed -i 's/tries=12/tries=24/' tests/test_lab.py && git commit -am "tests: wait longer for BGP" && git push -u origin try-it
```

Ouvre la pull request et regarde le runner faire en cinq minutes ce que les trois premières pages ont fait à la main.
````

````yaml title="content/netdevops-lab/validate-in-ci/diagram.yaml"
# The gist: a push starts a pipeline; the cheap job runs on the forge, the lab job
# on a runner installed on the lab machine, which deploys a fresh topology, renders
# and pushes from NetBox, tests, and destroys. Quick: five boxes. Guided adds the
# lint job, NetBox and the artifacts. Deep adds labels, secrets, files and timers.
title: { en: "From a pull request to a proven change", fr: "D'une pull request à un changement prouvé" }
caption:
  en: "A push opens a pipeline: lint on the forge's runner, then the lab job on the runner labelled ${RUNNER_LABEL} on the lab machine, which deploys a fresh topology, renders and pushes from ${NETBOX_URL}, runs the tests, publishes the artifacts and tears the lab down. main only merges what passed."
  fr: "Un push ouvre un pipeline : lint sur le runner de la forge, puis le job lab sur le runner labellisé ${RUNNER_LABEL} de la machine de lab, qui déploie une topologie neuve, rend et pousse depuis ${NETBOX_URL}, lance les tests, publie les artefacts et démonte le lab. main ne fusionne que ce qui est passé."

groups:
  - id: forge
    label: { en: "Your forge", fr: "Ta forge" }
    desc:
      en: "GitHub or GitLab: the repository at ${REPO_URL}, the hosted runner for lint, the secret, the branch protection."
      fr: "GitHub ou GitLab : le dépôt sur ${REPO_URL}, le runner hébergé pour le lint, le secret, la protection de branche."
  - id: host
    label: { en: "Lab machine", fr: "Machine de lab" }
    desc:
      en: "The same host as on the first page, now also running a CI runner labelled ${RUNNER_LABEL}. Docker, containerlab and the images are already there."
      fr: "Le même hôte qu'à la première page, qui fait maintenant aussi tourner un runner CI labellisé ${RUNNER_LABEL}. Docker, containerlab et les images sont déjà là."

nodes:
  - id: dev
    kind: user
    label: { en: "You", fr: "Toi" }
    sub: "git push · pull request"
    desc:
      en: "A change is a branch and a pull request. Nobody pushes to main directly, you included."
      fr: "Un changement, c'est une branche et une pull request. Personne ne pousse sur main directement, toi compris."
  - id: repo
    kind: file
    label: { en: "Repository", fr: "Dépôt" }
    sub: "${REPO_URL}"
    in: forge
    desc:
      en: "Topology, seed script, templates, configs/, tests, Makefile and the pipeline file. main is protected."
      fr: "Topologie, script de seed, templates, configs/, tests, Makefile et le fichier de pipeline. main est protégée."
    deep:
      sub: "${REPO_URL} · main protected · required checks lint + lab"
  - id: lint
    kind: service
    label: { en: "lint", fr: "lint" }
    sub: "yamllint · ruff / ansible-lint"
    in: forge
    level: guided
    desc:
      en: "Seconds on the forge's hosted runner: YAML and Python or playbooks must parse before a lab is spent on them."
      fr: "Quelques secondes sur le runner hébergé de la forge : le YAML et le Python ou les playbooks doivent se parser avant qu'un lab soit dépensé pour eux."
    deep:
      sub: "ubuntu-latest / python:3.12-slim · make deps lint"
  - id: runner
    kind: service
    label: { en: "lab job", fr: "job lab" }
    sub: "runner ${RUNNER_LABEL}"
    in: host
    focus: true
    desc:
      en: "Self-hosted: deploy, render, push, test, always destroy. One at a time, 30 minutes at most, NETBOX_TOKEN from the project's secrets."
      fr: "Auto-hébergé : deploy, render, push, test, toujours destroy. Un à la fois, 30 minutes au plus, NETBOX_TOKEN venu des secrets du projet."
    deep:
      sub: "self-hosted · label ${RUNNER_LABEL} · concurrency/resource_group · sudo containerlab only"
  - id: clab
    kind: net
    label: { en: "Fresh lab", fr: "Lab neuf" }
    sub: "containerlab deploy --reconfigure"
    in: host
    desc:
      en: "The topology from the first page, deployed from scratch on every run, then rendered and pushed from NetBox exactly as on the third page."
      fr: "La topologie de la première page, déployée de zéro à chaque passage, puis rendue et poussée depuis NetBox exactement comme à la troisième page."
    deep:
      sub: "clab-${LAB_NAME}-* · ${MGMT_SUBNET} · make deploy render push"
  - id: tests
    kind: service
    label: { en: "pytest", fr: "pytest" }
    sub: "BGP established · loopback ping"
    in: host
    desc:
      en: "scrapli asks each node for its BGP state and leaf1 for a ping to leaf2's loopback; BGP gets a minute to converge."
      fr: "scrapli demande à chaque nœud son état BGP et à leaf1 un ping vers la loopback de leaf2 ; BGP a une minute pour converger."
    deep:
      sub: "tests/test_lab.py · 4 tests · report.xml (JUnit)"
  - id: netbox
    kind: store
    label: { en: "NetBox", fr: "NetBox" }
    sub: "${NETBOX_URL}"
    level: guided
    desc:
      en: "Read by the render step with the token from the secret. A nightly run with no code change is the drift detector between NetBox and the lab."
      fr: "Lu par l'étape de rendu avec le jeton venu du secret. Un passage nocturne sans changement de code est le détecteur de dérive entre NetBox et le lab."
  - id: artifacts
    kind: file
    label: { en: "Artifacts", fr: "Artefacts" }
    sub: "configs/ · report.xml"
    in: forge
    level: guided
    desc:
      en: "Kept whether the run passed or failed: the rendered configs and the test report, attached to the run and to the pull request."
      fr: "Conservés que le passage ait réussi ou non : les configs rendues et le rapport de tests, attachés au passage et à la pull request."

edges:
  - from: dev
    to: repo
    label: "push"
    deep: { label: "git push · pull request" }
    desc: { en: "The push opens the pull request and starts the pipeline.", fr: "Le push ouvre la pull request et lance le pipeline." }
  - from: repo
    to: runner
    label: "pipeline"
    max: quick
    desc: { en: "The forge sends the lab job to the runner on the lab machine.", fr: "La forge envoie le job lab au runner de la machine de lab." }
  - from: repo
    to: lint
    label: "pipeline"
    level: guided
    desc: { en: "The first job, on the forge's own runner.", fr: "Le premier job, sur le runner de la forge elle-même." }
  - from: lint
    to: runner
    label: "if green"
    level: guided
    deep: { label: "needs: lint · HTTPS long-poll" }
    desc: { en: "The lab job waits for lint and never starts on a broken file. The runner polls the forge over HTTPS; nothing inbound.", fr: "Le job lab attend le lint et ne démarre jamais sur un fichier cassé. Le runner interroge la forge en HTTPS ; rien en entrée." }
  - from: runner
    to: clab
    label: "deploy · push"
    deep: { label: "make deploy render push · sudo containerlab" }
    desc: { en: "A fresh topology, then the configuration rendered from NetBox pushed to it.", fr: "Une topologie neuve, puis la configuration rendue depuis NetBox poussée dessus." }
  - from: netbox
    to: runner
    label: "REST"
    dashed: true
    level: guided
    deep: { label: "NETBOX_TOKEN secret · read only" }
    desc: { en: "The render step reads devices, interfaces, addresses and contexts with the token from the secret.", fr: "L'étape de rendu lit équipements, interfaces, adresses et contextes avec le jeton venu du secret." }
  - from: clab
    to: tests
    label: "SSH"
    deep: { label: "scrapli · SSH TCP 22 · mgmt IPs from topology" }
    desc: { en: "The tests connect to each node's management IP, read from the topology file.", fr: "Les tests se connectent à l'IP de management de chaque nœud, lue dans le fichier de topologie." }
  - from: tests
    to: artifacts
    label: "report"
    level: guided
    deep: { label: "configs/ + report.xml · when: always" }
    desc: { en: "Uploaded whether the tests passed or not; a red run is the one whose artifacts you want.", fr: "Envoyés que les tests aient réussi ou non ; un passage rouge est celui dont tu veux les artefacts." }
  - from: tests
    to: repo
    label: "green → merge"
    dashed: true
    deep: { label: "required status checks · destroy always runs" }
    desc: { en: "main only accepts a pull request whose lint and lab checks passed. The lab is destroyed either way.", fr: "main n'accepte qu'une pull request dont les checks lint et lab sont passés. Le lab est démonté dans tous les cas." }
````

---

# Writing a page for Runfold

A self-contained brief. Hand it to a person or a model with a subject ("set up a VPS to host a Next.js site behind nginx with TLS") and you get back the files a page is made of. Nothing else is needed to write; the site's test suite then checks the result.

## 1. What the reader gets, and why it shapes the writing

A page is one tutorial. The reader fills in **their context once** — hostnames, ports, paths, and a few **choices** of stack (Debian or RHEL, nginx or Apache, TLS from Let's Encrypt or self-signed) — and every command on the page is rewritten with their values. Sections that do not apply to their choices disappear.

The reader also picks a **reading level**, once for the whole site:

| Level | EN / FR | What it shows |
|---|---|---|
| Run | Run / Automatique | Only the `<Run>` block: a script that does the whole page. "Too lazy to read? Run it." |
| Quick | Quick / Express | The plain paragraphs and the commands. "It worked? Fine." |
| Guided | Guided / Détaillé | Quick + the `<Guided>` explanations: why, and how, for someone who has never done it. |
| Deep | Deep / Exhaustif | Everything, plus `<Deep>`: mechanism, alternatives, gotchas, what happens under the hood. |

So a page is **written once, in layers**, not four times. Every paragraph you write belongs to a layer. Plain text is Quick; wrap the rest.

Each page also carries a **gist**: a small diagram at the top (boxes, containers, arrows, no coordinates) that follows the reader's choices and level. And each page exists in **English and French**, each with its own file, same structure.

## 2. The files

```
content/<series>/series.yaml                 what all pages of the series share (title, order, shared variables/choices)
content/<series>/<page>/tuto.yaml            this page's contract: metadata, its own variables/choices
content/<series>/<page>/page-en.mdx          the prose, English
content/<series>/<page>/page-fr.mdx          the prose, French (same heading skeleton)
content/<series>/<page>/diagram.yaml         the gist (optional but expected)
```

Slugs are kebab-case (`secure-ssh-on-a-fresh-server`). Variable and choice keys are `UPPER_SNAKE_CASE`.

A page **inherits** the series' groups, variables and choices and may add its own; it must **never redeclare** a key the series declares. Two different pages may declare the same key (the reader's value is shared across the series).

Deliver every file in full, each in its own fenced block with the path as title.

## 3. `series.yaml` (only when creating a series)

```yaml
title:   { en: NetBox, fr: NetBox }
summary:
  en: >-
    One or two sentences: what the series takes the reader from and to.
  fr: >-
    Une ou deux phrases : d'où part la série et où elle mène.
order: [install-from-scratch, configure-for-your-team]   # page slugs, reading order

groups:                        # sections of the "Your values" panel
  - id: host
    label: { en: Server, fr: Serveur }
    desc:  { en: Where it runs and how it is reached., fr: Où ça tourne et comment on l'atteint. }
  - id: auth
    label: { en: Directory, fr: Annuaire }
    when:  { flag: LDAP }      # the whole group disappears when the choice is off

vars:                          # same shape as in tuto.yaml, see below
  - key: NETBOX_HOST
    kind: hostname
    group: host
    default: netbox.example.com
    label:  { en: NetBox hostname, fr: Nom d'hôte de NetBox }
    hint:   { en: …, fr: … }
    impact: { en: …, fr: … }

choices:
  - key: OS
    type: select
    label: { en: Distribution, fr: Distribution }
    default: ubuntu
    options:
      - { value: ubuntu, label: { en: Ubuntu 24.04, fr: Ubuntu 24.04 } }
      - { value: rhel,   label: { en: RHEL 9 / Rocky / Alma, fr: RHEL 9 / Rocky / Alma } }
  - key: LDAP
    type: boolean
    label: { en: Authenticate against LDAP, fr: Authentifier via LDAP }
    default: false
```

## 4. `tuto.yaml`

```yaml
# Inherits from ../series.yaml: <list the inherited keys here as a comment>.
title:   { en: Secure SSH on a fresh server, fr: Sécuriser l'accès SSH d'un serveur neuf }
summary:
  en: >-
    Two or three lines. What the reader has at the end. Concrete.
  fr: >-
    Deux ou trois lignes. Ce que le lecteur a à la fin. Concret.
difficulty: beginner           # beginner | intermediate | advanced
tags: [ssh, linux, security]   # lowercase, used by search filters
authors: [thudal]
created: 2026-09-26            # YYYY-MM-DD
minutes: 10                    # hands-on time
validated: Debian 12 · Rocky 9 # upstream version / platform it was written for

groups:
  - id: access
    label: { en: Access, fr: Accès }

vars:
  - key: SSH_PORT
    kind: port                 # text | ip | cidr | port | hostname | domain | user | email | secret | path | url | sshkey
    group: access
    default: "1234"            # always a string; "" for none (secrets)
    label: { en: SSH port, fr: Port SSH }
    hint:                      # what it is, one or two sentences — shown in the ? tip
      en: The port SSH listens on after hardening. Anything but 22 removes most automated noise.
      fr: Le port sur lequel SSH écoutera après durcissement. Tout sauf 22 supprime l'essentiel du bruit automatisé.
    impact:                    # consequences of getting it wrong, where it is reused — also in the tip
      en: Reused by the firewall and fail2ban. Open it in the firewall before restarting SSH, or you lock yourself out.
      fr: Réutilisé par le pare-feu et fail2ban. Ouvre-le dans le pare-feu avant de redémarrer SSH, sinon tu te verrouilles dehors.
    when: { flag: FAIL2BAN }   # optional: only relevant when a choice holds

choices:
  - key: FAIL2BAN
    type: boolean
    label: { en: Ban brute-force attempts (fail2ban), fr: Bannir le brute force (fail2ban) }
    hint:  { en: Recommended on any server reachable from the internet., fr: Recommandé sur tout serveur joignable depuis internet. }
    default: true
```

Rules:
- `kind: secret` for passwords and tokens: masked in the panel, never printed in the print view, never carried by share links. `default: ""`.
- Every value a reader could reasonably change is a variable. Hard-code only true constants (a well-known port of a protocol, a package name).
- `hint` and `impact` are the **whole explanation** of a variable; the page prose should not repeat them.
- Anything the reader **must** provide (their public key, their token) is a variable with `required: true` and no example default (marked in the panel). A block that uses an empty or invalid value is marked red and cannot be copied. Never write an example that looks like code in a block (`ssh-ed25519 AAAA… user@host`, `YOUR.IP`, `<user>`): readers paste it as is (the test refuses these).
- `kind: sshkey` checks a whole OpenSSH public key line (`ssh-ed25519 AAAAC3…`).
- `${EDITOR}` is reserved: the app fills it with the reader's editor (vim by default, nano in the panel). Write `sudo ${EDITOR} /etc/x` whenever the reader edits a file by hand. Never declare it.
- Conditions (`when`, and `<When>` in prose): `{ flag: KEY }`, `{ notFlag: KEY }`, `{ is: KEY, equals: value }`, `{ is: KEY, oneOf: [a, b] }`. They may only reference choices.

## 5. `page-en.mdx` / `page-fr.mdx`

### Skeleton

```mdx
{/* First pass — to be validated against <official docs URL> before publishing. */}

One paragraph (Quick): what this page does, in what order, for whom.

<Run>

One sentence saying what the script does, on which machine, as which user, and what must be in place first.

```bash
#!/usr/bin/env bash
set -euo pipefail
# <Title> — ${MAIN_VAR}
… the whole page as a script, using ${VARS} …
```

</Run>

## Before you start

<Guided>What you need on hand: accounts, access, prerequisites, and where the commands run.</Guided>

<Check cmd="…" expect="…" />

## First step

Plain sentence saying what to do.

```bash
command with ${VARS}
```

<Guided>Why this step, what the command does, what to look at.</Guided>

<Deep>The mechanism, the alternative, the gotcha, the thing that bites in production.</Deep>

<Check cmd="…" expect="…" />

## Second step
…

## Done

What the reader now has. What the next page of the series does.
```

The **French file has the same headings, in the same order, the same count** (the test checks it). It is written in natural French with "tu" (tutoiement), not a word-for-word translation.

### The syntax

| Syntax | Meaning |
|---|---|
| `${SSH_PORT}` inside a code fence | replaced by the reader's value; the copy button copies the filled command |
| `<V name="SSH_PORT" />` | the same, inline in prose |
| plain markdown | visible from **Quick** |
| `<Guided>…</Guided>` | visible from **Guided** |
| `<Deep>…</Deep>` | visible at **Deep** only |
| `<Note>…</Note>` | an aside, from Guided |
| `<Warn>…</Warn>` | always visible: anything that can lock you out, lose data or cost money |
| `<When is="OS" equals="debian">…</When>` | conditional on a select choice; also `oneOf="a,b"` |
| `<When flag="FAIL2BAN">…</When>` / `<When notFlag="…">` | conditional on a boolean choice |
| `<Run>…</Run>` | the only thing shown at Run: a script or a single file that does the whole page |
| `<Check cmd="…" expect="…" />` | a verification with a checkbox, tracked per page. Also `id="…"` to name it. |
| `<Details summary="If it fails">…</Details>` | collapsible, closed by default |
| `<Tabs group="x"><Tab label="/etc/a.conf">…</Tab><Tab label="/etc/b.conf">…</Tab></Tabs>` | several files of one feature, side by side |
| ` ```ini title="/etc/x.conf" {1,3-5} ` | caption, highlighted lines |
| ` ```bash on="mac" ` · ` ```bash on="server" as="debian" ` · `as="${USERNAME}"` | where the block is typed and as whom: a coloured badge and edge, one colour per terminal |
| ` ```caddy file="/etc/caddy/Caddyfile" on="server" as="${USERNAME}" ` | **the whole content** of a file: shown with its path, copied as a ready `sudo tee … <<'EOF'` command (or as content only, for an editor). `sudo` is added outside /home and /tmp; force with `sudo` / `sudo="false"` |
| ` ```bash interactive ` | the command asks something (password, confirmation): badge, and it must be alone in its block |
| `$${X}` | a literal `${X}` (GitHub Actions `$${{ … }}`, JS template literals) |
| `<Annotated>` + `# (1)` markers at line ends + a numbered list after the fence | annotations: stripped at Quick, badges at Guided, inline at Deep |
| `## Heading` | a step: numbered automatically, listed in the outline. `###` for sub-steps. |

Details that matter:
- A `<Check>` is a real command whose expected output is **stable**: a version line, `active (running)`, an HTTP `200`. Two to six per page. `expect` may contain `${VARS}` but never a secret. If the command mixes `'` and `"`, write `cmd={"…"}`.
- Fences: ` ```bash ` for shell (a `$` prompt is drawn in front of what the reader types, not in front of comments or continuation lines), ` ```yaml `, ` ```python `, ` ```ini `, ` ```nginx `. One fence per command group; keep them short enough to read.
- Inside `<Run>`, the script must work with the variables and the choices: branch it with `<When>` blocks around separate fences when the stack differs. It runs unattended, so `set -euo pipefail`, no prompts, no `sudo` password expectations that the intro does not state.
- Do not put MDX components inside a fence, and do not put `\"` inside a component attribute (use `cmd={"…"}`).
- Blank line before and after every component, and around fences inside components.
- A component whose content spans several paragraphs or holds a list must be written as a block: the opening tag alone on its line, a blank line, the content, a blank line, the closing tag alone on its line. `<Guided>One line.</Guided>` is fine; `<Guided>Intro:\n\n1. item</Guided>` does not compile.
- New pages carry `status: draft` in `tuto.yaml` until their author has run them end to end.

Rules that come from a real run (the test suite checks the first four):
- **Where and as whom.** When a page uses more than one terminal (the laptop, the server as `debian`, the server as yourself), every block says so with `on=` / `as=`. A tired reader follows the badges, not the sentences.
- **One file, one block, complete.** A file is shown once, whole, with `file="…"`. Never "now add this line before `log {`". When a choice changes the file, `<When>` picks between complete versions of it. The Run script writes exactly the file the page shows.
- **Interactive commands alone.** `adduser`, `passwd`, `ssh-keygen` without `-N`, `ssh-copy-id`: alone in their block, marked `interactive`, or the rest of a paste lands in the password prompt.
- **No fake examples** in blocks (see `required` above).
- **A check proves the right thing.** A `<Check>` that tests an SSH login forces the method under test: `ssh -i KEY -o IdentitiesOnly=yes -o PasswordAuthentication=no -o BatchMode=yes …`. A check that passes thanks to a password before the step that forbids passwords is how people lock themselves out.
- **No unexplained jargon** in the Quick text: "the bare domain, `example.com`, nothing in front" rather than "the apex".

### Voice

Direct, concrete, an experienced engineer talking to a colleague. Short sentences. No marketing, no "simply", no "just". Say what a command does before showing it when it is not obvious. Say when a step is dangerous before the reader runs it (`<Warn>`). Name the file being edited. When a fact is from memory and must be confirmed, keep the common well-known form and add a `<Note>` saying where to confirm it.

Length: 200–400 lines per language file. Accuracy over volume.

## 6. `diagram.yaml` — the gist

```yaml
title:   { en: "One server, four pieces", fr: "Un serveur, quatre briques" }
caption:
  en: "One or two sentences read under the drawing."
  fr: "Une ou deux phrases lues sous le schéma."

groups:                                       # dashed containers
  - id: server
    label: { en: "Your server", fr: "Ton serveur" }
    desc:  { en: "…", fr: "…" }               # shown on hover

nodes:
  - id: users                                 # kebab-case
    kind: user                                # user | client | server | service | store | file | net | cloud (the icon)
    label: { en: "Your team", fr: "Ton équipe" }
    sub: "https://${NETBOX_HOST}"             # second line, may use ${VARS}
    desc:  { en: "What this piece does.", fr: "Ce que fait cette brique." }
  - id: nginx
    kind: net
    label: { en: "nginx", fr: "nginx" }
    sub: "443 → 8001"
    in: server                                # container
    when: { is: WEB, equals: nginx }          # follows the reader's choices
    desc:  { en: "…", fr: "…" }
    guided: { sub: ":443 → 127.0.0.1:8001" }  # overrides from Guided up
    deep:   { sub: "TLS ends here · :443 → 127.0.0.1:8001 · HTTP/1.1" }
  - id: firewall
    kind: net
    label: { en: "Firewall", fr: "Pare-feu" }
    in: server
    level: guided                             # appears from Guided up (level: deep for Deep only)
    desc:  { en: "…", fr: "…" }
  - id: netbox
    kind: server
    label: { en: "NetBox", fr: "NetBox" }
    in: server
    focus: true                               # the thing the page is about, drawn in the accent
    desc:  { en: "…", fr: "…" }

edges:
  - { from: users, to: nginx, label: "HTTPS", max: quick, desc: { en: "…", fr: "…" } }          # a simplification replaced from Guided
  - { from: users, to: firewall, label: "HTTPS · TCP 443", level: guided, deep: { label: "L7 HTTPS · L4 TCP 443 · L3 IP" }, desc: { en: "…", fr: "…" } }
  - { from: firewall, to: nginx, level: guided, desc: { en: "…", fr: "…" } }
  - { from: nginx, to: netbox, desc: { en: "…", fr: "…" } }
  - { from: netbox, to: ldap, label: "bind", dashed: true, when: { flag: LDAP }, desc: { en: "…", fr: "…" } }   # dashed = secondary relation
```

Rules:
- Solid arrows lay the boxes out left to right; dashed arrows are secondary and do not move anything. No coordinates ever.
- Quick shows 4–7 boxes; Guided adds the pieces a first-timer should know exist; Deep shows every layer (DNS, TLS, ports, sockets, units, log files).
- `desc` on every node, edge and group, both languages; it is what the reader gets on hover.
- Only reference variables and choices declared for the page (series + page).

## 7. Checklist before delivering

- [ ] Every user-facing string exists in `en` and `fr`.
- [ ] Every `${VAR}` and `<V name>` in the prose is declared (series or page); every `<When>` references a declared choice.
- [ ] No key redeclared that the series already declares.
- [ ] Same `##`/`###` headings, same order, same count, in both language files.
- [ ] A `<Run>` block that does the whole page with the variables.
- [ ] 2–6 `<Check>`s with stable expected output; no secret in `expect`.
- [ ] `<Warn>` before anything that locks out, deletes or costs.
- [ ] The first-pass comment at the top of each `.mdx`, naming the docs to validate against.
- [ ] `diagram.yaml` with `desc` everywhere, `focus` on one node, levels used.
- [ ] Every file delivered once, in full, as a `file="…"` block; the Run script writes the same content.
- [ ] `on=` / `as=` on every block when the page uses more than one terminal; `interactive` commands alone.
- [ ] What the reader must provide is a `required` variable, never an example in a block.

## 8. A worked request

> Write the page `host-a-nextjs-site` for a new series `vps` (title "A VPS from scratch"): from a freshly delivered Debian 12 VPS to a Next.js site served by nginx with a Let's Encrypt certificate, the app run by systemd, deployed by `git pull` + `npm run build`. Series variables: SERVER_IP (ip), USERNAME (user, the deploy user), DOMAIN (domain). Page variables: APP_DIR (path, default /srv/site), REPO_URL (url), NODE_MAJOR (text, default "22"), APP_PORT (port, default "3000"). Choices: TLS (select letsencrypt | selfsigned, default letsencrypt), PM (select systemd | pm2, default systemd). Deliver series.yaml, tuto.yaml, page-en.mdx, page-fr.mdx, diagram.yaml.

The answer is five fenced blocks, complete, following sections 3–6 and passing the checklist in 7.
