# Source of "Model the lab in NetBox"

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/netbox-as-source-of-truth/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 containerlab-topology: same keys, the reader fills them once per series.

title:
  en: Model the lab in NetBox
  fr: Modéliser le lab dans NetBox
summary:
  en: >-
    What belongs in a source of truth and what does not, then a pynetbox script that creates
    the site, the devices, their interfaces, addresses and cables, and a config context with
    the BGP numbers. Safe to run twice.
  fr: >-
    Ce qui a sa place dans une source de vérité et ce qui n'y a pas, puis un script pynetbox
    qui crée le site, les équipements, leurs interfaces, adresses et câbles, et un config
    context avec les numéros BGP. Rejouable sans risque.
difficulty: intermediate
tags: [netbox, pynetbox, source-of-truth, ipam, dcim, netdevops]
authors: [thudal]
created: 2026-09-25
minutes: 40
validated: NetBox 4.4 · pynetbox 7.x · Python 3.12
status: draft             # not yet run end to end by its author

groups:
  - id: nodes
    label: { en: Nodes, fr: Nœuds }
    desc: { en: The management address of each node, as pinned in the topology., fr: L'adresse de management de chaque nœud, telle que fixée dans la topologie. }

vars:
  - key: LOOPBACK_PREFIX
    kind: cidr
    group: sot
    default: 10.0.0.0/24
    label: { en: Loopback prefix, fr: Préfixe des loopbacks }
    hint:
      en: Each node gets one /32 from it, in order (spine1 first). Also the router IDs.
      fr: Chaque nœud y prend un /32, dans l'ordre (spine1 d'abord). Ce sont aussi les router-id.
    impact:
      en: Advertised in BGP on the next page; the loopback pings are the end-to-end test of the whole series.
      fr: Annoncé en BGP à la page suivante ; les pings de loopback sont le test de bout en bout de toute la série.
  - key: ASN_BASE
    kind: text
    group: sot
    default: "65000"
    label: { en: Base AS number, fr: Numéro d'AS de base }
    hint:
      en: The spine's private AS. Leaves get the following numbers, one each.
      fr: L'AS privé du spine. Les leaves prennent les numéros suivants, un chacun.
    impact:
      en: Stored in config contexts, read by the templates of the next page. Private range 64512–65534.
      fr: Stocké dans les config contexts, lu par les templates de la page suivante. Plage privée 64512–65534.
  - 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: The address pinned in topology.clab.yml on the previous page.
      fr: L'adresse fixée dans topology.clab.yml à la page précédente.
    impact:
      en: Becomes the device's primary IP in NetBox, which is what the inventory plugins connect to.
      fr: Devient l'IP primaire de l'équipement dans NetBox, celle à laquelle les plugins d'inventaire se connectent.
  - 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: As pinned in the topology.
      fr: Telle que fixée dans la topologie.
    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: As pinned in the topology.
      fr: Telle que fixée dans la topologie.
    impact:
      en: Same role as spine1's address.
      fr: Même rôle que l'adresse de spine1.
````

````mdx title="content/netdevops-lab/netbox-as-source-of-truth/page-en.mdx"
{/* First pass — to be validated against docs.netbox.dev (data model, config contexts, REST API, GraphQL) and pynetbox.readthedocs.io before publishing. */}

The previous page typed addresses into three CLIs. That is the last time: from here on, the lab is described in NetBox, devices, interfaces, cables, addresses and the BGP numbers, and everything else is derived from it. This page decides what belongs in the source of truth, then writes one pynetbox script that creates all of it, and that you can run again tomorrow without harm.

<Run>

The script creates a virtual environment in <V name="LAB_DIR" />, writes `sot/seed.py` and runs it against <V name="NETBOX_URL" />. Run it **on the lab machine**, with `NETBOX_TOKEN` in the environment.

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetDevOps lab — seed NetBox at ${NETBOX_URL} with lab ${LAB_NAME}
cd ${LAB_DIR}
python3 -m venv .venv && .venv/bin/pip install -q pynetbox
mkdir -p sot
cat > sot/seed.py <<'EOF'
import ipaddress, os, re
import pynetbox

nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])
SITE, TAG = "${SITE_NAME}", "${LAB_NAME}"
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
MGMT_LEN = ipaddress.ip_network("${MGMT_SUBNET}").prefixlen
ASN_BASE = int("${ASN_BASE}")
EOF
```

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

```bash
cat >> sot/seed.py <<'EOF'
NOS = {"manufacturer": "Nokia", "model": "SR Linux (container)", "slug": "srlinux",
       "platform": ("Nokia SR Linux", "nokia_srl"), "mgmt": "mgmt0", "loopback": "system0",
       "ports": ["ethernet-1/1", "ethernet-1/2"]}
EOF
```

</When>

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

```bash
cat >> sot/seed.py <<'EOF'
NOS = {"manufacturer": "Arista", "model": "cEOS-lab", "slug": "ceos",
       "platform": ("Arista EOS", "arista_eos"), "mgmt": "Management0", "loopback": "Loopback0",
       "ports": ["Ethernet1", "Ethernet2"]}
EOF
```

</When>

```bash
cat >> sot/seed.py <<'EOF'
DEVICES = {"spine1": ("spine", None, "${SPINE1_MGMT}", LOOPBACKS[0]),
           "leaf1": ("leaf", ASN_BASE + 1, "${LEAF1_MGMT}", LOOPBACKS[1]),
           "leaf2": ("leaf", ASN_BASE + 2, "${LEAF2_MGMT}", LOOPBACKS[2])}
P = NOS["ports"]
LINKS = [("spine1", P[0], "10.1.0.0/31", "leaf1", P[0], "10.1.0.1/31"),
         ("spine1", P[1], "10.1.0.2/31", "leaf2", P[0], "10.1.0.3/31")]
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
tag = ensure(nb.extras.tags, {"slug": TAG}, name=TAG, slug=TAG)
site = ensure(nb.dcim.sites, {"slug": slug(SITE)}, name=SITE, slug=slug(SITE), status="active")
mfr = ensure(nb.dcim.manufacturers, {"slug": slug(NOS["manufacturer"])}, name=NOS["manufacturer"], slug=slug(NOS["manufacturer"]))
dtype = ensure(nb.dcim.device_types, {"slug": NOS["slug"]}, manufacturer=mfr.id, model=NOS["model"], slug=NOS["slug"])
plat = ensure(nb.dcim.platforms, {"slug": NOS["platform"][1]}, name=NOS["platform"][0], slug=NOS["platform"][1], manufacturer=mfr.id)
roles = {r: ensure(nb.dcim.device_roles, {"slug": r}, name=r, slug=r, color=c) for r, c in (("spine", "2196f3"), ("leaf", "4caf50"))}
for p in ("${MGMT_SUBNET}", "${LOOPBACK_PREFIX}", "10.1.0.0/24"):
    ensure(nb.ipam.prefixes, {"prefix": p}, prefix=p, site=site.id, status="active", tags=[tag.id])
def iface(dev, name, **extra):
    return ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": name}, device=dev.id, name=name, type="1000base-t", **extra)
def address(i, addr):
    return ensure(nb.ipam.ip_addresses, {"address": addr}, address=addr, status="active",
                  assigned_object_type="dcim.interface", assigned_object_id=i.id, tags=[tag.id])
devices = {}
for name, (role, asn, mgmt, lo) in DEVICES.items():
    dev = ensure(nb.dcim.devices, {"name": name, "site_id": site.id}, name=name, site=site.id, role=roles[role].id,
                 device_type=dtype.id, platform=plat.id, status="active", tags=[tag.id])
    mgmt_ip = address(iface(dev, NOS["mgmt"], mgmt_only=True), f"{mgmt}/{MGMT_LEN}")
    address(iface(dev, NOS["loopback"], type="virtual"), f"{lo}/32")
    if dev.primary_ip4 is None or dev.primary_ip4.id != mgmt_ip.id:
        dev.update({"primary_ip4": mgmt_ip.id})
    if asn and (dev.local_context_data or {}).get("bgp", {}).get("asn") != asn:
        dev.update({"local_context_data": {"bgp": {"asn": asn}}})
    devices[name] = dev
for a_dev, a_port, a_addr, b_dev, b_port, b_addr in LINKS:
    a, b = iface(devices[a_dev], a_port), iface(devices[b_dev], b_port)
    address(a, a_addr); address(b, b_addr)
    if a.cable is None:
        nb.dcim.cables.create(a_terminations=[{"object_type": "dcim.interface", "object_id": a.id}],
                              b_terminations=[{"object_type": "dcim.interface", "object_id": b.id}], status="connected", tags=[tag.id])
ensure(nb.extras.config_contexts, {"name": "bgp-spine"}, name="bgp-spine", roles=[roles["spine"].id], data={"bgp": {"asn": ASN_BASE, "peer_group": "leaves"}})
ensure(nb.extras.config_contexts, {"name": "bgp-leaf"}, name="bgp-leaf", roles=[roles["leaf"].id], data={"bgp": {"peer_group": "spines"}})
print(f"ok: {len(devices)} devices, {len(LINKS)} cables, site {site.name}")
EOF
.venv/bin/python sot/seed.py
git add sot && git -c user.name=lab -c user.email=lab@localhost commit -qm "sot: seed script" || true
```

<Warn>The script creates objects under site <V name="SITE_NAME" /> and tag <V name="LAB_NAME" /> only, but it runs with your token's full rights. On a NetBox that also holds production, give the lab its own user and a token limited to that site.</Warn>

</Run>

## Before you start

<Guided>You need a NetBox 4.x reachable from the lab machine (the NetBox series on this site installs one), a token with write access to DCIM, IPAM and extras (config contexts and tags), and Python 3.10 or newer on the lab machine. pynetbox goes in a virtual environment inside the repository; the token goes in the environment, never in a file.</Guided>

```bash
cd ${LAB_DIR}
python3 -m venv .venv
source .venv/bin/activate
pip install pynetbox
export NETBOX_TOKEN=${NETBOX_TOKEN}   # for this shell only; a secret manager for anything longer-lived
```

<Check cmd="pip show pynetbox | head -1" expect="Name: pynetbox" />

<Check cmd={"curl -sf -o /dev/null -w '%{http_code}' -H \"Authorization: Token $NETBOX_TOKEN\" ${NETBOX_URL}/api/dcim/sites/"} expect="200" />

<Note>Self-signed certificate on NetBox? Point `REQUESTS_CA_BUNDLE` at its certificate, or, for a lab only, add `nb.http_session.verify = False` after creating the client. `curl` takes `-k`.</Note>

## What to model, and what not

<Guided>A source of truth holds **intent**: what the network should be. It does not hold **state**: what it currently is, which the network itself knows better. The line is easy to draw for a lab and the same one applies to production.</Guided>

| Belongs in NetBox | Stays out |
|---|---|
| Sites, devices, roles, device types, platforms | Serial numbers of containers, uptime |
| Interfaces, cables between them | Interface counters, link state |
| Prefixes, the /31 and loopback addresses, the primary IP | ARP tables, routes learned |
| AS numbers and BGP groups (config context) | BGP session state |
| A tag that says "this belongs to the lab" | The rendered configuration itself |

<Deep>The last row matters most: NetBox stores *what* the network should look like, never the vendor configuration text. The configuration is a function of the model and a template; store it in NetBox and you have two sources that drift. The same goes for anything the lab reports (`show` output, test results): it lives in the pipeline's artifacts on the last page, not here. Config contexts are the grey zone: they carry structured parameters (ASNs, peer groups, timers) that have no dedicated model. Use them for numbers a template consumes, not for free text. When a parameter appears in every device of a role, it goes in a context attached to the role; when it is unique to one device, in that device's local context. NetBox deep-merges the two, role first, device last.</Deep>

## The seed script

<Guided>One file, `sot/seed.py`, runs top to bottom: the client, the constants of the lab, then each object in dependency order. Every object goes through `ensure()`, get by its unique fields or create: running the script twice creates nothing twice. Interface and platform names depend on the network OS, hence the `NOS` dictionary.</Guided>

<Annotated>

```python title="${LAB_DIR}/sot/seed.py" {4,9-12,24,40,42,47,50-51}
import ipaddress, os, re
import pynetbox

nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])   # (1)
SITE, TAG = "${SITE_NAME}", "${LAB_NAME}"
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
MGMT_LEN = ipaddress.ip_network("${MGMT_SUBNET}").prefixlen
ASN_BASE = int("${ASN_BASE}")
NOS = …                                                               # (2)
DEVICES = {"spine1": ("spine", None, "${SPINE1_MGMT}", LOOPBACKS[0]),  # (3)
           "leaf1": ("leaf", ASN_BASE + 1, "${LEAF1_MGMT}", LOOPBACKS[1]),
           "leaf2": ("leaf", ASN_BASE + 2, "${LEAF2_MGMT}", LOOPBACKS[2])}
P = NOS["ports"]
LINKS = [("spine1", P[0], "10.1.0.0/31", "leaf1", P[0], "10.1.0.1/31"),
         ("spine1", P[1], "10.1.0.2/31", "leaf2", P[0], "10.1.0.3/31")]
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
tag = ensure(nb.extras.tags, {"slug": TAG}, name=TAG, slug=TAG)
site = ensure(nb.dcim.sites, {"slug": slug(SITE)}, name=SITE, slug=slug(SITE), status="active")
mfr = ensure(nb.dcim.manufacturers, {"slug": slug(NOS["manufacturer"])}, name=NOS["manufacturer"], slug=slug(NOS["manufacturer"]))
dtype = ensure(nb.dcim.device_types, {"slug": NOS["slug"]}, manufacturer=mfr.id, model=NOS["model"], slug=NOS["slug"])
plat = ensure(nb.dcim.platforms, {"slug": NOS["platform"][1]}, name=NOS["platform"][0], slug=NOS["platform"][1], manufacturer=mfr.id)  # (4)
roles = {r: ensure(nb.dcim.device_roles, {"slug": r}, name=r, slug=r, color=c) for r, c in (("spine", "2196f3"), ("leaf", "4caf50"))}
for p in ("${MGMT_SUBNET}", "${LOOPBACK_PREFIX}", "10.1.0.0/24"):
    ensure(nb.ipam.prefixes, {"prefix": p}, prefix=p, site=site.id, status="active", tags=[tag.id])
def iface(dev, name, **extra):
    return ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": name}, device=dev.id, name=name, type="1000base-t", **extra)
def address(i, addr):
    return ensure(nb.ipam.ip_addresses, {"address": addr}, address=addr, status="active",
                  assigned_object_type="dcim.interface", assigned_object_id=i.id, tags=[tag.id])
devices = {}
for name, (role, asn, mgmt, lo) in DEVICES.items():
    dev = ensure(nb.dcim.devices, {"name": name, "site_id": site.id}, name=name, site=site.id, role=roles[role].id,
                 device_type=dtype.id, platform=plat.id, status="active", tags=[tag.id])
    mgmt_ip = address(iface(dev, NOS["mgmt"], mgmt_only=True), f"{mgmt}/{MGMT_LEN}")
    address(iface(dev, NOS["loopback"], type="virtual"), f"{lo}/32")
    if dev.primary_ip4 is None or dev.primary_ip4.id != mgmt_ip.id:
        dev.update({"primary_ip4": mgmt_ip.id})                                  # (5)
    if asn and (dev.local_context_data or {}).get("bgp", {}).get("asn") != asn:
        dev.update({"local_context_data": {"bgp": {"asn": asn}}})               # (6)
    devices[name] = dev
for a_dev, a_port, a_addr, b_dev, b_port, b_addr in LINKS:
    a, b = iface(devices[a_dev], a_port), iface(devices[b_dev], b_port)
    address(a, a_addr); address(b, b_addr)
    if a.cable is None:                                                          # (7)
        nb.dcim.cables.create(a_terminations=[{"object_type": "dcim.interface", "object_id": a.id}],
                              b_terminations=[{"object_type": "dcim.interface", "object_id": b.id}], status="connected", tags=[tag.id])
ensure(nb.extras.config_contexts, {"name": "bgp-spine"}, name="bgp-spine", roles=[roles["spine"].id], data={"bgp": {"asn": ASN_BASE, "peer_group": "leaves"}})  # (8)
ensure(nb.extras.config_contexts, {"name": "bgp-leaf"}, name="bgp-leaf", roles=[roles["leaf"].id], data={"bgp": {"peer_group": "spines"}})
print(f"ok: {len(devices)} devices, {len(LINKS)} cables, site {site.name}")
```

1. The URL is a lab constant; the token comes from the environment so the file can be committed.
2. The OS-specific names, shown below. Everything after this line is vendor-neutral.
3. Per device: role, its own AS (none for the spine, whose AS comes from the role), management IP, loopback taken in order from <V name="LOOPBACK_PREFIX" />.
4. The platform slug is what the inventory plugins of the next page hand to the automation tool as the driver name: `nokia_srl` or `arista_eos`, not a pretty name.
5. The primary IP is what the inventory plugins connect to. It must be assigned to an interface of that device first, hence the order.
6. A per-device config context: only the leaves get one, with their own AS. `update()` is skipped when the value is already there, so the change log stays quiet on a rerun.
7. A cable is created once, checked from its A side. NetBox 4 cables take lists of terminations, which is how breakout and multi-strand cables are modelled; here each list has one interface.
8. Role-level contexts: every spine shares AS <V name="ASN_BASE" />, every leaf peers with the group `spines`. The device's *rendered* context is the merge of both levels.

</Annotated>

The `NOS` line for your network OS:

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

```python
NOS = {"manufacturer": "Nokia", "model": "SR Linux (container)", "slug": "srlinux",
       "platform": ("Nokia SR Linux", "nokia_srl"), "mgmt": "mgmt0", "loopback": "system0",
       "ports": ["ethernet-1/1", "ethernet-1/2"]}
```

<Deep>`system0` is SR Linux's loopback that carries the system address and the default router ID, the equivalent of `Loopback0`; plain `lo0` interfaces exist too. `mgmt0` lives in the `mgmt` network-instance, and `mgmt_only=True` in NetBox is how the templates of the next page know to leave it alone. Interface names must match the NOS exactly (`ethernet-1/1`, not `e1-1`): the rendered configuration uses them verbatim.</Deep>

</When>

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

```python
NOS = {"manufacturer": "Arista", "model": "cEOS-lab", "slug": "ceos",
       "platform": ("Arista EOS", "arista_eos"), "mgmt": "Management0", "loopback": "Loopback0",
       "ports": ["Ethernet1", "Ethernet2"]}
```

<Deep>`Management0` is the interface containerlab configures with the management IP; `mgmt_only=True` in NetBox is how the templates of the next page know to leave it alone. Interface names must match EOS exactly (`Ethernet1`, capital E, no space): the rendered configuration uses them verbatim.</Deep>

</When>

<Deep>Why a different AS per leaf when the brief could be "one AS per role"? eBGP drops a route whose AS path already contains the receiver's AS. With both leaves in AS <V name="ASN_BASE" />+1, leaf2 would refuse leaf1's loopback as a loop and the end-to-end ping of the next page would fail. Spines can share an AS because they never learn routes from each other. The rendered context makes that visible: `spine1` shows `bgp.asn` from the role context, `leaf1` shows its own from the local context, and both show `peer_group`.</Deep>

## Run it, twice

<Guided>First run creates everything and prints a summary; second run finds everything and prints the same summary, with nothing new in NetBox's change log. That second run is the test that the script is safe to put in a pipeline.</Guided>

```bash
cd ${LAB_DIR}
.venv/bin/python sot/seed.py
.venv/bin/python sot/seed.py
git add sot/seed.py && git commit -m "sot: seed NetBox with the lab"
```

<Check cmd="cd ${LAB_DIR} && .venv/bin/python sot/seed.py" expect="ok: 3 devices, 2 cables, site ${SITE_NAME}" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?tag=${LAB_NAME}' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="3" />

<Details summary="If it fails halfway">
pynetbox raises with NetBox's own error message, which names the field. The usual ones: a `400` on the device type means the slug already exists under another manufacturer; a `400` on an IP address means it is already assigned elsewhere (a previous experiment, maybe); a `403` means the token lacks a permission on that model. Fix the cause and rerun: everything already created is found, not recreated.
</Details>

## Check the model

<Guided>In the UI, **Devices → Devices**, filter on tag <V name="LAB_NAME" />: three rows, each with a primary IP. Open `spine1`: the **Interfaces** tab shows four interfaces, two of them cabled with the far end named; the **Config context** tab shows the merged data with `bgp.asn`. The same through the API:</Guided>

```bash
api=${NETBOX_URL}/api
auth="Authorization: Token $NETBOX_TOKEN"
curl -s -H "$auth" "$api/dcim/devices/?tag=${LAB_NAME}&brief=1" | python3 -m json.tool
curl -s -H "$auth" "$api/dcim/interfaces/?device=spine1&cabled=true" | python3 -m json.tool | grep -E '"name"|"address"'
curl -s -H "$auth" "$api/dcim/devices/?name=spine1" | python3 -c 'import sys, json; print(json.load(sys.stdin)["results"][0]["config_context"])'
```

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?tag=${LAB_NAME}&has_primary_ip=true' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="3" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/cables/?tag=${LAB_NAME}' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="2" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?name=spine1' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"results\"][0][\"config_context\"][\"bgp\"][\"asn\"])'"} expect="${ASN_BASE}" />

<Deep>

The REST answer for a device is flat: interfaces, addresses and cables are separate endpoints, three calls and a join in your head. GraphQL returns the tree in one query, which is what a renderer wants:

```bash
curl -s -H "Authorization: Token $NETBOX_TOKEN" -H 'Content-Type: application/json' ${NETBOX_URL}/graphql/ -d @- <<'EOF' | python3 -m json.tool
{"query": "{ device_list(filters: {tag: [\"${LAB_NAME}\"]}) { name role { slug } primary_ip4 { address } config_context
  interfaces { name mgmt_only ip_addresses { address } link_peers { ... on InterfaceType { name device { name } } } } } }"}
EOF
```

`link_peers` is the far end of the cable, so one query gives each device its interfaces, their addresses, and who sits on the other side: everything the BGP template of the next page needs. The filter syntax changed in NetBox 4.3 (nested `{slug: {exact: …}}` objects instead of plain values); the explorer at **<V name="NETBOX_URL" />/graphql/** autocompletes the right one for your version. GraphQL is read-only; writes stay on REST.

</Deep>

## Done

NetBox now holds the lab: site <V name="SITE_NAME" />, three devices tagged <V name="LAB_NAME" /> with a primary IP each, their interfaces, two cables, the /31s, the loopbacks from <V name="LOOPBACK_PREFIX" />, and the BGP numbers in config contexts. The script that built it is in git and can be rerun at will; on the last page, a nightly pipeline does exactly that.

The next page turns this model into configuration: an inventory read from NetBox, one template per network OS, a renderer and a push, until BGP is established between the spine and its leaves.
````

````mdx title="content/netdevops-lab/netbox-as-source-of-truth/page-fr.mdx"
{/* Première passe — à valider contre docs.netbox.dev (modèle de données, config contexts, API REST, GraphQL) et pynetbox.readthedocs.io avant publication. */}

La page précédente a tapé des adresses dans trois CLI. C'est la dernière fois : à partir d'ici, le lab est décrit dans NetBox, équipements, interfaces, câbles, adresses et numéros BGP, et tout le reste en dérive. Cette page décide de ce qui a sa place dans la source de vérité, puis écrit un script pynetbox qui crée tout ça, et que tu peux relancer demain sans dégât.

<Run>

Le script crée un environnement virtuel dans <V name="LAB_DIR" />, écrit `sot/seed.py` et le lance contre <V name="NETBOX_URL" />. Lance-le **sur la machine de lab**, avec `NETBOX_TOKEN` dans l'environnement.

```bash
#!/usr/bin/env bash
set -euo pipefail
# Lab NetDevOps — seed de NetBox sur ${NETBOX_URL} avec le lab ${LAB_NAME}
cd ${LAB_DIR}
python3 -m venv .venv && .venv/bin/pip install -q pynetbox
mkdir -p sot
cat > sot/seed.py <<'EOF'
import ipaddress, os, re
import pynetbox

nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])
SITE, TAG = "${SITE_NAME}", "${LAB_NAME}"
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
MGMT_LEN = ipaddress.ip_network("${MGMT_SUBNET}").prefixlen
ASN_BASE = int("${ASN_BASE}")
EOF
```

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

```bash
cat >> sot/seed.py <<'EOF'
NOS = {"manufacturer": "Nokia", "model": "SR Linux (container)", "slug": "srlinux",
       "platform": ("Nokia SR Linux", "nokia_srl"), "mgmt": "mgmt0", "loopback": "system0",
       "ports": ["ethernet-1/1", "ethernet-1/2"]}
EOF
```

</When>

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

```bash
cat >> sot/seed.py <<'EOF'
NOS = {"manufacturer": "Arista", "model": "cEOS-lab", "slug": "ceos",
       "platform": ("Arista EOS", "arista_eos"), "mgmt": "Management0", "loopback": "Loopback0",
       "ports": ["Ethernet1", "Ethernet2"]}
EOF
```

</When>

```bash
cat >> sot/seed.py <<'EOF'
DEVICES = {"spine1": ("spine", None, "${SPINE1_MGMT}", LOOPBACKS[0]),
           "leaf1": ("leaf", ASN_BASE + 1, "${LEAF1_MGMT}", LOOPBACKS[1]),
           "leaf2": ("leaf", ASN_BASE + 2, "${LEAF2_MGMT}", LOOPBACKS[2])}
P = NOS["ports"]
LINKS = [("spine1", P[0], "10.1.0.0/31", "leaf1", P[0], "10.1.0.1/31"),
         ("spine1", P[1], "10.1.0.2/31", "leaf2", P[0], "10.1.0.3/31")]
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
tag = ensure(nb.extras.tags, {"slug": TAG}, name=TAG, slug=TAG)
site = ensure(nb.dcim.sites, {"slug": slug(SITE)}, name=SITE, slug=slug(SITE), status="active")
mfr = ensure(nb.dcim.manufacturers, {"slug": slug(NOS["manufacturer"])}, name=NOS["manufacturer"], slug=slug(NOS["manufacturer"]))
dtype = ensure(nb.dcim.device_types, {"slug": NOS["slug"]}, manufacturer=mfr.id, model=NOS["model"], slug=NOS["slug"])
plat = ensure(nb.dcim.platforms, {"slug": NOS["platform"][1]}, name=NOS["platform"][0], slug=NOS["platform"][1], manufacturer=mfr.id)
roles = {r: ensure(nb.dcim.device_roles, {"slug": r}, name=r, slug=r, color=c) for r, c in (("spine", "2196f3"), ("leaf", "4caf50"))}
for p in ("${MGMT_SUBNET}", "${LOOPBACK_PREFIX}", "10.1.0.0/24"):
    ensure(nb.ipam.prefixes, {"prefix": p}, prefix=p, site=site.id, status="active", tags=[tag.id])
def iface(dev, name, **extra):
    return ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": name}, device=dev.id, name=name, type="1000base-t", **extra)
def address(i, addr):
    return ensure(nb.ipam.ip_addresses, {"address": addr}, address=addr, status="active",
                  assigned_object_type="dcim.interface", assigned_object_id=i.id, tags=[tag.id])
devices = {}
for name, (role, asn, mgmt, lo) in DEVICES.items():
    dev = ensure(nb.dcim.devices, {"name": name, "site_id": site.id}, name=name, site=site.id, role=roles[role].id,
                 device_type=dtype.id, platform=plat.id, status="active", tags=[tag.id])
    mgmt_ip = address(iface(dev, NOS["mgmt"], mgmt_only=True), f"{mgmt}/{MGMT_LEN}")
    address(iface(dev, NOS["loopback"], type="virtual"), f"{lo}/32")
    if dev.primary_ip4 is None or dev.primary_ip4.id != mgmt_ip.id:
        dev.update({"primary_ip4": mgmt_ip.id})
    if asn and (dev.local_context_data or {}).get("bgp", {}).get("asn") != asn:
        dev.update({"local_context_data": {"bgp": {"asn": asn}}})
    devices[name] = dev
for a_dev, a_port, a_addr, b_dev, b_port, b_addr in LINKS:
    a, b = iface(devices[a_dev], a_port), iface(devices[b_dev], b_port)
    address(a, a_addr); address(b, b_addr)
    if a.cable is None:
        nb.dcim.cables.create(a_terminations=[{"object_type": "dcim.interface", "object_id": a.id}],
                              b_terminations=[{"object_type": "dcim.interface", "object_id": b.id}], status="connected", tags=[tag.id])
ensure(nb.extras.config_contexts, {"name": "bgp-spine"}, name="bgp-spine", roles=[roles["spine"].id], data={"bgp": {"asn": ASN_BASE, "peer_group": "leaves"}})
ensure(nb.extras.config_contexts, {"name": "bgp-leaf"}, name="bgp-leaf", roles=[roles["leaf"].id], data={"bgp": {"peer_group": "spines"}})
print(f"ok: {len(devices)} devices, {len(LINKS)} cables, site {site.name}")
EOF
.venv/bin/python sot/seed.py
git add sot && git -c user.name=lab -c user.email=lab@localhost commit -qm "sot: seed script" || true
```

<Warn>Le script ne crée des objets que sous le site <V name="SITE_NAME" /> et le tag <V name="LAB_NAME" />, mais il tourne avec tous les droits de ton jeton. Sur un NetBox qui contient aussi la production, donne au lab son propre utilisateur et un jeton limité à ce site.</Warn>

</Run>

## Avant de commencer

<Guided>Il te faut un NetBox 4.x joignable depuis la machine de lab (la série NetBox de ce site en installe un), un jeton avec les droits d'écriture sur DCIM, IPAM et extras (config contexts et tags), et Python 3.10 ou plus récent sur la machine de lab. pynetbox va dans un environnement virtuel dans le dépôt ; le jeton va dans l'environnement, jamais dans un fichier.</Guided>

```bash
cd ${LAB_DIR}
python3 -m venv .venv
source .venv/bin/activate
pip install pynetbox
export NETBOX_TOKEN=${NETBOX_TOKEN}   # pour ce shell seulement ; un gestionnaire de secrets pour tout ce qui dure
```

<Check cmd="pip show pynetbox | head -1" expect="Name: pynetbox" />

<Check cmd={"curl -sf -o /dev/null -w '%{http_code}' -H \"Authorization: Token $NETBOX_TOKEN\" ${NETBOX_URL}/api/dcim/sites/"} expect="200" />

<Note>Certificat auto-signé sur NetBox ? Pointe `REQUESTS_CA_BUNDLE` vers son certificat, ou, pour un lab seulement, ajoute `nb.http_session.verify = False` après la création du client. `curl` prend `-k`.</Note>

## Quoi modéliser, et quoi laisser dehors

<Guided>Une source de vérité contient l'**intention** : ce que le réseau doit être. Elle ne contient pas l'**état** : ce qu'il est en ce moment, que le réseau lui-même connaît mieux. La ligne est facile à tracer pour un lab et c'est la même en production.</Guided>

| Va dans NetBox | Reste dehors |
|---|---|
| Sites, équipements, rôles, types, plateformes | Numéros de série des conteneurs, uptime |
| Interfaces, câbles entre elles | Compteurs d'interface, état des liens |
| Préfixes, les /31 et les loopbacks, l'IP primaire | Tables ARP, routes apprises |
| Numéros d'AS et groupes BGP (config context) | État des sessions BGP |
| Un tag qui dit « ceci appartient au lab » | La configuration rendue elle-même |

<Deep>La dernière ligne est la plus importante : NetBox stocke *à quoi* le réseau doit ressembler, jamais le texte de configuration constructeur. La configuration est une fonction du modèle et d'un template ; stocke-la dans NetBox et tu as deux sources qui dérivent. Même chose pour tout ce que le lab rapporte (sorties de `show`, résultats de tests) : ça vit dans les artefacts du pipeline à la dernière page, pas ici. Les config contexts sont la zone grise : ils portent des paramètres structurés (AS, groupes de pairs, timers) qui n'ont pas de modèle dédié. Utilise-les pour des nombres qu'un template consomme, pas pour du texte libre. Quand un paramètre vaut pour tous les équipements d'un rôle, il va dans un contexte attaché au rôle ; quand il est propre à un équipement, dans le contexte local de cet équipement. NetBox fusionne les deux en profondeur, le rôle d'abord, l'équipement en dernier.</Deep>

## Le script de seed

<Guided>Un fichier, `sot/seed.py`, se lit de haut en bas : le client, les constantes du lab, puis chaque objet dans l'ordre des dépendances. Chaque objet passe par `ensure()`, chercher par ses champs uniques ou créer : lancer le script deux fois ne crée rien deux fois. Les noms d'interfaces et de plateforme dépendent de l'OS réseau, d'où le dictionnaire `NOS`.</Guided>

<Annotated>

```python title="${LAB_DIR}/sot/seed.py" {4,9-12,24,40,42,47,50-51}
import ipaddress, os, re
import pynetbox

nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])   # (1)
SITE, TAG = "${SITE_NAME}", "${LAB_NAME}"
LOOPBACKS = list(ipaddress.ip_network("${LOOPBACK_PREFIX}").hosts())
MGMT_LEN = ipaddress.ip_network("${MGMT_SUBNET}").prefixlen
ASN_BASE = int("${ASN_BASE}")
NOS = …                                                               # (2)
DEVICES = {"spine1": ("spine", None, "${SPINE1_MGMT}", LOOPBACKS[0]),  # (3)
           "leaf1": ("leaf", ASN_BASE + 1, "${LEAF1_MGMT}", LOOPBACKS[1]),
           "leaf2": ("leaf", ASN_BASE + 2, "${LEAF2_MGMT}", LOOPBACKS[2])}
P = NOS["ports"]
LINKS = [("spine1", P[0], "10.1.0.0/31", "leaf1", P[0], "10.1.0.1/31"),
         ("spine1", P[1], "10.1.0.2/31", "leaf2", P[0], "10.1.0.3/31")]
slug = lambda s: re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
def ensure(endpoint, lookup, **fields):
    obj = endpoint.get(**lookup)
    return obj if obj is not None else endpoint.create(**fields)
tag = ensure(nb.extras.tags, {"slug": TAG}, name=TAG, slug=TAG)
site = ensure(nb.dcim.sites, {"slug": slug(SITE)}, name=SITE, slug=slug(SITE), status="active")
mfr = ensure(nb.dcim.manufacturers, {"slug": slug(NOS["manufacturer"])}, name=NOS["manufacturer"], slug=slug(NOS["manufacturer"]))
dtype = ensure(nb.dcim.device_types, {"slug": NOS["slug"]}, manufacturer=mfr.id, model=NOS["model"], slug=NOS["slug"])
plat = ensure(nb.dcim.platforms, {"slug": NOS["platform"][1]}, name=NOS["platform"][0], slug=NOS["platform"][1], manufacturer=mfr.id)  # (4)
roles = {r: ensure(nb.dcim.device_roles, {"slug": r}, name=r, slug=r, color=c) for r, c in (("spine", "2196f3"), ("leaf", "4caf50"))}
for p in ("${MGMT_SUBNET}", "${LOOPBACK_PREFIX}", "10.1.0.0/24"):
    ensure(nb.ipam.prefixes, {"prefix": p}, prefix=p, site=site.id, status="active", tags=[tag.id])
def iface(dev, name, **extra):
    return ensure(nb.dcim.interfaces, {"device_id": dev.id, "name": name}, device=dev.id, name=name, type="1000base-t", **extra)
def address(i, addr):
    return ensure(nb.ipam.ip_addresses, {"address": addr}, address=addr, status="active",
                  assigned_object_type="dcim.interface", assigned_object_id=i.id, tags=[tag.id])
devices = {}
for name, (role, asn, mgmt, lo) in DEVICES.items():
    dev = ensure(nb.dcim.devices, {"name": name, "site_id": site.id}, name=name, site=site.id, role=roles[role].id,
                 device_type=dtype.id, platform=plat.id, status="active", tags=[tag.id])
    mgmt_ip = address(iface(dev, NOS["mgmt"], mgmt_only=True), f"{mgmt}/{MGMT_LEN}")
    address(iface(dev, NOS["loopback"], type="virtual"), f"{lo}/32")
    if dev.primary_ip4 is None or dev.primary_ip4.id != mgmt_ip.id:
        dev.update({"primary_ip4": mgmt_ip.id})                                  # (5)
    if asn and (dev.local_context_data or {}).get("bgp", {}).get("asn") != asn:
        dev.update({"local_context_data": {"bgp": {"asn": asn}}})               # (6)
    devices[name] = dev
for a_dev, a_port, a_addr, b_dev, b_port, b_addr in LINKS:
    a, b = iface(devices[a_dev], a_port), iface(devices[b_dev], b_port)
    address(a, a_addr); address(b, b_addr)
    if a.cable is None:                                                          # (7)
        nb.dcim.cables.create(a_terminations=[{"object_type": "dcim.interface", "object_id": a.id}],
                              b_terminations=[{"object_type": "dcim.interface", "object_id": b.id}], status="connected", tags=[tag.id])
ensure(nb.extras.config_contexts, {"name": "bgp-spine"}, name="bgp-spine", roles=[roles["spine"].id], data={"bgp": {"asn": ASN_BASE, "peer_group": "leaves"}})  # (8)
ensure(nb.extras.config_contexts, {"name": "bgp-leaf"}, name="bgp-leaf", roles=[roles["leaf"].id], data={"bgp": {"peer_group": "spines"}})
print(f"ok: {len(devices)} devices, {len(LINKS)} cables, site {site.name}")
```

1. L'URL est une constante du lab ; le jeton vient de l'environnement pour que le fichier puisse être commité.
2. Les noms propres à l'OS, montrés plus bas. Tout ce qui suit cette ligne est indépendant du constructeur.
3. Par équipement : rôle, son propre AS (aucun pour le spine, dont l'AS vient du rôle), IP de management, loopback prise dans l'ordre dans <V name="LOOPBACK_PREFIX" />.
4. Le slug de la plateforme est ce que les plugins d'inventaire de la page suivante donnent à l'outil d'automatisation comme nom de driver : `nokia_srl` ou `arista_eos`, pas un joli nom.
5. L'IP primaire est celle à laquelle les plugins d'inventaire se connectent. Elle doit d'abord être assignée à une interface de cet équipement, d'où l'ordre.
6. Un config context par équipement : seules les leaves en ont un, avec leur propre AS. `update()` est sauté quand la valeur est déjà là, le journal des changements reste silencieux au second passage.
7. Un câble est créé une fois, vérifié depuis son côté A. Les câbles de NetBox 4 prennent des listes de terminaisons, c'est ainsi qu'on modélise les breakouts et les câbles multi-brins ; ici chaque liste a une interface.
8. Les contextes au niveau du rôle : tous les spines partagent l'AS <V name="ASN_BASE" />, toutes les leaves peerent avec le groupe `spines`. Le contexte *rendu* d'un équipement est la fusion des deux niveaux.

</Annotated>

La ligne `NOS` pour ton OS réseau :

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

```python
NOS = {"manufacturer": "Nokia", "model": "SR Linux (container)", "slug": "srlinux",
       "platform": ("Nokia SR Linux", "nokia_srl"), "mgmt": "mgmt0", "loopback": "system0",
       "ports": ["ethernet-1/1", "ethernet-1/2"]}
```

<Deep>`system0` est la loopback de SR Linux qui porte l'adresse système et le router-id par défaut, l'équivalent de `Loopback0` ; des interfaces `lo0` ordinaires existent aussi. `mgmt0` vit dans la network-instance `mgmt`, et `mgmt_only=True` dans NetBox est ce qui dit aux templates de la page suivante de ne pas y toucher. Les noms d'interfaces doivent correspondre exactement à ceux du NOS (`ethernet-1/1`, pas `e1-1`) : la configuration rendue les utilise tels quels.</Deep>

</When>

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

```python
NOS = {"manufacturer": "Arista", "model": "cEOS-lab", "slug": "ceos",
       "platform": ("Arista EOS", "arista_eos"), "mgmt": "Management0", "loopback": "Loopback0",
       "ports": ["Ethernet1", "Ethernet2"]}
```

<Deep>`Management0` est l'interface que containerlab configure avec l'IP de management ; `mgmt_only=True` dans NetBox est ce qui dit aux templates de la page suivante de ne pas y toucher. Les noms d'interfaces doivent correspondre exactement à ceux d'EOS (`Ethernet1`, E majuscule, sans espace) : la configuration rendue les utilise tels quels.</Deep>

</When>

<Deep>Pourquoi un AS différent par leaf alors qu'on pourrait dire « un AS par rôle » ? eBGP rejette une route dont le chemin d'AS contient déjà l'AS du récepteur. Avec les deux leaves dans l'AS <V name="ASN_BASE" />+1, leaf2 refuserait la loopback de leaf1 comme une boucle et le ping de bout en bout de la page suivante échouerait. Les spines peuvent partager un AS parce qu'ils n'apprennent jamais de routes l'un de l'autre. Le contexte rendu le rend visible : `spine1` montre `bgp.asn` venu du contexte de rôle, `leaf1` montre le sien venu du contexte local, et les deux montrent `peer_group`.</Deep>

## Le lancer, deux fois

<Guided>Le premier passage crée tout et affiche un résumé ; le second retrouve tout et affiche le même résumé, sans rien de neuf dans le journal des changements de NetBox. Ce second passage est le test qui prouve que le script peut aller dans un pipeline.</Guided>

```bash
cd ${LAB_DIR}
.venv/bin/python sot/seed.py
.venv/bin/python sot/seed.py
git add sot/seed.py && git commit -m "sot: seed NetBox with the lab"
```

<Check cmd="cd ${LAB_DIR} && .venv/bin/python sot/seed.py" expect="ok: 3 devices, 2 cables, site ${SITE_NAME}" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?tag=${LAB_NAME}' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="3" />

<Details summary="S'il s'arrête en route">
pynetbox remonte le message d'erreur de NetBox lui-même, qui nomme le champ. Les classiques : un `400` sur le type d'équipement veut dire que le slug existe déjà sous un autre fabricant ; un `400` sur une adresse IP veut dire qu'elle est déjà assignée ailleurs (une expérience précédente, peut-être) ; un `403` veut dire que le jeton n'a pas la permission sur ce modèle. Corrige la cause et relance : tout ce qui est déjà créé est retrouvé, pas recréé.
</Details>

## Vérifier le modèle

<Guided>Dans l'interface, **Devices → Devices**, filtre sur le tag <V name="LAB_NAME" /> : trois lignes, chacune avec une IP primaire. Ouvre `spine1` : l'onglet **Interfaces** montre quatre interfaces, dont deux câblées avec le nom du bout d'en face ; l'onglet **Config context** montre les données fusionnées avec `bgp.asn`. La même chose par l'API :</Guided>

```bash
api=${NETBOX_URL}/api
auth="Authorization: Token $NETBOX_TOKEN"
curl -s -H "$auth" "$api/dcim/devices/?tag=${LAB_NAME}&brief=1" | python3 -m json.tool
curl -s -H "$auth" "$api/dcim/interfaces/?device=spine1&cabled=true" | python3 -m json.tool | grep -E '"name"|"address"'
curl -s -H "$auth" "$api/dcim/devices/?name=spine1" | python3 -c 'import sys, json; print(json.load(sys.stdin)["results"][0]["config_context"])'
```

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?tag=${LAB_NAME}&has_primary_ip=true' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="3" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/cables/?tag=${LAB_NAME}' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"count\"])'"} expect="2" />

<Check cmd={"curl -sf -H \"Authorization: Token $NETBOX_TOKEN\" '${NETBOX_URL}/api/dcim/devices/?name=spine1' | python3 -c 'import sys, json; print(json.load(sys.stdin)[\"results\"][0][\"config_context\"][\"bgp\"][\"asn\"])'"} expect="${ASN_BASE}" />

<Deep>

La réponse REST pour un équipement est plate : interfaces, adresses et câbles sont des endpoints séparés, trois appels et une jointure dans ta tête. GraphQL renvoie l'arbre en une requête, ce qu'un moteur de rendu veut :

```bash
curl -s -H "Authorization: Token $NETBOX_TOKEN" -H 'Content-Type: application/json' ${NETBOX_URL}/graphql/ -d @- <<'EOF' | python3 -m json.tool
{"query": "{ device_list(filters: {tag: [\"${LAB_NAME}\"]}) { name role { slug } primary_ip4 { address } config_context
  interfaces { name mgmt_only ip_addresses { address } link_peers { ... on InterfaceType { name device { name } } } } } }"}
EOF
```

`link_peers` est le bout d'en face du câble, donc une seule requête donne à chaque équipement ses interfaces, leurs adresses, et qui est de l'autre côté : tout ce dont le template BGP de la page suivante a besoin. La syntaxe des filtres a changé dans NetBox 4.3 (objets imbriqués `{slug: {exact: …}}` au lieu de valeurs simples) ; l'explorateur sur **<V name="NETBOX_URL" />/graphql/** complète la bonne forme pour ta version. GraphQL est en lecture seule ; les écritures restent sur REST.

</Deep>

## Terminé

NetBox contient maintenant le lab : le site <V name="SITE_NAME" />, trois équipements tagués <V name="LAB_NAME" /> avec chacun une IP primaire, leurs interfaces, deux câbles, les /31, les loopbacks tirées de <V name="LOOPBACK_PREFIX" />, et les numéros BGP dans des config contexts. Le script qui a construit tout ça est dans git et se relance à volonté ; à la dernière page, un pipeline de nuit fait exactement ça.

La page suivante transforme ce modèle en configuration : un inventaire lu depuis NetBox, un template par OS réseau, un rendu et un push, jusqu'à ce que BGP soit établi entre le spine et ses leaves.
````

````yaml title="content/netdevops-lab/netbox-as-source-of-truth/diagram.yaml"
# The gist: one script, run from the lab repository, talks to the NetBox API and
# leaves behind the objects the next pages read. Quick: four boxes. Guided adds the
# token, the tag and the config contexts. Deep adds endpoints, the merge and GraphQL.
title: { en: "From a script to a model", fr: "D'un script à un modèle" }
caption:
  en: "seed.py calls the REST API of ${NETBOX_URL} with your token and creates, once, the site, the devices with their interfaces, addresses and cables, and the BGP numbers in config contexts. Run it again: it finds everything and changes nothing."
  fr: "seed.py appelle l'API REST de ${NETBOX_URL} avec ton jeton et crée, une fois, le site, les équipements avec leurs interfaces, adresses et câbles, et les numéros BGP dans des config contexts. Relance-le : il retrouve tout et ne change rien."

groups:
  - id: repo
    label: { en: "Lab repository", fr: "Dépôt du lab" }
    desc:
      en: "${LAB_DIR} on the lab machine: the topology from the previous page, now the sot/ folder too."
      fr: "${LAB_DIR} sur la machine de lab : la topologie de la page précédente, et maintenant le dossier sot/."
  - id: netbox
    label: { en: "NetBox", fr: "NetBox" }
    desc:
      en: "${NETBOX_URL}: the source of truth. Everything created here carries the tag ${LAB_NAME}."
      fr: "${NETBOX_URL} : la source de vérité. Tout ce qui est créé ici porte le tag ${LAB_NAME}."

nodes:
  - id: seed
    kind: file
    label: { en: "seed.py", fr: "seed.py" }
    sub: "pynetbox · get, then create"
    in: repo
    focus: true
    desc:
      en: "One file, top to bottom, every object through ensure(): get by unique fields, create if missing. Safe to run twice."
      fr: "Un fichier, de haut en bas, chaque objet via ensure() : chercher par champs uniques, créer s'il manque. Rejouable sans risque."
    deep:
      sub: "${LAB_DIR}/sot/seed.py · .venv · NETBOX_TOKEN from the environment"
  - id: api
    kind: server
    label: { en: "REST API", fr: "API REST" }
    sub: "/api/"
    in: netbox
    desc:
      en: "One endpoint per model. pynetbox turns nb.dcim.devices.get() into GET /api/dcim/devices/ and .create() into a POST."
      fr: "Un endpoint par modèle. pynetbox transforme nb.dcim.devices.get() en GET /api/dcim/devices/ et .create() en POST."
    deep:
      sub: "HTTPS · Authorization: Token … · /api/dcim/ · /api/ipam/ · /api/extras/"
  - id: dcim
    kind: store
    label: { en: "Site & devices", fr: "Site & équipements" }
    sub: "${SITE_NAME} · spine1 · leaf1 · leaf2"
    in: netbox
    desc:
      en: "The site, the manufacturer, the device type, the platform, two roles and the three devices, each with a primary IP."
      fr: "Le site, le fabricant, le type d'équipement, la plateforme, deux rôles et les trois équipements, chacun avec une IP primaire."
    deep:
      sub: "dcim: site · manufacturer · device_type · platform · roles spine/leaf · devices"
  - id: ifaces
    kind: store
    label: { en: "Interfaces & cables", fr: "Interfaces & câbles" }
    sub: "4 per device · 2 cables"
    in: netbox
    desc:
      en: "Management (mgmt_only), loopback (virtual) and the fabric ports; two cables joining spine1 to each leaf."
      fr: "Management (mgmt_only), loopback (virtuelle) et les ports de fabric ; deux câbles reliant spine1 à chaque leaf."
    deep:
      sub: "dcim.interfaces · dcim.cables (a/b terminations) · link_peers"
  - id: ipam
    kind: store
    label: { en: "Addresses", fr: "Adresses" }
    sub: "${MGMT_SUBNET} · ${LOOPBACK_PREFIX} · /31s"
    in: netbox
    level: guided
    desc:
      en: "Three prefixes and every address assigned to an interface: management, loopbacks, the /31 on each link."
      fr: "Trois préfixes et chaque adresse assignée à une interface : management, loopbacks, le /31 de chaque lien."
    deep:
      sub: "ipam.prefixes · ipam.ip_addresses (assigned_object = interface) · primary_ip4"
  - id: ctx
    kind: file
    label: { en: "Config contexts", fr: "Config contexts" }
    sub: "bgp.asn ${ASN_BASE}…"
    in: netbox
    level: guided
    desc:
      en: "Role contexts (spine AS ${ASN_BASE}, peer groups) and a local context per leaf with its own AS. The device's rendered context is the merge."
      fr: "Contextes de rôle (AS ${ASN_BASE} du spine, groupes de pairs) et un contexte local par leaf avec son propre AS. Le contexte rendu de l'équipement est la fusion."
    deep:
      sub: "extras.config_contexts (roles) + local_context_data → config_context (deep merge)"

edges:
  - from: seed
    to: api
    label: "HTTPS"
    guided: { label: "Token · JSON" }
    deep: { label: "GET ?slug= → 200 or POST → 201" }
    desc: { en: "Every ensure() is a GET with a filter, then a POST only if nothing came back.", fr: "Chaque ensure() est un GET avec un filtre, puis un POST seulement si rien n'est revenu." }
  - from: api
    to: dcim
    label: "creates"
    desc: { en: "In dependency order: site, manufacturer, type, platform, roles, then devices.", fr: "Dans l'ordre des dépendances : site, fabricant, type, plateforme, rôles, puis équipements." }
  - from: dcim
    to: ifaces
    label: "owns"
    dashed: true
    desc: { en: "Interfaces belong to a device; a cable joins two interfaces.", fr: "Les interfaces appartiennent à un équipement ; un câble joint deux interfaces." }
  - from: ifaces
    to: ipam
    label: "assigned"
    dashed: true
    level: guided
    desc: { en: "An address is assigned to an interface; the device then points at one of them as primary.", fr: "Une adresse est assignée à une interface ; l'équipement pointe ensuite l'une d'elles comme primaire." }
  - from: dcim
    to: ctx
    label: "by role"
    dashed: true
    level: guided
    deep: { label: "roles → context · device → local_context_data" }
    desc: { en: "A context attached to a role applies to every device of that role; a local context to one device only.", fr: "Un contexte attaché à un rôle s'applique à tous les équipements de ce rôle ; un contexte local à un seul." }
````

---

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