# Source of "A network lab with containerlab"

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/containerlab-topology/tuto.yaml"
# Inherits from ../series.yaml: LAB_DIR, LAB_NAME, MGMT_SUBNET, NETBOX_URL, NETBOX_TOKEN, SITE_NAME, NOS, AUTOMATION, CI.
# The three management IPs are also declared by netbox-as-source-of-truth: same keys, the reader fills them once per series.

title:
  en: A network lab with containerlab
  fr: Un lab réseau avec containerlab
summary:
  en: >-
    Docker and containerlab on one Linux machine, a network OS image, a spine and two leaves
    wired together, a first interface configuration, and the whole thing in a git repository.
  fr: >-
    Docker et containerlab sur une machine Linux, une image d'OS réseau, un spine et deux leaves
    câblés ensemble, une première configuration d'interface, et le tout dans un dépôt git.
difficulty: beginner
tags: [containerlab, docker, srlinux, ceos, lab, netdevops]
authors: [thudal]
created: 2026-09-25
minutes: 30
validated: containerlab 0.60 · Ubuntu 24.04 · SR Linux 24.10 · cEOS-lab 4.32
status: draft             # not yet run end to end by its author

groups:
  - id: srlinux
    label: { en: SR Linux image, fr: Image SR Linux }
    when: { is: NOS, equals: srlinux }
  - id: ceos
    label: { en: cEOS image, fr: Image cEOS }
    when: { is: NOS, equals: ceos }
  - id: nodes
    label: { en: Nodes, fr: Nœuds }
    desc: { en: The management address of each node, inside the management subnet., fr: L'adresse de management de chaque nœud, dans le sous-réseau de management. }

vars:
  - key: CLAB_VERSION
    kind: text
    group: lab
    default: "0.60.0"
    label: { en: containerlab version, fr: Version de containerlab }
    hint:
      en: The release the install script pins. Check the latest on github.com/srl-labs/containerlab/releases.
      fr: La version que le script d'installation fixe. Vérifie la dernière sur github.com/srl-labs/containerlab/releases.
    impact:
      en: The CI runner on the last page must run the same version, or a topology that deploys here may not deploy there.
      fr: Le runner CI de la dernière page doit avoir la même version, sinon une topologie qui se déploie ici peut ne pas se déployer là-bas.
  - key: SRL_IMAGE
    kind: text
    group: srlinux
    when: { is: NOS, equals: srlinux }
    default: ghcr.io/nokia/srlinux:24.10.1
    label: { en: SR Linux image, fr: Image SR Linux }
    hint:
      en: Public image on GitHub's registry, no account needed. Pin a tag; "latest" changes under you.
      fr: Image publique sur le registre de GitHub, aucun compte nécessaire. Fixe un tag ; « latest » change sous tes pieds.
    impact:
      en: Written into the topology file and pulled by the CI runner. The CLI syntax on the next pages was written for 24.x.
      fr: Écrite dans le fichier de topologie et tirée par le runner CI. La syntaxe CLI des pages suivantes a été écrite pour 24.x.
  - key: CEOS_IMAGE
    kind: text
    group: ceos
    when: { is: NOS, equals: ceos }
    default: ceos:4.32.0F
    label: { en: cEOS image tag, fr: Tag de l'image cEOS }
    hint:
      en: The local name you give the image when importing the tarball from arista.com.
      fr: Le nom local que tu donnes à l'image en important l'archive depuis arista.com.
    impact:
      en: Written into the topology file. The image is not in any public registry, so the CI runner must have it imported by hand too.
      fr: Écrit dans le fichier de topologie. L'image n'est dans aucun registre public, donc le runner CI doit aussi l'avoir importée à la main.
  - key: SPINE1_MGMT
    kind: ip
    group: nodes
    default: 172.20.20.11
    label: { en: spine1 management IP, fr: IP de management de spine1 }
    hint:
      en: Inside the management subnet, outside the first few addresses Docker keeps for itself.
      fr: Dans le sous-réseau de management, hors des premières adresses que Docker garde pour lui.
    impact:
      en: Pinned in the topology so every redeploy gives the same address; recorded in NetBox as the primary IP on the next page.
      fr: Fixée dans la topologie pour que chaque redéploiement donne la même adresse ; enregistrée dans NetBox comme IP primaire à la page suivante.
  - key: LEAF1_MGMT
    kind: ip
    group: nodes
    default: 172.20.20.12
    label: { en: leaf1 management IP, fr: IP de management de leaf1 }
    hint:
      en: Inside the management subnet.
      fr: Dans le sous-réseau de management.
    impact:
      en: Same role as spine1's address.
      fr: Même rôle que l'adresse de spine1.
  - key: LEAF2_MGMT
    kind: ip
    group: nodes
    default: 172.20.20.13
    label: { en: leaf2 management IP, fr: IP de management de leaf2 }
    hint:
      en: Inside the management subnet.
      fr: Dans le sous-réseau de management.
    impact:
      en: Same role as spine1's address.
      fr: Même rôle que l'adresse de spine1.
````

````mdx title="content/netdevops-lab/containerlab-topology/page-en.mdx"
{/* First pass — to be validated against containerlab.dev (install, nokia_srlinux and ceos kinds, save/destroy), learn.srlinux.dev and arista.com (cEOS-lab) before publishing. */}

A network lab used to be a rack, a week of cabling and a licence. With containerlab it is a YAML file and one command: the nodes are containers running a real network OS, the links are virtual wires between them. This page installs the tooling on **one Linux machine**, brings up a spine and two leaves, configures the first links, and puts the whole thing in git so the next pages can build on it.

<Run>

The script installs Docker and containerlab on the lab machine, writes the topology to <V name="LAB_DIR" />, deploys it and initialises the repository. Run it **on the lab machine** as a user with sudo. <When is="NOS" equals="ceos">The cEOS image must already be imported as <V name="CEOS_IMAGE" /> (see the image step: it cannot be downloaded without an account).</When>

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetDevOps lab — containerlab topology ${LAB_NAME} in ${LAB_DIR}
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${CLAB_VERSION}
mkdir -p ${LAB_DIR} && cd ${LAB_DIR}
```

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

```bash
sudo docker pull ${SRL_IMAGE}
cat > topology.clab.yml <<EOF
name: ${LAB_NAME}
mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}
topology:
  kinds:
    nokia_srlinux:
      image: ${SRL_IMAGE}
  nodes:
    spine1: { kind: nokia_srlinux, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF1_MGMT} }
    leaf2:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:e1-1", "leaf1:e1-1"]
    - endpoints: ["spine1:e1-2", "leaf2:e1-1"]
EOF
```

</When>

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

```bash
sudo docker image inspect ${CEOS_IMAGE} >/dev/null
cat > topology.clab.yml <<EOF
name: ${LAB_NAME}
mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}
topology:
  kinds:
    ceos:
      image: ${CEOS_IMAGE}
  nodes:
    spine1: { kind: ceos, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: ceos, mgmt-ipv4: ${LEAF1_MGMT} }
    leaf2:  { kind: ceos, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:eth1", "leaf1:eth1"]
    - endpoints: ["spine1:eth2", "leaf2:eth1"]
EOF
```

</When>

```bash
printf 'clab-*/\n*.tar.xz\n.venv/\n' > .gitignore
git init -q 2>/dev/null || true
git add -A && git -c user.name=lab -c user.email=lab@localhost commit -qm "containerlab topology" || true
sudo containerlab deploy -t topology.clab.yml
sudo containerlab inspect -t topology.clab.yml
echo "Done. Nodes: ssh admin@${SPINE1_MGMT} (and ${LEAF1_MGMT}, ${LEAF2_MGMT})"
```

<Warn>The Docker convenience script and the containerlab installer both run as root and add repositories to the machine. Fine on a lab host; read them first on anything else.</Warn>

</Run>

## Before you start

<Guided>You need one Linux machine, physical or virtual, with **Ubuntu 22.04 or 24.04**, at least 4 vCPU and 8 GB of RAM (each node takes 1 to 2 GB), 20 GB of disk, sudo, and internet access to pull images. A VM in the cloud works; nested virtualisation is **not** needed, these are containers. Everything on this page happens on that machine, in <V name="LAB_DIR" />.</Guided>

```bash
sudo apt update && sudo apt install -y curl git
mkdir -p ${LAB_DIR}
```

<Deep>Why one machine and not your laptop? The node images are Linux x86_64 containers; on an ARM Mac they run under emulation, when they run at all, and the CI runner of the last page must be a Linux host with Docker anyway. Setting the lab up on the same kind of machine you will run CI on removes a whole class of "works on my laptop" surprises. Keep the laptop for editing and `ssh`.</Deep>

<Check cmd="lsb_release -is && docker --version >/dev/null 2>&1 || echo no-docker-yet" expect="Ubuntu
no-docker-yet" />

## Install Docker and containerlab

<Guided>containerlab drives Docker: it creates the containers, the virtual wires between them and the management network. Docker comes first, from Docker's own repository (the Ubuntu package is older and lacks the buildx and compose plugins).</Guided>

```bash
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world | head -2
```

Then containerlab, pinned to <V name="CLAB_VERSION" />:

```bash
sudo bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${CLAB_VERSION}
containerlab version
```

<Note>The installer's `-v` flag selects a release; without it you get the latest. Confirm the exact flag on containerlab.dev/install, the script has changed shape over the years. The same version goes on the CI runner later, so note it.</Note>

<Deep>containerlab is a single Go binary that talks to the Docker API on `/var/run/docker.sock` and creates veth pairs between the containers' network namespaces for the links: that is why it needs root. It also writes `/etc/hosts` entries for each node, so `ssh admin@clab-<V name="LAB_NAME" />-spine1` works without knowing the IP. Everything it creates carries the lab name as a prefix, which is how it finds its own containers again to inspect or destroy them.</Deep>

<Check cmd="containerlab version | grep -o 'version: ${CLAB_VERSION}'" expect="version: ${CLAB_VERSION}" />

## Get the image

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

<Guided>Nokia publishes SR Linux as a public container image on GitHub's registry: no account, no licence, a `docker pull`. Pin a tag; the CLI on the next pages was written for the 24.x line.</Guided>

```bash
docker pull ${SRL_IMAGE}
```

<Deep>The image is a full SR Linux: the management server, the CLI (`sr_cli`), gNMI on 57400 and JSON-RPC on 80/443, all started by containerlab with a default configuration that enables them and creates the `admin` user. Interfaces `ethernet-1/1` to `ethernet-1/N` exist as soon as a link is attached; the default chassis type (`ixrd2l`) is enough for a lab. A node needs about 1.5 GB of RAM and a minute to boot.</Deep>

<Check cmd="docker image ls ${SRL_IMAGE} -q | wc -l" expect="1" />

</When>

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

<Guided>Arista's cEOS-lab is free but sits behind a registration: create an account on arista.com, go to **Software downloads → cEOS-lab**, and download the 64-bit tarball for your version (`cEOS64-lab-4.32.0F.tar.xz` for the default). Copy it to the lab machine, then import it under the name you set in <V name="CEOS_IMAGE" />.</Guided>

```bash
cd ${LAB_DIR}
docker import cEOS64-lab-4.32.0F.tar.xz ${CEOS_IMAGE}
docker image ls ${CEOS_IMAGE}
```

<Note>`docker import`, not `docker load`: the tarball is a filesystem, not a saved image. Match the version in the file name with the tag you give the image, future you will thank you when three versions coexist.</Note>

<Deep>cEOS is EOS's user space in a container, with the same CLI and the same `startup-config` at `/mnt/flash/startup-config`. containerlab sets the environment variables it needs (`CEOS=1`, `INTFTYPE=eth`…) and maps `eth1` to `Ethernet1`. Boot takes about two minutes, longer than SR Linux, and the image is under Arista's licence: do not push it to a public registry, which is also why the CI runner on the last page must import it by hand.</Deep>

<Check cmd="docker image ls ${CEOS_IMAGE} -q | wc -l" expect="1" />

</When>

## Write the topology

<Guided>One file describes the lab: a name, a management network, the nodes, the links. Names are the ones NetBox will use on the next page, so keep them: `spine1`, `leaf1`, `leaf2`. The management IPs are pinned, not left to Docker, so that every redeploy gives the same addresses and NetBox stays right.</Guided>

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

<Annotated>

```yaml title="${LAB_DIR}/topology.clab.yml" {1,4-5,9,12-14,16-17}
name: ${LAB_NAME}                                # (1)

mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}                    # (2)

topology:
  kinds:
    nokia_srlinux:
      image: ${SRL_IMAGE}                        # (3)
  nodes:
    spine1: { kind: nokia_srlinux, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF1_MGMT} }   # (4)
    leaf2:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:e1-1", "leaf1:e1-1"]  # (5)
    - endpoints: ["spine1:e1-2", "leaf2:e1-1"]
```

1. The lab name: prefix of every container (`clab-<V name="LAB_NAME" />-spine1`), of the working folder and of the management network.
2. A Docker bridge network created for the lab; each node gets its management interface on it. The lab host is the gateway (`.1`).
3. Set once at the kind level, inherited by every node of that kind. Change the tag here and redeploy to test another release.
4. A fixed management IP inside the subnet. Docker keeps the first addresses for itself; start at `.11` and you never collide.
5. A link is two endpoints, `node:interface`. `e1-1` is containerlab's short form of `ethernet-1/1`; the endpoint name is what you will see in `show interface`.

</Annotated>

</When>

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

<Annotated>

```yaml title="${LAB_DIR}/topology.clab.yml" {1,4-5,9,12-14,16-17}
name: ${LAB_NAME}                                # (1)

mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}                    # (2)

topology:
  kinds:
    ceos:
      image: ${CEOS_IMAGE}                       # (3)
  nodes:
    spine1: { kind: ceos, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: ceos, mgmt-ipv4: ${LEAF1_MGMT} }   # (4)
    leaf2:  { kind: ceos, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:eth1", "leaf1:eth1"]  # (5)
    - endpoints: ["spine1:eth2", "leaf2:eth1"]
```

1. The lab name: prefix of every container (`clab-<V name="LAB_NAME" />-spine1`), of the working folder and of the management network.
2. A Docker bridge network created for the lab; each node gets its management interface on it. The lab host is the gateway (`.1`).
3. Set once at the kind level, inherited by every node of that kind. Change the tag here and redeploy to test another release.
4. A fixed management IP inside the subnet. Docker keeps the first addresses for itself; start at `.11` and you never collide.
5. A link is two endpoints, `node:interface`. `eth1` in the container is `Ethernet1` in EOS; the numbering must be contiguous, `eth1` then `eth2`, no gaps.

</Annotated>

</When>

<Deep>containerlab has an `inspect`-able model of everything in this file and a JSON schema for it: a wrong key fails at deploy time, not silently. Larger labs use `defaults:` and `groups:` to avoid repeating kinds, and `startup-config:` per node to boot with a given configuration; on the next pages configuration comes from NetBox instead, so the nodes boot blank on purpose. Links can also go to the host (`host:eth1`) or to a bridge, which is how you attach a real machine or a traffic generator.</Deep>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; t = yaml.safe_load(open(\"topology.clab.yml\")); print(len(t[\"topology\"][\"nodes\"]), len(t[\"topology\"][\"links\"]))'"} expect="3 2" />

## Deploy and look around

<Guided>Deploy pulls nothing you have not pulled, creates the management network, starts the three containers and wires the links. The first boot takes one to two minutes per node; `inspect` shows the table you will keep coming back to.</Guided>

```bash
cd ${LAB_DIR}
sudo containerlab deploy -t topology.clab.yml
sudo containerlab inspect -t topology.clab.yml
```

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

Log in to a node. containerlab created `/etc/hosts` entries, and the default credentials are `admin` / `NokiaSrl1!`:

```bash
ssh admin@${SPINE1_MGMT}
```

```text
A:spine1# show version
A:spine1# show interface brief
```

<Deep>The `A:` prompt means the active management server; SR Linux's CLI is a view over a YANG model, so `show` output, `info` (the running config, as a tree or `info flat` as `set` lines) and the gNMI paths of the next pages are the same thing seen from three sides. `docker exec -it clab-<V name="LAB_NAME" />-spine1 sr_cli` gets you the same CLI without SSH, handy when the management network misbehaves.</Deep>

</When>

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

Log in to a node. containerlab created `/etc/hosts` entries, and the default credentials are `admin` / `admin`:

```bash
ssh admin@${SPINE1_MGMT}
```

```text
spine1>enable
spine1#show version
spine1#show interfaces status
```

<Deep>Same CLI as a physical EOS switch, same `show` commands, same `startup-config`. `docker exec -it clab-<V name="LAB_NAME" />-spine1 Cli` gets you there without SSH. If SSH refuses the connection during the first two minutes, the box is still booting; `docker logs` shows the agents starting.</Deep>

</When>

<Check cmd="docker ps --filter name=clab-${LAB_NAME}- -q | wc -l" expect="3" />

<Check cmd="sudo containerlab inspect -t ${LAB_DIR}/topology.clab.yml | grep -c running" expect="3" />

<Details summary="If a node stays in 'created' or restarts">
`docker logs clab-<V name="LAB_NAME" />-spine1` tells. The usual causes: not enough RAM (the kernel kills the biggest process, `dmesg | grep -i kill`), <When is="NOS" equals="ceos">a cEOS tarball imported with `docker load` instead of `docker import`, </When>or a management subnet that overlaps a network the host already has (`ip route`; pick another <V name="MGMT_SUBNET" />). Destroy, fix, redeploy.
</Details>

## A first configuration

<Guided>Nothing talks yet: the links are up at layer 2, but no interface has an address. Give each end of each link an address from a /31, the standard for point-to-point links, and ping across. spine1–leaf1 uses `10.1.0.0/31`, spine1–leaf2 uses `10.1.0.2/31`; the spine takes the even address. These are lab constants: on the next page they move into NetBox and never get typed again.</Guided>

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

<Tabs group="first-config">

<Tab label="spine1">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.0/31
set / interface ethernet-1/2 admin-state enable
set / interface ethernet-1/2 subinterface 0 admin-state enable
set / interface ethernet-1/2 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/2 subinterface 0 ipv4 address 10.1.0.2/31
set / network-instance default interface ethernet-1/1.0
set / network-instance default interface ethernet-1/2.0
commit now
```

</Tab>

<Tab label="leaf1">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.1/31
set / network-instance default interface ethernet-1/1.0
commit now
```

</Tab>

<Tab label="leaf2">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.3/31
set / network-instance default interface ethernet-1/1.0
commit now
```

</Tab>

</Tabs>

<Deep>Three things a Junos or IOS engineer trips on. An address lives on a *subinterface* (`ethernet-1/1.0`), never on the interface. An interface belongs to nothing until you put its subinterface in a *network-instance*; `default` is the global routing table. And since 23.x the IPv4 stack of a subinterface is disabled until `ipv4 admin-state enable`. `enter candidate` opens a private candidate, `commit now` applies it and skips the confirmation timer; `diff` before committing shows what is about to change, which the push step of the third page relies on.</Deep>

From spine1, ping leaf1 across the link:

```text
ping -c 3 10.1.0.1 network-instance default
```

<Note>SR Linux's `ping` takes the network instance as a keyword after the destination; if your release rejects the order, `ping network-instance default 10.1.0.1` is the other form. Check `ping ?`.</Note>

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 sr_cli 'ping -c 3 10.1.0.1 network-instance default' | grep -o '3 received'"} expect="3 received" />

</When>

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

<Tabs group="first-config">

<Tab label="spine1">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.0/31
interface Ethernet2
   no switchport
   ip address 10.1.0.2/31
end
write
```

</Tab>

<Tab label="leaf1">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.1/31
end
write
```

</Tab>

<Tab label="leaf2">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.3/31
end
write
```

</Tab>

</Tabs>

<Deep>cEOS boots as a switch: every Ethernet port is a layer-2 access port in VLAN 1 and routing is off. `no switchport` turns a port into a routed interface, `ip routing` turns the router on; forget either and the ping fails with no error anywhere. `write` copies the running config to `startup-config`, which is what `containerlab save` reads back.</Deep>

From spine1, ping leaf1 across the link:

```text
ping 10.1.0.1
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'ping 10.1.0.1 repeat 3' | grep -o '3 received'"} expect="3 received" />

</When>

## Save, destroy, redeploy

<Guided>A lab you cannot throw away is a pet. `save` copies each node's running configuration into the lab's working folder; `destroy` removes the containers and the network and keeps that folder; the next `deploy` boots the nodes with the saved configuration. `destroy --cleanup` removes the folder too, for a truly blank lab.</Guided>

```bash
cd ${LAB_DIR}
sudo containerlab save -t topology.clab.yml
ls clab-${LAB_NAME}/
sudo containerlab destroy -t topology.clab.yml
sudo containerlab deploy -t topology.clab.yml
```

<Deep><When is="NOS" equals="srlinux">For SR Linux, `save` runs `tools system configuration save` and the checkpoint ends up under `clab-<V name="LAB_NAME" />/spine1/config/`; </When><When is="NOS" equals="ceos">For cEOS, `save` runs `write` and the file is `clab-<V name="LAB_NAME" />/spine1/flash/startup-config`; </When>containerlab mounts that folder into the container, so a redeploy finds it. `containerlab redeploy` does destroy + deploy in one command. On the next pages the nodes boot blank on purpose and get their configuration from NetBox: `save` is a convenience for hand-made experiments, not the source of truth. Confirm the exact paths for your version on containerlab.dev, kinds section.</Deep>

<Check cmd="sudo containerlab inspect -t ${LAB_DIR}/topology.clab.yml | grep -c running" expect="3" />

## The lab in git

<Guided>The topology file is code: version it from day one. The working folder containerlab creates (`clab-<V name="LAB_NAME" />/`) holds runtime files and saved configs and is ignored; the cEOS tarball, if any, is ignored too.</Guided>

```text title="${LAB_DIR}/.gitignore"
clab-*/
*.tar.xz
.venv/
```

```bash
cd ${LAB_DIR}
git init
git add topology.clab.yml .gitignore
git commit -m "containerlab topology: spine1, leaf1, leaf2"
```

<Deep>Everything the following pages add lives in this repository: the seed script for NetBox, the templates, the rendering and push scripts, the tests, and the pipeline file. The last page pushes it to GitHub or GitLab and runs it on a runner installed on this very machine, which is why the layout under <V name="LAB_DIR" /> matters from the start.</Deep>

<Check cmd="git -C ${LAB_DIR} status --short | wc -l" expect="0" />

## Done

Three <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> nodes run on <V name="LAB_DIR" />, wired spine-to-leaf, reachable on <V name="SPINE1_MGMT" />, <V name="LEAF1_MGMT" /> and <V name="LEAF2_MGMT" />, and the topology is in git. You can destroy and rebuild the whole thing in two commands:

```bash
cd ${LAB_DIR} && sudo containerlab destroy -t topology.clab.yml --cleanup && sudo containerlab deploy -t topology.clab.yml
```

The addresses you typed by hand in this page are the last ones you will type: the next page models the lab in NetBox, devices, interfaces, cables and IPs, so that configuration can be generated from it.
````

````mdx title="content/netdevops-lab/containerlab-topology/page-fr.mdx"
{/* Première passe — à valider contre containerlab.dev (install, kinds nokia_srlinux et ceos, save/destroy), learn.srlinux.dev et arista.com (cEOS-lab) avant publication. */}

Un lab réseau, c'était une baie, une semaine de câblage et une licence. Avec containerlab, c'est un fichier YAML et une commande : les nœuds sont des conteneurs qui font tourner un vrai OS réseau, les liens sont des câbles virtuels entre eux. Cette page installe l'outillage sur **une machine Linux**, monte un spine et deux leaves, configure les premiers liens, et met le tout dans git pour que les pages suivantes construisent dessus.

<Run>

Le script installe Docker et containerlab sur la machine de lab, écrit la topologie dans <V name="LAB_DIR" />, la déploie et initialise le dépôt. Lance-le **sur la machine de lab** avec un utilisateur qui a sudo. <When is="NOS" equals="ceos">L'image cEOS doit déjà être importée sous le nom <V name="CEOS_IMAGE" /> (voir l'étape image : elle ne se télécharge pas sans compte).</When>

```bash
#!/usr/bin/env bash
set -euo pipefail
# Lab NetDevOps — topologie containerlab ${LAB_NAME} dans ${LAB_DIR}
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${CLAB_VERSION}
mkdir -p ${LAB_DIR} && cd ${LAB_DIR}
```

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

```bash
sudo docker pull ${SRL_IMAGE}
cat > topology.clab.yml <<EOF
name: ${LAB_NAME}
mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}
topology:
  kinds:
    nokia_srlinux:
      image: ${SRL_IMAGE}
  nodes:
    spine1: { kind: nokia_srlinux, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF1_MGMT} }
    leaf2:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:e1-1", "leaf1:e1-1"]
    - endpoints: ["spine1:e1-2", "leaf2:e1-1"]
EOF
```

</When>

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

```bash
sudo docker image inspect ${CEOS_IMAGE} >/dev/null
cat > topology.clab.yml <<EOF
name: ${LAB_NAME}
mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}
topology:
  kinds:
    ceos:
      image: ${CEOS_IMAGE}
  nodes:
    spine1: { kind: ceos, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: ceos, mgmt-ipv4: ${LEAF1_MGMT} }
    leaf2:  { kind: ceos, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:eth1", "leaf1:eth1"]
    - endpoints: ["spine1:eth2", "leaf2:eth1"]
EOF
```

</When>

```bash
printf 'clab-*/\n*.tar.xz\n.venv/\n' > .gitignore
git init -q 2>/dev/null || true
git add -A && git -c user.name=lab -c user.email=lab@localhost commit -qm "containerlab topology" || true
sudo containerlab deploy -t topology.clab.yml
sudo containerlab inspect -t topology.clab.yml
echo "Terminé. Nœuds : ssh admin@${SPINE1_MGMT} (et ${LEAF1_MGMT}, ${LEAF2_MGMT})"
```

<Warn>Le script d'installation de Docker et celui de containerlab tournent tous deux en root et ajoutent des dépôts à la machine. Très bien sur un hôte de lab ; lis-les d'abord sur n'importe quoi d'autre.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut une machine Linux, physique ou virtuelle, sous **Ubuntu 22.04 ou 24.04**, avec au moins 4 vCPU et 8 Go de RAM (chaque nœud en prend 1 à 2 Go), 20 Go de disque, sudo, et un accès internet pour tirer les images. Une VM dans le cloud convient ; la virtualisation imbriquée n'est **pas** nécessaire, ce sont des conteneurs. Tout ce qui suit se passe sur cette machine, dans <V name="LAB_DIR" />.</Guided>

```bash
sudo apt update && sudo apt install -y curl git
mkdir -p ${LAB_DIR}
```

<Deep>Pourquoi une machine et pas ton portable ? Les images des nœuds sont des conteneurs Linux x86_64 ; sur un Mac ARM elles tournent en émulation, quand elles tournent, et le runner CI de la dernière page devra de toute façon être un hôte Linux avec Docker. Monter le lab sur le même genre de machine que celle qui fera tourner la CI élimine toute une famille de surprises du type « ça marche sur mon portable ». Garde le portable pour éditer et faire du `ssh`.</Deep>

<Check cmd="lsb_release -is && docker --version >/dev/null 2>&1 || echo no-docker-yet" expect="Ubuntu
no-docker-yet" />

## Installer Docker et containerlab

<Guided>containerlab pilote Docker : c'est lui qui crée les conteneurs, les câbles virtuels entre eux et le réseau de management. Docker vient donc en premier, depuis le dépôt de Docker lui-même (le paquet Ubuntu est plus ancien et n'a pas les plugins buildx et compose).</Guided>

```bash
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world | head -2
```

Puis containerlab, fixé à la version <V name="CLAB_VERSION" /> :

```bash
sudo bash -c "$(curl -sL https://get.containerlab.dev)" -- -v ${CLAB_VERSION}
containerlab version
```

<Note>L'option `-v` de l'installeur choisit une version ; sans elle tu obtiens la dernière. Confirme l'option exacte sur containerlab.dev/install, le script a changé de forme au fil des années. La même version ira sur le runner CI plus tard, note-la.</Note>

<Deep>containerlab est un binaire Go unique qui parle à l'API Docker sur `/var/run/docker.sock` et crée des paires veth entre les espaces de noms réseau des conteneurs pour les liens : c'est pour ça qu'il a besoin de root. Il écrit aussi des entrées dans `/etc/hosts` pour chaque nœud, donc `ssh admin@clab-<V name="LAB_NAME" />-spine1` marche sans connaître l'IP. Tout ce qu'il crée porte le nom du lab en préfixe, c'est ainsi qu'il retrouve ses propres conteneurs pour les inspecter ou les détruire.</Deep>

<Check cmd="containerlab version | grep -o 'version: ${CLAB_VERSION}'" expect="version: ${CLAB_VERSION}" />

## Récupérer l'image

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

<Guided>Nokia publie SR Linux comme image de conteneur publique sur le registre de GitHub : pas de compte, pas de licence, un `docker pull`. Fixe un tag ; la CLI des pages suivantes a été écrite pour la ligne 24.x.</Guided>

```bash
docker pull ${SRL_IMAGE}
```

<Deep>L'image est un SR Linux complet : le serveur de management, la CLI (`sr_cli`), gNMI sur 57400 et JSON-RPC sur 80/443, le tout démarré par containerlab avec une configuration par défaut qui les active et crée l'utilisateur `admin`. Les interfaces `ethernet-1/1` à `ethernet-1/N` existent dès qu'un lien y est attaché ; le type de châssis par défaut (`ixrd2l`) suffit pour un lab. Un nœud demande environ 1,5 Go de RAM et une minute pour démarrer.</Deep>

<Check cmd="docker image ls ${SRL_IMAGE} -q | wc -l" expect="1" />

</When>

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

<Guided>Le cEOS-lab d'Arista est gratuit mais derrière une inscription : crée un compte sur arista.com, va dans **Software downloads → cEOS-lab**, et télécharge l'archive 64 bits de ta version (`cEOS64-lab-4.32.0F.tar.xz` pour la valeur par défaut). Copie-la sur la machine de lab, puis importe-la sous le nom que tu as mis dans <V name="CEOS_IMAGE" />.</Guided>

```bash
cd ${LAB_DIR}
docker import cEOS64-lab-4.32.0F.tar.xz ${CEOS_IMAGE}
docker image ls ${CEOS_IMAGE}
```

<Note>`docker import`, pas `docker load` : l'archive est un système de fichiers, pas une image sauvegardée. Fais correspondre la version du nom de fichier et le tag que tu donnes à l'image, tu te remercieras quand trois versions cohabiteront.</Note>

<Deep>cEOS, c'est l'espace utilisateur d'EOS dans un conteneur, avec la même CLI et le même `startup-config` dans `/mnt/flash/startup-config`. containerlab pose les variables d'environnement dont il a besoin (`CEOS=1`, `INTFTYPE=eth`…) et fait correspondre `eth1` à `Ethernet1`. Le démarrage prend environ deux minutes, plus que SR Linux, et l'image est sous licence Arista : ne la pousse pas sur un registre public, ce qui explique aussi que le runner CI de la dernière page doive l'importer à la main.</Deep>

<Check cmd="docker image ls ${CEOS_IMAGE} -q | wc -l" expect="1" />

</When>

## Écrire la topologie

<Guided>Un fichier décrit le lab : un nom, un réseau de management, les nœuds, les liens. Les noms sont ceux que NetBox utilisera à la page suivante, garde-les : `spine1`, `leaf1`, `leaf2`. Les IP de management sont fixées, pas laissées à Docker, pour que chaque redéploiement donne les mêmes adresses et que NetBox reste juste.</Guided>

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

<Annotated>

```yaml title="${LAB_DIR}/topology.clab.yml" {1,4-5,9,12-14,16-17}
name: ${LAB_NAME}                                # (1)

mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}                    # (2)

topology:
  kinds:
    nokia_srlinux:
      image: ${SRL_IMAGE}                        # (3)
  nodes:
    spine1: { kind: nokia_srlinux, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF1_MGMT} }   # (4)
    leaf2:  { kind: nokia_srlinux, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:e1-1", "leaf1:e1-1"]  # (5)
    - endpoints: ["spine1:e1-2", "leaf2:e1-1"]
```

1. Le nom du lab : préfixe de chaque conteneur (`clab-<V name="LAB_NAME" />-spine1`), du dossier de travail et du réseau de management.
2. Un réseau bridge Docker créé pour le lab ; chaque nœud y a son interface de management. L'hôte de lab est la passerelle (`.1`).
3. Défini une fois au niveau du kind, hérité par chaque nœud de ce kind. Change le tag ici et redéploie pour tester une autre version.
4. Une IP de management fixe dans le sous-réseau. Docker garde les premières adresses pour lui ; commence à `.11` et tu n'entreras jamais en collision.
5. Un lien, c'est deux extrémités, `nœud:interface`. `e1-1` est la forme courte containerlab de `ethernet-1/1` ; le nom de l'extrémité est celui que tu verras dans `show interface`.

</Annotated>

</When>

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

<Annotated>

```yaml title="${LAB_DIR}/topology.clab.yml" {1,4-5,9,12-14,16-17}
name: ${LAB_NAME}                                # (1)

mgmt:
  network: ${LAB_NAME}-mgmt
  ipv4-subnet: ${MGMT_SUBNET}                    # (2)

topology:
  kinds:
    ceos:
      image: ${CEOS_IMAGE}                       # (3)
  nodes:
    spine1: { kind: ceos, mgmt-ipv4: ${SPINE1_MGMT} }
    leaf1:  { kind: ceos, mgmt-ipv4: ${LEAF1_MGMT} }   # (4)
    leaf2:  { kind: ceos, mgmt-ipv4: ${LEAF2_MGMT} }
  links:
    - endpoints: ["spine1:eth1", "leaf1:eth1"]  # (5)
    - endpoints: ["spine1:eth2", "leaf2:eth1"]
```

1. Le nom du lab : préfixe de chaque conteneur (`clab-<V name="LAB_NAME" />-spine1`), du dossier de travail et du réseau de management.
2. Un réseau bridge Docker créé pour le lab ; chaque nœud y a son interface de management. L'hôte de lab est la passerelle (`.1`).
3. Défini une fois au niveau du kind, hérité par chaque nœud de ce kind. Change le tag ici et redéploie pour tester une autre version.
4. Une IP de management fixe dans le sous-réseau. Docker garde les premières adresses pour lui ; commence à `.11` et tu n'entreras jamais en collision.
5. Un lien, c'est deux extrémités, `nœud:interface`. `eth1` dans le conteneur, c'est `Ethernet1` dans EOS ; la numérotation doit être contiguë, `eth1` puis `eth2`, sans trou.

</Annotated>

</When>

<Deep>containerlab a un modèle de tout ce qui est dans ce fichier et un schéma JSON pour le valider : une mauvaise clé échoue au déploiement, pas en silence. Les labs plus grands utilisent `defaults:` et `groups:` pour ne pas répéter les kinds, et `startup-config:` par nœud pour démarrer avec une configuration donnée ; sur les pages suivantes la configuration vient de NetBox, donc les nœuds démarrent vierges exprès. Les liens peuvent aussi aller vers l'hôte (`host:eth1`) ou vers un bridge, c'est ainsi qu'on attache une vraie machine ou un générateur de trafic.</Deep>

<Check cmd={"cd ${LAB_DIR} && python3 -c 'import yaml; t = yaml.safe_load(open(\"topology.clab.yml\")); print(len(t[\"topology\"][\"nodes\"]), len(t[\"topology\"][\"links\"]))'"} expect="3 2" />

## Déployer et regarder

<Guided>Le déploiement ne tire rien que tu n'aies déjà tiré, crée le réseau de management, démarre les trois conteneurs et câble les liens. Le premier démarrage prend une à deux minutes par nœud ; `inspect` affiche le tableau auquel tu reviendras sans cesse.</Guided>

```bash
cd ${LAB_DIR}
sudo containerlab deploy -t topology.clab.yml
sudo containerlab inspect -t topology.clab.yml
```

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

Connecte-toi à un nœud. containerlab a créé les entrées `/etc/hosts`, et les identifiants par défaut sont `admin` / `NokiaSrl1!` :

```bash
ssh admin@${SPINE1_MGMT}
```

```text
A:spine1# show version
A:spine1# show interface brief
```

<Deep>Le prompt `A:` indique le serveur de management actif ; la CLI de SR Linux est une vue sur un modèle YANG, donc la sortie des `show`, `info` (la configuration courante, en arbre, ou `info flat` en lignes `set`) et les chemins gNMI des pages suivantes sont la même chose vue de trois côtés. `docker exec -it clab-<V name="LAB_NAME" />-spine1 sr_cli` donne la même CLI sans SSH, pratique quand le réseau de management fait des siennes.</Deep>

</When>

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

Connecte-toi à un nœud. containerlab a créé les entrées `/etc/hosts`, et les identifiants par défaut sont `admin` / `admin` :

```bash
ssh admin@${SPINE1_MGMT}
```

```text
spine1>enable
spine1#show version
spine1#show interfaces status
```

<Deep>Même CLI qu'un switch EOS physique, mêmes `show`, même `startup-config`. `docker exec -it clab-<V name="LAB_NAME" />-spine1 Cli` t'y amène sans SSH. Si SSH refuse la connexion pendant les deux premières minutes, la boîte démarre encore ; `docker logs` montre les agents qui se lancent.</Deep>

</When>

<Check cmd="docker ps --filter name=clab-${LAB_NAME}- -q | wc -l" expect="3" />

<Check cmd="sudo containerlab inspect -t ${LAB_DIR}/topology.clab.yml | grep -c running" expect="3" />

<Details summary="Si un nœud reste en « created » ou redémarre en boucle">
`docker logs clab-<V name="LAB_NAME" />-spine1` te le dit. Les causes habituelles : pas assez de RAM (le noyau tue le plus gros processus, `dmesg | grep -i kill`), <When is="NOS" equals="ceos">une archive cEOS importée avec `docker load` au lieu de `docker import`, </When>ou un sous-réseau de management qui chevauche un réseau que l'hôte a déjà (`ip route` ; choisis un autre <V name="MGMT_SUBNET" />). Détruis, corrige, redéploie.
</Details>

## Une première configuration

<Guided>Rien ne parle encore : les liens sont montés en couche 2, mais aucune interface n'a d'adresse. Donne à chaque bout de chaque lien une adresse prise dans un /31, le standard pour les liens point à point, et pingue en face. spine1–leaf1 utilise `10.1.0.0/31`, spine1–leaf2 utilise `10.1.0.2/31` ; le spine prend l'adresse paire. Ce sont des constantes du lab : à la page suivante elles passent dans NetBox et ne se tapent plus jamais.</Guided>

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

<Tabs group="first-config">

<Tab label="spine1">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.0/31
set / interface ethernet-1/2 admin-state enable
set / interface ethernet-1/2 subinterface 0 admin-state enable
set / interface ethernet-1/2 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/2 subinterface 0 ipv4 address 10.1.0.2/31
set / network-instance default interface ethernet-1/1.0
set / network-instance default interface ethernet-1/2.0
commit now
```

</Tab>

<Tab label="leaf1">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.1/31
set / network-instance default interface ethernet-1/1.0
commit now
```

</Tab>

<Tab label="leaf2">

```text
enter candidate
set / interface ethernet-1/1 admin-state enable
set / interface ethernet-1/1 subinterface 0 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 admin-state enable
set / interface ethernet-1/1 subinterface 0 ipv4 address 10.1.0.3/31
set / network-instance default interface ethernet-1/1.0
commit now
```

</Tab>

</Tabs>

<Deep>Trois choses sur lesquelles un ingénieur Junos ou IOS trébuche. Une adresse vit sur une *sous-interface* (`ethernet-1/1.0`), jamais sur l'interface. Une interface n'appartient à rien tant que sa sous-interface n'est pas dans une *network-instance* ; `default` est la table de routage globale. Et depuis 23.x la pile IPv4 d'une sous-interface est désactivée tant qu'on n'a pas fait `ipv4 admin-state enable`. `enter candidate` ouvre un candidat privé, `commit now` l'applique en sautant le délai de confirmation ; `diff` avant de valider montre ce qui va changer, ce sur quoi l'étape de push de la troisième page s'appuie.</Deep>

Depuis spine1, pingue leaf1 à travers le lien :

```text
ping -c 3 10.1.0.1 network-instance default
```

<Note>Le `ping` de SR Linux prend la network-instance en mot-clé après la destination ; si ta version refuse cet ordre, `ping network-instance default 10.1.0.1` est l'autre forme. Regarde `ping ?`.</Note>

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 sr_cli 'ping -c 3 10.1.0.1 network-instance default' | grep -o '3 received'"} expect="3 received" />

</When>

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

<Tabs group="first-config">

<Tab label="spine1">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.0/31
interface Ethernet2
   no switchport
   ip address 10.1.0.2/31
end
write
```

</Tab>

<Tab label="leaf1">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.1/31
end
write
```

</Tab>

<Tab label="leaf2">

```text
enable
configure
ip routing
interface Ethernet1
   no switchport
   ip address 10.1.0.3/31
end
write
```

</Tab>

</Tabs>

<Deep>cEOS démarre en switch : chaque port Ethernet est un port d'accès de couche 2 dans le VLAN 1 et le routage est coupé. `no switchport` transforme un port en interface routée, `ip routing` allume le routeur ; oublie l'un des deux et le ping échoue sans aucune erreur nulle part. `write` copie la configuration courante dans `startup-config`, c'est ce que `containerlab save` relit.</Deep>

Depuis spine1, pingue leaf1 à travers le lien :

```text
ping 10.1.0.1
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'ping 10.1.0.1 repeat 3' | grep -o '3 received'"} expect="3 received" />

</When>

## Sauver, détruire, redéployer

<Guided>Un lab qu'on ne peut pas jeter est un animal de compagnie. `save` copie la configuration courante de chaque nœud dans le dossier de travail du lab ; `destroy` supprime les conteneurs et le réseau en gardant ce dossier ; le `deploy` suivant démarre les nœuds avec la configuration sauvée. `destroy --cleanup` supprime aussi le dossier, pour un lab vraiment vierge.</Guided>

```bash
cd ${LAB_DIR}
sudo containerlab save -t topology.clab.yml
ls clab-${LAB_NAME}/
sudo containerlab destroy -t topology.clab.yml
sudo containerlab deploy -t topology.clab.yml
```

<Deep><When is="NOS" equals="srlinux">Pour SR Linux, `save` lance `tools system configuration save` et le checkpoint atterrit sous `clab-<V name="LAB_NAME" />/spine1/config/` ; </When><When is="NOS" equals="ceos">Pour cEOS, `save` lance `write` et le fichier est `clab-<V name="LAB_NAME" />/spine1/flash/startup-config` ; </When>containerlab monte ce dossier dans le conteneur, donc un redéploiement le retrouve. `containerlab redeploy` fait destroy + deploy en une commande. Sur les pages suivantes les nœuds démarrent vierges exprès et reçoivent leur configuration depuis NetBox : `save` est une commodité pour les expériences à la main, pas la source de vérité. Confirme les chemins exacts pour ta version sur containerlab.dev, section kinds.</Deep>

<Check cmd="sudo containerlab inspect -t ${LAB_DIR}/topology.clab.yml | grep -c running" expect="3" />

## Le lab dans git

<Guided>Le fichier de topologie, c'est du code : versionne-le dès le premier jour. Le dossier de travail que containerlab crée (`clab-<V name="LAB_NAME" />/`) contient des fichiers d'exécution et les configs sauvées, il est ignoré ; l'archive cEOS, s'il y en a une, aussi.</Guided>

```text title="${LAB_DIR}/.gitignore"
clab-*/
*.tar.xz
.venv/
```

```bash
cd ${LAB_DIR}
git init
git add topology.clab.yml .gitignore
git commit -m "containerlab topology: spine1, leaf1, leaf2"
```

<Deep>Tout ce que les pages suivantes ajoutent vit dans ce dépôt : le script de seed pour NetBox, les templates, les scripts de rendu et de push, les tests, et le fichier de pipeline. La dernière page le pousse sur GitHub ou GitLab et le fait tourner sur un runner installé sur cette machine même, c'est pour ça que l'arborescence sous <V name="LAB_DIR" /> compte dès le départ.</Deep>

<Check cmd="git -C ${LAB_DIR} status --short | wc -l" expect="0" />

## Terminé

Trois nœuds <When is="NOS" equals="srlinux">SR Linux</When><When is="NOS" equals="ceos">cEOS</When> tournent dans <V name="LAB_DIR" />, câblés spine vers leaf, joignables sur <V name="SPINE1_MGMT" />, <V name="LEAF1_MGMT" /> et <V name="LEAF2_MGMT" />, et la topologie est dans git. Tu peux détruire et reconstruire le tout en deux commandes :

```bash
cd ${LAB_DIR} && sudo containerlab destroy -t topology.clab.yml --cleanup && sudo containerlab deploy -t topology.clab.yml
```

Les adresses que tu as tapées à la main sur cette page sont les dernières que tu taperas : la page suivante modélise le lab dans NetBox, équipements, interfaces, câbles et IP, pour que la configuration puisse en être générée.
````

````yaml title="content/netdevops-lab/containerlab-topology/diagram.yaml"
# The gist: your laptop reaches one lab machine; on it, containerlab drives Docker
# to run three network OS containers wired spine-to-leaf. Quick: five boxes. Guided
# adds the topology file and the management network. Deep adds images, ports, veths.
title: { en: "One machine, three switches", fr: "Une machine, trois switches" }
caption:
  en: "containerlab reads topology.clab.yml, asks Docker for three containers running the network OS, wires them with virtual cables and puts their management interfaces on ${MGMT_SUBNET}."
  fr: "containerlab lit topology.clab.yml, demande à Docker trois conteneurs qui font tourner l'OS réseau, les câble virtuellement et met leurs interfaces de management sur ${MGMT_SUBNET}."

groups:
  - id: host
    label: { en: "Lab machine", fr: "Machine de lab" }
    desc:
      en: "One Ubuntu host with Docker and containerlab. Everything on this page runs here, in ${LAB_DIR}."
      fr: "Un hôte Ubuntu avec Docker et containerlab. Tout ce qui est sur cette page tourne ici, dans ${LAB_DIR}."
  - id: lab
    label: { en: "Lab ${LAB_NAME}", fr: "Lab ${LAB_NAME}" }
    desc:
      en: "The three containers, prefixed clab-${LAB_NAME}-, on the management network ${MGMT_SUBNET}."
      fr: "Les trois conteneurs, préfixés clab-${LAB_NAME}-, sur le réseau de management ${MGMT_SUBNET}."

nodes:
  - id: you
    kind: client
    label: { en: "Your laptop", fr: "Ton portable" }
    sub: "ssh · git"
    desc:
      en: "Where you edit and from where you ssh into the lab machine. Nothing runs here."
      fr: "Là où tu édites et d'où tu fais du ssh vers la machine de lab. Rien ne tourne ici."
  - id: topo
    kind: file
    label: { en: "topology.clab.yml", fr: "topology.clab.yml" }
    sub: "${LAB_DIR}"
    in: host
    level: guided
    desc:
      en: "The whole lab in one file: name, management subnet, three nodes, two links. Versioned in git."
      fr: "Tout le lab dans un fichier : nom, sous-réseau de management, trois nœuds, deux liens. Versionné dans git."
    deep:
      sub: "${LAB_DIR}/topology.clab.yml · kinds · nodes · links"
  - id: clab
    kind: service
    label: { en: "containerlab", fr: "containerlab" }
    sub: "deploy · inspect · destroy"
    in: host
    focus: true
    desc:
      en: "Reads the topology, creates the containers through Docker, the veth pairs for the links and the management network."
      fr: "Lit la topologie, crée les conteneurs via Docker, les paires veth pour les liens et le réseau de management."
    deep:
      sub: "v${CLAB_VERSION} · /var/run/docker.sock · veth pairs · /etc/hosts"
  - id: docker
    kind: service
    label: { en: "Docker", fr: "Docker" }
    sub: "containers · bridge"
    in: host
    level: guided
    desc:
      en: "Runs the node images and owns the management bridge network."
      fr: "Fait tourner les images des nœuds et possède le réseau bridge de management."
    deep:
      sub: "dockerd · network ${LAB_NAME}-mgmt · ${MGMT_SUBNET}"
  - id: spine1
    kind: net
    label: { en: "spine1", fr: "spine1" }
    sub: "${SPINE1_MGMT}"
    in: lab
    desc:
      en: "The spine. Two links down to the leaves, /31 on each. Management on ${SPINE1_MGMT}."
      fr: "Le spine. Deux liens vers les leaves, un /31 sur chacun. Management sur ${SPINE1_MGMT}."
    deep:
      sub: "clab-${LAB_NAME}-spine1 · ${SPINE1_MGMT} · 10.1.0.0/31 · 10.1.0.2/31"
  - id: leaf1
    kind: net
    label: { en: "leaf1", fr: "leaf1" }
    sub: "${LEAF1_MGMT}"
    in: lab
    desc:
      en: "First leaf, one link up to spine1. Management on ${LEAF1_MGMT}."
      fr: "Première leaf, un lien vers spine1. Management sur ${LEAF1_MGMT}."
    deep:
      sub: "clab-${LAB_NAME}-leaf1 · ${LEAF1_MGMT} · 10.1.0.1/31"
  - id: leaf2
    kind: net
    label: { en: "leaf2", fr: "leaf2" }
    sub: "${LEAF2_MGMT}"
    in: lab
    desc:
      en: "Second leaf, one link up to spine1. Management on ${LEAF2_MGMT}."
      fr: "Deuxième leaf, un lien vers spine1. Management sur ${LEAF2_MGMT}."
    deep:
      sub: "clab-${LAB_NAME}-leaf2 · ${LEAF2_MGMT} · 10.1.0.3/31"

edges:
  - from: you
    to: clab
    label: "ssh"
    max: quick
    desc: { en: "You ssh into the lab machine and run containerlab there.", fr: "Tu fais du ssh vers la machine de lab et y lances containerlab." }
  - from: you
    to: topo
    label: "edits"
    level: guided
    deep: { label: "ssh · git commit" }
    desc: { en: "The topology is edited and committed; containerlab only ever reads it.", fr: "La topologie est éditée et commitée ; containerlab ne fait que la lire." }
  - from: topo
    to: clab
    label: "reads"
    level: guided
    desc: { en: "deploy -t topology.clab.yml: the file is validated against containerlab's schema before anything starts.", fr: "deploy -t topology.clab.yml : le fichier est validé contre le schéma de containerlab avant que rien ne démarre." }
  - from: clab
    to: docker
    label: "Docker API"
    level: guided
    deep: { label: "unix socket · create · start · network" }
    desc: { en: "containerlab never runs an image itself: it asks Docker to.", fr: "containerlab ne lance jamais une image lui-même : il le demande à Docker." }
  - from: clab
    to: spine1
    label: "deploys"
    max: quick
    desc: { en: "Three containers from one command.", fr: "Trois conteneurs en une commande." }
  - from: docker
    to: spine1
    level: guided
    deep: { label: "image ${SRL_IMAGE}" }
    when: { is: NOS, equals: srlinux }
    desc: { en: "The node image, pulled from GitHub's registry.", fr: "L'image du nœud, tirée du registre de GitHub." }
  - from: docker
    to: spine1
    level: guided
    deep: { label: "image ${CEOS_IMAGE}" }
    when: { is: NOS, equals: ceos }
    desc: { en: "The node image, imported from Arista's tarball.", fr: "L'image du nœud, importée depuis l'archive d'Arista." }
  - from: spine1
    to: leaf1
    label: "link"
    guided: { label: "10.1.0.0/31" }
    deep: { label: "veth · e1-1 ↔ e1-1 · 10.1.0.0/31" }
    when: { is: NOS, equals: srlinux }
    desc: { en: "A veth pair between the two containers' network namespaces: the virtual cable.", fr: "Une paire veth entre les espaces de noms réseau des deux conteneurs : le câble virtuel." }
  - from: spine1
    to: leaf2
    label: "link"
    guided: { label: "10.1.0.2/31" }
    deep: { label: "veth · e1-2 ↔ e1-1 · 10.1.0.2/31" }
    when: { is: NOS, equals: srlinux }
    desc: { en: "Second virtual cable, spine1's second port.", fr: "Deuxième câble virtuel, second port de spine1." }
  - from: spine1
    to: leaf1
    label: "link"
    guided: { label: "10.1.0.0/31" }
    deep: { label: "veth · eth1 ↔ eth1 · 10.1.0.0/31" }
    when: { is: NOS, equals: ceos }
    desc: { en: "A veth pair between the two containers' network namespaces: the virtual cable.", fr: "Une paire veth entre les espaces de noms réseau des deux conteneurs : le câble virtuel." }
  - from: spine1
    to: leaf2
    label: "link"
    guided: { label: "10.1.0.2/31" }
    deep: { label: "veth · eth2 ↔ eth1 · 10.1.0.2/31" }
    when: { is: NOS, equals: ceos }
    desc: { en: "Second virtual cable, spine1's second port.", fr: "Deuxième câble virtuel, second port de spine1." }
````

---

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