# Source of "Generate and push configs from 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/render-and-push-configs/tuto.yaml"
# Inherits from ../series.yaml: LAB_DIR, LAB_NAME, MGMT_SUBNET, NETBOX_URL, NETBOX_TOKEN, SITE_NAME, NOS, AUTOMATION, CI.
# LOOPBACK_PREFIX is also declared by netbox-as-source-of-truth: same key, the reader fills it once per series.

title:
  en: Generate and push configs from NetBox
  fr: Générer et pousser les configs depuis NetBox
summary:
  en: >-
    An inventory read from NetBox, one Jinja2 template per network OS, a renderer that writes
    one file per device, a push with a dry run first, and BGP established between the spine
    and its leaves without typing a single address.
  fr: >-
    Un inventaire lu depuis NetBox, un template Jinja2 par OS réseau, un rendu qui écrit un
    fichier par équipement, un push précédé d'un dry-run, et BGP établi entre le spine et ses
    leaves sans taper une seule adresse.
difficulty: intermediate
tags: [netbox, nornir, scrapli, ansible, jinja2, bgp, netdevops]
authors: [thudal]
created: 2026-09-25
minutes: 45
validated: NetBox 4.4 · Nornir 3.x · scrapli 2024.x · Ansible core 2.17 · netbox.netbox 3.x
status: draft             # not yet run end to end by its author

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: The prefix the loopbacks were taken from on the previous page, in order (spine1 first).
      fr: Le préfixe dans lequel les loopbacks ont été prises à la page précédente, dans l'ordre (spine1 d'abord).
    impact:
      en: The end-to-end test pings leaf2's loopback from leaf1's; both come from here.
      fr: Le test de bout en bout pingue la loopback de leaf2 depuis celle de leaf1 ; les deux viennent d'ici.
````

````mdx title="content/netdevops-lab/render-and-push-configs/page-en.mdx"
{/* First pass — to be validated against nornir.readthedocs.io, github.com/wvandeun/nornir_netbox, scrapli.github.io (core and community platforms), docs.ansible.com (netbox.netbox.nb_inventory, arista.eos.eos_config), github.com/nokia/srlinux-ansible-collection and learn.srlinux.dev before publishing. */}

NetBox knows the lab; the nodes do not, yet. This page closes the gap: the automation tool reads its inventory from NetBox, a template per network OS turns each device's interfaces, addresses and BGP numbers into configuration text, the text lands in `configs/` where you can read and diff it, and only then is it pushed. At the end, BGP is up between the spine and both leaves and the loopbacks ping each other, and not one address was typed by hand.

<Run>

The script installs <When is="AUTOMATION" equals="nornir">Nornir, scrapli and their plugins</When><When is="AUTOMATION" equals="ansible">Ansible and the collections</When> in the repository's virtual environment, writes the inventory, the templates and the render and push tooling, renders, pushes and shows the BGP state. Run it **on the lab machine**, with the lab deployed and `NETBOX_TOKEN` in the environment.

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

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetDevOps lab — render and push from ${NETBOX_URL} with Nornir (lab ${LAB_NAME})
cd ${LAB_DIR}
.venv/bin/pip install -q nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox
mkdir -p inventory templates configs
cat > config.yaml <<'EOF'
inventory:
  plugin: NetBoxInventory2
  options:
    nb_url: "${NETBOX_URL}"
    filter_parameters: { tag: "${LAB_NAME}" }
    use_platform_slug: true
    defaults_file: inventory/defaults.yaml
runner:
  plugin: threaded
  options: { num_workers: 3 }
EOF
```

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

```bash
printf 'username: admin\npassword: NokiaSrl1!\n' > inventory/defaults.yaml
cat > templates/nokia_srl.j2 <<'EOF'
{% for i in interfaces %}
set / interface {{ i.name }} admin-state enable
set / interface {{ i.name }} subinterface 0 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 address {{ i.address }}
set / network-instance default interface {{ i.name }}.0
{% endfor %}
set / routing-policy policy all default-action policy-result accept
set / network-instance default protocols bgp admin-state enable
set / network-instance default protocols bgp autonomous-system {{ bgp.asn }}
set / network-instance default protocols bgp router-id {{ router_id }}
set / network-instance default protocols bgp afi-safi ipv4-unicast admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} export-policy [ all ]
set / network-instance default protocols bgp group {{ bgp.peer_group }} import-policy [ all ]
{% for p in peers %}
set / network-instance default protocols bgp neighbor {{ p.address }} admin-state enable
set / network-instance default protocols bgp neighbor {{ p.address }} peer-as {{ p.asn }}
set / network-instance default protocols bgp neighbor {{ p.address }} peer-group {{ bgp.peer_group }}
{% endfor %}
EOF
```

</When>

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

```bash
printf 'username: admin\npassword: admin\n' > inventory/defaults.yaml
cat > templates/arista_eos.j2 <<'EOF'
hostname {{ hostname }}
ip routing
{% for i in interfaces %}
interface {{ i.name }}
{% if not i.loopback %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in interfaces if i.loopback %}
   network {{ i.address }}
{% endfor %}
EOF
```

</When>

```bash
cat > render.py <<'EOF'
import os
import pynetbox
from nornir import InitNornir
from nornir_jinja2.plugins.tasks import template_file
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.files import write_file

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])

def facts(host):
    dev = nb.dcim.devices.get(host.data["id"])
    interfaces, peers, router_id = [], [], None
    for i in nb.dcim.interfaces.filter(device_id=dev.id, mgmt_only=False):
        ip = nb.ipam.ip_addresses.get(interface_id=i.id)
        if ip is None:
            continue
        loopback = i.type.value == "virtual"
        interfaces.append({"name": i.name, "address": ip.address, "loopback": loopback})
        if loopback:
            router_id = ip.address.split("/")[0]
        for peer in i.link_peers:
            peer_dev = nb.dcim.devices.get(peer.device.id)
            peer_ip = nb.ipam.ip_addresses.get(interface_id=peer.id)
            peers.append({"name": peer_dev.name, "address": peer_ip.address.split("/")[0],
                          "asn": peer_dev.config_context["bgp"]["asn"]})
    return {"hostname": dev.name, "interfaces": interfaces, "peers": peers,
            "router_id": router_id, "bgp": dev.config_context["bgp"]}

def render(task):
    cfg = task.run(template_file, template=f"{task.host.platform}.j2", path="templates", **facts(task.host)).result
    task.run(write_file, filename=f"configs/{task.host.name}.cfg", content=cfg)

nr = InitNornir(config_file="config.yaml")
print_result(nr.run(task=render), severity_level=30)
EOF
cat > push.py <<'EOF'
import os, sys
from nornir import InitNornir
from nornir_scrapli.tasks import send_command, send_configs
from nornir_utils.plugins.functions import print_result

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"

def push(task):
    lines = open(f"configs/{task.host.name}.cfg").read().splitlines()
    if mode == "--dry-run":
        return "\n".join(lines)
    if task.host.platform == "nokia_srl":
        tail = ["diff", "discard now"] if mode == "--diff" else ["commit now"]
        task.run(send_configs, configs=lines + tail)
    else:
        task.run(send_configs, configs=lines)
        task.run(send_command, command="write memory")

print_result(InitNornir(config_file="config.yaml").run(task=push))
EOF
.venv/bin/python render.py
.venv/bin/python push.py --dry-run
.venv/bin/python push.py --commit
git add config.yaml inventory templates render.py push.py && git -c user.name=lab -c user.email=lab@localhost commit -qm "render and push from NetBox" || true
```

</When>

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

```bash
#!/usr/bin/env bash
set -euo pipefail
# NetDevOps lab — render and push from ${NETBOX_URL} with Ansible (lab ${LAB_NAME})
cd ${LAB_DIR}
.venv/bin/pip install -q ansible pynetbox netaddr
.venv/bin/ansible-galaxy collection install -q netbox.netbox ansible.netcommon ansible.utils arista.eos nokia.srlinux
mkdir -p inventory group_vars templates configs
cat > inventory/netbox.yml <<'EOF'
plugin: netbox.netbox.nb_inventory
api_endpoint: "${NETBOX_URL}"
validate_certs: true
config_context: true
interfaces: true
query_filters:
  - tag: "${LAB_NAME}"
group_by: [device_roles, platforms]
EOF
cat > ansible.cfg <<'EOF'
[defaults]
inventory = inventory/netbox.yml
host_key_checking = False
interpreter_python = auto_silent
EOF
```

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

```bash
cat > group_vars/platforms_nokia_srl.yml <<'EOF'
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: nokia.srlinux.srlinux
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
ansible_user: admin
ansible_password: NokiaSrl1!
EOF
cat > templates/nokia_srl.j2 <<'EOF'
{
  "interface": [
{% for i in fabric %}
    {"name": "{{ i.name }}", "admin-state": "enable", "subinterface": [{"index": 0, "admin-state": "enable",
      "ipv4": {"admin-state": "enable", "address": [{"ip-prefix": "{{ i.address }}"}]}}]}{{ "," if not loop.last }}
{% endfor %}
  ],
  "routing-policy": {"policy": [{"name": "all", "default-action": {"policy-result": "accept"}}]},
  "network-instance": [{"name": "default",
    "interface": [{% for i in fabric %}{"name": "{{ i.name }}.0"}{{ "," if not loop.last }}{% endfor %}],
    "protocols": {"bgp": {"admin-state": "enable", "autonomous-system": {{ config_context.bgp.asn }}, "router-id": "{{ router_id }}",
      "afi-safi": [{"afi-safi-name": "ipv4-unicast", "admin-state": "enable"}],
      "group": [{"group-name": "{{ config_context.bgp.peer_group }}", "admin-state": "enable", "export-policy": ["all"], "import-policy": ["all"]}],
      "neighbor": [{% for p in peers %}{"peer-address": "{{ p.address }}", "admin-state": "enable", "peer-as": {{ p.asn }}, "peer-group": "{{ config_context.bgp.peer_group }}"}{{ "," if not loop.last }}{% endfor %}]
    }}}]
}
EOF
cat > push.yml <<'EOF'
- hosts: platforms_nokia_srl
  gather_facts: false
  tasks:
    - name: Push the rendered configuration over JSON-RPC
      nokia.srlinux.config:
        update:
          - path: /
            value: "{{ lookup('file', 'configs/' ~ inventory_hostname ~ '.cfg') | from_json }}"
        save_when: changed
EOF
```

</When>

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

```bash
cat > group_vars/platforms_arista_eos.yml <<'EOF'
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: admin
ansible_become: true
ansible_become_method: enable
EOF
cat > templates/arista_eos.j2 <<'EOF'
hostname {{ inventory_hostname }}
ip routing
{% for i in fabric %}
interface {{ i.name }}
{% if i.type.value != 'virtual' %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ config_context.bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ config_context.bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ config_context.bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in fabric if i.type.value == 'virtual' %}
   network {{ i.address }}
{% endfor %}
EOF
cat > push.yml <<'EOF'
- hosts: platforms_arista_eos
  gather_facts: false
  tasks:
    - name: Push the rendered configuration
      arista.eos.eos_config:
        src: "configs/{{ inventory_hostname }}.cfg"
        save_when: changed
      diff: true
EOF
```

</When>

```bash
cat > render.yml <<'EOF'
- hosts: all
  gather_facts: false
  tasks:
    - name: Fabric interfaces (everything but management, with an address)
      set_fact:
        fabric: "{{ interfaces | rejectattr('mgmt_only') | selectattr('ip_addresses') | list }}"
    - name: Attach the first address to each interface
      set_fact:
        addressed: "{{ (addressed | default([])) + [i | combine({'address': i.ip_addresses[0].address})] }}"
      loop: "{{ fabric }}"
      loop_control: { loop_var: i }
    - name: Router ID from the loopback, BGP peers from the cables
      set_fact:
        fabric: "{{ addressed }}"
        router_id: "{{ (addressed | selectattr('type.value', 'equalto', 'virtual') | first).address | ansible.utils.ipaddr('address') }}"
        peers: "{{ (peers | default([])) + [{'name': p.device.name, 'asn': hostvars[p.device.name].config_context.bgp.asn,
                  'address': (hostvars[p.device.name].interfaces | selectattr('name', 'equalto', p.name) | first).ip_addresses[0].address | ansible.utils.ipaddr('address')}] }}"
      loop: "{{ addressed | map(attribute='link_peers') | flatten }}"
      loop_control: { loop_var: p }
    - name: Render
      template:
        src: "templates/{{ platforms[0] }}.j2"
        dest: "configs/{{ inventory_hostname }}.cfg"
      delegate_to: localhost
EOF
.venv/bin/ansible-playbook render.yml
.venv/bin/ansible-playbook push.yml --check --diff
.venv/bin/ansible-playbook push.yml
git add ansible.cfg inventory group_vars templates render.yml push.yml && git -c user.name=lab -c user.email=lab@localhost commit -qm "render and push from NetBox" || true
```

</When>

<Warn>The push replaces interface and BGP configuration on every device tagged <V name="LAB_NAME" /> in NetBox. The tag filter is the only thing between this script and everything else your NetBox knows about: check `configs/` before pushing, every time.</Warn>

</Run>

## Before you start

<Guided>The lab from the first page is deployed and the seed script of the second page has run. Everything here happens in <V name="LAB_DIR" /> on the lab machine, in the same virtual environment, with `NETBOX_TOKEN` in the environment. The default credentials of the nodes are <When is="NOS" equals="srlinux">`admin` / `NokiaSrl1!`</When><When is="NOS" equals="ceos">`admin` / `admin`</When>.</Guided>

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

```bash
cd ${LAB_DIR} && source .venv/bin/activate
pip install nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox
mkdir -p inventory templates configs
export NETBOX_TOKEN=${NETBOX_TOKEN}
```

<Deep>Five small packages, each doing one thing: `nornir` runs tasks against an inventory in parallel; `nornir-netbox` builds that inventory from NetBox; `nornir-scrapli` connects over SSH and knows each platform's prompts and modes; `nornir-jinja2` renders templates; `nornir-utils` prints results and writes files. `scrapli-community` adds the `nokia_srl` platform, which core scrapli does not ship; `arista_eos` is in the core. `pynetbox` stays for the one thing an inventory plugin does not give you: the tree of interfaces, addresses and cable peers.</Deep>

<Check cmd="pip show nornir nornir-scrapli | grep -c '^Name:'" expect="2" />

</When>

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

```bash
cd ${LAB_DIR} && source .venv/bin/activate
pip install ansible pynetbox netaddr
ansible-galaxy collection install netbox.netbox ansible.netcommon ansible.utils arista.eos nokia.srlinux
mkdir -p inventory group_vars templates configs
export NETBOX_TOKEN=${NETBOX_TOKEN}
```

<Deep>`netbox.netbox` brings the inventory plugin; `ansible.netcommon` the network connection plugins (`network_cli`, `httpapi`); `ansible.utils` the `ipaddr` filter (which needs `netaddr`); `arista.eos` the `eos_config` module and `nokia.srlinux` the JSON-RPC modules for SR Linux. `ansible` the package, not `ansible-core` alone, so the collections a fresh install already carries do not surprise you with older versions.</Deep>

<Check cmd="ansible-galaxy collection list 2>/dev/null | grep -c 'netbox.netbox\|arista.eos\|nokia.srlinux'" expect="3" />

</When>

## The inventory from NetBox

<Guided>No `hosts` file: the inventory *is* NetBox. The plugin asks for every device tagged <V name="LAB_NAME" />, uses the primary IP as the address to connect to and the platform slug as the driver name. Change a device in NetBox and the next run sees it.</Guided>

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

<Tabs group="nornir-inventory">

<Tab label="config.yaml">

```yaml title="${LAB_DIR}/config.yaml" {2,4-6}
inventory:
  plugin: NetBoxInventory2
  options:
    nb_url: "${NETBOX_URL}"
    filter_parameters: { tag: "${LAB_NAME}" }
    use_platform_slug: true
    defaults_file: inventory/defaults.yaml
runner:
  plugin: threaded
  options: { num_workers: 3 }
```

</Tab>

<Tab label="inventory/defaults.yaml">

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

```yaml title="${LAB_DIR}/inventory/defaults.yaml"
username: admin
password: NokiaSrl1!
```

</When>

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

```yaml title="${LAB_DIR}/inventory/defaults.yaml"
username: admin
password: admin
```

</When>

</Tab>

</Tabs>

<Note>The token is not in the file: `nornir-netbox` reads `NB_TOKEN` from the environment when `nb_token` is absent, and the scripts below copy `NETBOX_TOKEN` into it. Confirm the option names (`use_platform_slug`, `defaults_file`, `filter_parameters`) in the plugin's README for your version.</Note>

```bash
export NB_TOKEN=$NETBOX_TOKEN
python -c 'from nornir import InitNornir; nr = InitNornir(config_file="config.yaml"); [print(h.name, h.hostname, h.platform) for h in nr.inventory.hosts.values()]'
```

<Deep>`NetBoxInventory2` makes one call to `/api/dcim/devices/` with your filter and one host per result: `hostname` is the primary IP without its mask, `platform` the slug (`nokia_srl` or `arista_eos`, which scrapli takes as the driver name: that is why the slug was chosen on the previous page), and the whole device JSON, config context included, sits in `host.data`. Credentials come from `defaults.yaml`, the lowest layer of Nornir's inventory; a group file could override them per role. Default lab credentials in git are fine; real ones go through environment variables in a `defaults` you generate.</Deep>

<Check cmd={"cd ${LAB_DIR} && NB_TOKEN=$NETBOX_TOKEN .venv/bin/python -c 'from nornir import InitNornir; print(len(InitNornir(config_file=\"config.yaml\").inventory.hosts))'"} expect="3" />

</When>

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

<Tabs group="ansible-inventory">

<Tab label="inventory/netbox.yml">

```yaml title="${LAB_DIR}/inventory/netbox.yml" {1,4-7}
plugin: netbox.netbox.nb_inventory
api_endpoint: "${NETBOX_URL}"
validate_certs: true
config_context: true
interfaces: true
query_filters:
  - tag: "${LAB_NAME}"
group_by: [device_roles, platforms]
```

</Tab>

<Tab label="ansible.cfg">

```ini title="${LAB_DIR}/ansible.cfg"
[defaults]
inventory = inventory/netbox.yml
host_key_checking = False
interpreter_python = auto_silent
```

</Tab>

<Tab label="group_vars">

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

```yaml title="${LAB_DIR}/group_vars/platforms_nokia_srl.yml"
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: nokia.srlinux.srlinux
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
ansible_user: admin
ansible_password: NokiaSrl1!
```

</When>

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

```yaml title="${LAB_DIR}/group_vars/platforms_arista_eos.yml"
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: admin
ansible_become: true
ansible_become_method: enable
```

</When>

</Tab>

</Tabs>

<Note>The token comes from the `NETBOX_TOKEN` environment variable, which the plugin reads by default. `config_context: true` and `interfaces: true` put the rendered context and the interface list (with addresses and cable peers) in each host's variables; confirm the exact option names in the collection's documentation for your version.</Note>

```bash
ansible-inventory --graph
ansible-inventory --host spine1 | head -40
```

<Deep>The plugin groups hosts by what you list in `group_by`: `device_roles_spine`, `platforms_arista_eos`… and `group_vars/` files named after those groups set the connection. `ansible_host` is the primary IP. <When is="NOS" equals="srlinux">For SR Linux there is no CLI-over-SSH module in the collection: it talks JSON-RPC to the management server on 443, which containerlab enables by default with a self-signed certificate, hence `validate_certs: false`.</When><When is="NOS" equals="ceos">`network_cli` is SSH with a prompt parser; `become` with `enable` is what gets you from `>` to `#`.</When> Default lab credentials in git are fine; real ones go in Ansible Vault or the environment.</Deep>

<Check cmd={"cd ${LAB_DIR} && .venv/bin/ansible-inventory --list | python3 -c 'import sys, json; print(len(json.load(sys.stdin)[\"_meta\"][\"hostvars\"]))'"} expect="3" />

</When>

## One template per network OS

<Guided>A template is the configuration with the values taken out. It receives, per device, the interfaces with their address, the BGP numbers from the config context, and the peers: for each cabled interface, the far end's address and AS. Whether the far end is a spine or a leaf, the template does not care: the source of truth already knows.</Guided>

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

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

```text title="${LAB_DIR}/templates/nokia_srl.j2"
{% for i in interfaces %}
set / interface {{ i.name }} admin-state enable
set / interface {{ i.name }} subinterface 0 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 address {{ i.address }}
set / network-instance default interface {{ i.name }}.0
{% endfor %}
set / routing-policy policy all default-action policy-result accept
set / network-instance default protocols bgp admin-state enable
set / network-instance default protocols bgp autonomous-system {{ bgp.asn }}
set / network-instance default protocols bgp router-id {{ router_id }}
set / network-instance default protocols bgp afi-safi ipv4-unicast admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} export-policy [ all ]
set / network-instance default protocols bgp group {{ bgp.peer_group }} import-policy [ all ]
{% for p in peers %}
set / network-instance default protocols bgp neighbor {{ p.address }} admin-state enable
set / network-instance default protocols bgp neighbor {{ p.address }} peer-as {{ p.asn }}
set / network-instance default protocols bgp neighbor {{ p.address }} peer-group {{ bgp.peer_group }}
{% endfor %}
```

</When>

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

```text title="${LAB_DIR}/templates/nokia_srl.j2"
{
  "interface": [
{% for i in fabric %}
    {"name": "{{ i.name }}", "admin-state": "enable", "subinterface": [{"index": 0, "admin-state": "enable",
      "ipv4": {"admin-state": "enable", "address": [{"ip-prefix": "{{ i.address }}"}]}}]}{{ "," if not loop.last }}
{% endfor %}
  ],
  "routing-policy": {"policy": [{"name": "all", "default-action": {"policy-result": "accept"}}]},
  "network-instance": [{"name": "default",
    "interface": [{% for i in fabric %}{"name": "{{ i.name }}.0"}{{ "," if not loop.last }}{% endfor %}],
    "protocols": {"bgp": {"admin-state": "enable", "autonomous-system": {{ config_context.bgp.asn }}, "router-id": "{{ router_id }}",
      "afi-safi": [{"afi-safi-name": "ipv4-unicast", "admin-state": "enable"}],
      "group": [{"group-name": "{{ config_context.bgp.peer_group }}", "admin-state": "enable", "export-policy": ["all"], "import-policy": ["all"]}],
      "neighbor": [{% for p in peers %}{"peer-address": "{{ p.address }}", "admin-state": "enable", "peer-as": {{ p.asn }}, "peer-group": "{{ config_context.bgp.peer_group }}"}{{ "," if not loop.last }}{% endfor %}]
    }}}]
}
```

<Note>The Ansible collection for SR Linux speaks JSON-RPC, so the template renders the YANG tree as JSON rather than CLI lines. The keys are the same words as the `set` commands. Validate the rendered file with `python3 -m json.tool configs/spine1.cfg` before pushing.</Note>

</When>

<Deep>Two things make eBGP work on SR Linux that other vendors do by default. First, since 23.x eBGP sessions reject every route in and out unless a policy says otherwise (`ebgp-default-policy`), so the `all` policy with `default-action accept` is applied as import and export on the group; a real network would match on prefix lists instead. Second, `afi-safi ipv4-unicast` must be enabled at the BGP level. The export policy also advertises the local routes, loopback included, which is why no `network` statement is needed. `export-policy [ all ]` is the leaf-list form of 24.x; on 23.x it was a single value `export-policy all`. Check `info flat network-instance default protocols bgp` on a node after the first push and compare.</Deep>

</When>

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

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

```text title="${LAB_DIR}/templates/arista_eos.j2"
hostname {{ hostname }}
ip routing
{% for i in interfaces %}
interface {{ i.name }}
{% if not i.loopback %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in interfaces if i.loopback %}
   network {{ i.address }}
{% endfor %}
```

</When>

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

```text title="${LAB_DIR}/templates/arista_eos.j2"
hostname {{ inventory_hostname }}
ip routing
{% for i in fabric %}
interface {{ i.name }}
{% if i.type.value != 'virtual' %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ config_context.bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ config_context.bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ config_context.bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in fabric if i.type.value == 'virtual' %}
   network {{ i.address }}
{% endfor %}
```

</When>

<Deep>EOS activates the IPv4 unicast family for every neighbour by default, so no `address-family` block is needed; the `network` statement is, since EOS does not redistribute connected routes without being told. A peer group keeps the neighbours' shared settings in one place; here it holds nothing yet, but it is where timers, passwords and route maps go later. `hostname` is in the template on purpose: the node's name is a NetBox fact, not something the lab sets.</Deep>

</When>

## Render

<Guided>Rendering writes one file per device in `configs/`, from NetBox's data and the template. No node is touched. Read the files: they are exactly what you would have typed on the first page, plus BGP.</Guided>

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

<Annotated>

```python title="${LAB_DIR}/render.py" {8,12,14,21-22,28,35}
import os
import pynetbox
from nornir import InitNornir
from nornir_jinja2.plugins.tasks import template_file
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.files import write_file

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])                 # (1)
nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])

def facts(host):
    dev = nb.dcim.devices.get(host.data["id"])                                 # (2)
    interfaces, peers, router_id = [], [], None
    for i in nb.dcim.interfaces.filter(device_id=dev.id, mgmt_only=False):     # (3)
        ip = nb.ipam.ip_addresses.get(interface_id=i.id)
        if ip is None:
            continue
        loopback = i.type.value == "virtual"
        interfaces.append({"name": i.name, "address": ip.address, "loopback": loopback})
        if loopback:
            router_id = ip.address.split("/")[0]                               # (4)
        for peer in i.link_peers:                                              # (5)
            peer_dev = nb.dcim.devices.get(peer.device.id)
            peer_ip = nb.ipam.ip_addresses.get(interface_id=peer.id)
            peers.append({"name": peer_dev.name, "address": peer_ip.address.split("/")[0],
                          "asn": peer_dev.config_context["bgp"]["asn"]})
    return {"hostname": dev.name, "interfaces": interfaces, "peers": peers,
            "router_id": router_id, "bgp": dev.config_context["bgp"]}          # (6)

def render(task):
    cfg = task.run(template_file, template=f"{task.host.platform}.j2", path="templates", **facts(task.host)).result
    task.run(write_file, filename=f"configs/{task.host.name}.cfg", content=cfg)

nr = InitNornir(config_file="config.yaml")
print_result(nr.run(task=render), severity_level=30)                           # (7)
```

1. One token, two names: Nornir's plugin wants `NB_TOKEN`, everything else on this series uses `NETBOX_TOKEN`.
2. The inventory already holds the device JSON; we fetch it again through pynetbox to get a live object with `config_context` and to walk its relations.
3. `mgmt_only=False` is the whole reason the management interface was flagged on the previous page: the template never sees it.
4. The router ID is the loopback address, the same convention on both platforms.
5. `link_peers` is the far end of the cable. From it: the peer device (for its AS, out of its rendered config context) and the address on the peer interface. This is the loop that makes the template vendor- and role-agnostic.
6. The rendered config context of the device itself: `bgp.asn` (from the role or the local context) and `bgp.peer_group`.
7. Nornir runs `render` for every host in parallel; `severity_level=30` prints only warnings and failures, so a green run is silent.

</Annotated>

```bash
python render.py
ls configs/
cat configs/spine1.cfg
```

<Deep>Every `facts()` call is a handful of API requests, so three devices take a second and thirty take ten: fine for a lab, and the point where a real project switches to one GraphQL query for the whole site (previous page, Deep), or to NetBox's own config templates, which render Jinja2 server-side from the same data. Keep the templates in the repository either way: a template in git has a diff, a reviewer and a test; one in a database has a form.</Deep>

</When>

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

<Annotated>

```yaml title="${LAB_DIR}/render.yml" {6,15-17,22}
- hosts: all
  gather_facts: false
  tasks:
    - name: Fabric interfaces (everything but management, with an address)
      set_fact:
        fabric: "{{ interfaces | rejectattr('mgmt_only') | selectattr('ip_addresses') | list }}"   # (1)
    - name: Attach the first address to each interface
      set_fact:
        addressed: "{{ (addressed | default([])) + [i | combine({'address': i.ip_addresses[0].address})] }}"
      loop: "{{ fabric }}"
      loop_control: { loop_var: i }
    - name: Router ID from the loopback, BGP peers from the cables
      set_fact:
        fabric: "{{ addressed }}"
        router_id: "{{ (addressed | selectattr('type.value', 'equalto', 'virtual') | first).address | ansible.utils.ipaddr('address') }}"   # (2)
        peers: "{{ (peers | default([])) + [{'name': p.device.name, 'asn': hostvars[p.device.name].config_context.bgp.asn,
                  'address': (hostvars[p.device.name].interfaces | selectattr('name', 'equalto', p.name) | first).ip_addresses[0].address | ansible.utils.ipaddr('address')}] }}"   # (3)
      loop: "{{ addressed | map(attribute='link_peers') | flatten }}"
      loop_control: { loop_var: p }
    - name: Render
      template:
        src: "templates/{{ platforms[0] }}.j2"                                 # (4)
        dest: "configs/{{ inventory_hostname }}.cfg"
      delegate_to: localhost
```

1. `interfaces` comes from the inventory plugin; `mgmt_only` is the flag set on the previous page, so the management interface never reaches a template.
2. The router ID is the loopback address without its mask; `ipaddr('address')` strips it.
3. For each cable peer: the far device's AS out of *its* config context, and the address on *its* interface, both read from `hostvars` since every lab device is in the inventory. No second API call.
4. `platforms` is the list of platform slugs the plugin sets per host: the template file is named after it.

</Annotated>

```bash
ansible-playbook render.yml
ls configs/
cat configs/spine1.cfg
```

<Deep>Everything here is filters on data the inventory plugin already fetched: one API call per run, whatever the number of devices. The price is Jinja in YAML, which stops being readable around the third `selectattr`; past that, a small custom filter plugin in `filter_plugins/` does the same in Python with a name. `delegate_to: localhost` because the template is rendered on the control machine, not on a switch. Confirm the shape of `interfaces` and `link_peers` in `ansible-inventory --host spine1` for your NetBox version before trusting the filters.</Deep>

</When>

<Check cmd="ls ${LAB_DIR}/configs | wc -l" expect="3" />

<Check cmd={"grep -o '10\\.1\\.0\\.[13]' ${LAB_DIR}/configs/spine1.cfg | sort -u | wc -l"} expect="2" />

<Details summary="If the render fails on a missing key">
A `KeyError: 'bgp'` or an undefined `config_context.bgp` means the device has no rendered context: the config contexts of the previous page are attached to roles, so check the device's role and that the contexts are *active*. An interface with no `link_peers` is a missing cable. An empty `interfaces` means the tag filter matched nothing: `tag=<V name="LAB_NAME" />` must be the slug, not the name.
</Details>

## Push

<Guided>Pushing sends each rendered file to its device. First a dry run that prints what would be sent and touches nothing, then the real thing. From here on, the nodes' configuration is whatever NetBox says it should be.</Guided>

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

```python title="${LAB_DIR}/push.py" {11-15}
import os, sys
from nornir import InitNornir
from nornir_scrapli.tasks import send_command, send_configs
from nornir_utils.plugins.functions import print_result

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"

def push(task):
    lines = open(f"configs/{task.host.name}.cfg").read().splitlines()
    if mode == "--dry-run":
        return "\n".join(lines)
    if task.host.platform == "nokia_srl":
        tail = ["diff", "discard now"] if mode == "--diff" else ["commit now"]
        task.run(send_configs, configs=lines + tail)
    else:
        task.run(send_configs, configs=lines)
        task.run(send_command, command="write memory")

print_result(InitNornir(config_file="config.yaml").run(task=push))
```

```bash
python push.py --dry-run
python push.py --commit
```

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

<Note>`send_configs` on the `nokia_srl` community platform enters the candidate configuration and sends the lines; the `commit now` at the end is what applies them, `diff` then `discard now` shows the change and throws it away (`python push.py --diff`). Check how the community driver handles candidate mode in scrapli-community's documentation for your version: if it commits on exit by itself, the explicit `commit now` is harmless.</Note>

</When>

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

<Note>`send_configs` enters `configure terminal`, sends the lines and leaves; `write memory` saves. EOS has a real diff too: `configure session`, the same lines, `show session-config diffs`, then `commit` or `abort`. scrapli supports sessions through `register_configuration_session`; wire it in when a blind push stops being acceptable, which is before production.</Note>

</When>

<Deep>scrapli is a screen-scraper done carefully: it knows each platform's prompts and privilege levels, sends a line, waits for the prompt, checks the output for the platform's error strings and fails the task on the first one, so a typo in a template stops the push on that device instead of silently applying half of it. Nornir runs the hosts in parallel, three threads here, and `print_result` shows per-host output with failures in red. A failed host does not stop the others; `nr.data.failed_hosts` lists them for a retry.</Deep>

</When>

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

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

```yaml title="${LAB_DIR}/push.yml" {5-9}
- hosts: platforms_nokia_srl
  gather_facts: false
  tasks:
    - name: Push the rendered configuration over JSON-RPC
      nokia.srlinux.config:
        update:
          - path: /
            value: "{{ lookup('file', 'configs/' ~ inventory_hostname ~ '.cfg') | from_json }}"
        save_when: changed
```

<Note>`nokia.srlinux.config` sends the JSON as an `update` at the root of the configuration over JSON-RPC and commits; `--check --diff` asks the node for the diff without committing. Confirm the parameter names (`update`, `save_when`) and the check-mode support in the collection's README for your version, the module was young at the time of writing.</Note>

</When>

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

```yaml title="${LAB_DIR}/push.yml" {5-8}
- hosts: platforms_arista_eos
  gather_facts: false
  tasks:
    - name: Push the rendered configuration
      arista.eos.eos_config:
        src: "configs/{{ inventory_hostname }}.cfg"
        save_when: changed
      diff: true
```

<Note>`eos_config` compares the file with the running configuration line by line and only sends what differs; `--check --diff` shows that difference without sending. `save_when: changed` writes `startup-config` only when something was pushed.</Note>

</When>

```bash
ansible-playbook push.yml --check --diff
ansible-playbook push.yml
```

<Deep>`--check` is Ansible's dry run: modules that support it compute what they would change and report it; with `--diff` they print it. Network config modules do, which makes the pair the standard first command of any push. The playbook targets the platform group, not `all`: a device of another platform tagged into the lab by mistake gets no config rather than a wrong one. Run with `-v` to see the lines each module sent, and `-l spine1` to push to one device.</Deep>

</When>

## Verify BGP

<Guided>Two sessions on the spine, one on each leaf, all *Established*; then a ping from leaf1's loopback to leaf2's, which crosses the spine and proves both loopbacks are advertised. With the default <V name="LOOPBACK_PREFIX" />, leaf1 is `10.0.0.2` and leaf2 is `10.0.0.3`.</Guided>

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

```text
A:spine1# show network-instance default protocols bgp neighbor
A:leaf1# show network-instance default route-table ipv4-unicast summary
A:leaf1# ping -c 3 10.0.0.3 -I 10.0.0.2 network-instance default
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 sr_cli 'info from state network-instance default protocols bgp neighbor * session-state' | grep -c 'session-state established'"} expect="2" />

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

</When>

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

```text
spine1#show ip bgp summary
leaf1#show ip route bgp
leaf1#ping 10.0.0.3 source 10.0.0.2 repeat 3
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'show ip bgp summary' | grep -c Estab"} expect="2" />

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

</When>

<Details summary="If a session stays in Active or Connect">
In order of likelihood: the /31 does not ping (the interface part of the push failed, look at the push output for that host); the peer AS is wrong (compare `configs/leaf1.cfg` with what the spine expects: the leaf's AS is in its *local* context); <When is="NOS" equals="srlinux">the `ipv4-unicast` family or the group is not `admin-state enable`;</When><When is="NOS" equals="ceos">`ip routing` is missing, so the neighbour address is unreachable from the routing table;</When> or the session is up but no routes cross: the export policy is missing on one side. Fix the template or NetBox, never the node, then render and push again.
</Details>

## Done

The lab now runs a configuration nobody typed: NetBox holds the intent, the templates hold the vendor syntax, `configs/` holds the result, and the push made the nodes match. Change a loopback in NetBox, run render and push again, and the fabric follows. The whole chain is in git:

```bash
cd ${LAB_DIR} && git add -A && git commit -m "render and push from NetBox" && git log --oneline | head -3
```

The last page makes that chain run by itself: a pipeline that lints the repository, deploys a fresh lab, renders and pushes, tests BGP and the pings, and tears the lab down, on every change.
````

````mdx title="content/netdevops-lab/render-and-push-configs/page-fr.mdx"
{/* Première passe — à valider contre nornir.readthedocs.io, github.com/wvandeun/nornir_netbox, scrapli.github.io (plateformes core et community), docs.ansible.com (netbox.netbox.nb_inventory, arista.eos.eos_config), github.com/nokia/srlinux-ansible-collection et learn.srlinux.dev avant publication. */}

NetBox connaît le lab ; les nœuds, pas encore. Cette page comble l'écart : l'outil d'automatisation lit son inventaire dans NetBox, un template par OS réseau transforme les interfaces, adresses et numéros BGP de chaque équipement en texte de configuration, le texte atterrit dans `configs/` où tu peux le lire et le differ, et seulement ensuite il est poussé. À la fin, BGP est établi entre le spine et les deux leaves, les loopbacks se pinguent, et pas une adresse n'a été tapée à la main.

<Run>

Le script installe <When is="AUTOMATION" equals="nornir">Nornir, scrapli et leurs plugins</When><When is="AUTOMATION" equals="ansible">Ansible et les collections</When> dans l'environnement virtuel du dépôt, écrit l'inventaire, les templates et l'outillage de rendu et de push, rend, pousse et affiche l'état BGP. Lance-le **sur la machine de lab**, lab déployé et `NETBOX_TOKEN` dans l'environnement.

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

```bash
#!/usr/bin/env bash
set -euo pipefail
# Lab NetDevOps — rendu et push depuis ${NETBOX_URL} avec Nornir (lab ${LAB_NAME})
cd ${LAB_DIR}
.venv/bin/pip install -q nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox
mkdir -p inventory templates configs
cat > config.yaml <<'EOF'
inventory:
  plugin: NetBoxInventory2
  options:
    nb_url: "${NETBOX_URL}"
    filter_parameters: { tag: "${LAB_NAME}" }
    use_platform_slug: true
    defaults_file: inventory/defaults.yaml
runner:
  plugin: threaded
  options: { num_workers: 3 }
EOF
```

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

```bash
printf 'username: admin\npassword: NokiaSrl1!\n' > inventory/defaults.yaml
cat > templates/nokia_srl.j2 <<'EOF'
{% for i in interfaces %}
set / interface {{ i.name }} admin-state enable
set / interface {{ i.name }} subinterface 0 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 address {{ i.address }}
set / network-instance default interface {{ i.name }}.0
{% endfor %}
set / routing-policy policy all default-action policy-result accept
set / network-instance default protocols bgp admin-state enable
set / network-instance default protocols bgp autonomous-system {{ bgp.asn }}
set / network-instance default protocols bgp router-id {{ router_id }}
set / network-instance default protocols bgp afi-safi ipv4-unicast admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} export-policy [ all ]
set / network-instance default protocols bgp group {{ bgp.peer_group }} import-policy [ all ]
{% for p in peers %}
set / network-instance default protocols bgp neighbor {{ p.address }} admin-state enable
set / network-instance default protocols bgp neighbor {{ p.address }} peer-as {{ p.asn }}
set / network-instance default protocols bgp neighbor {{ p.address }} peer-group {{ bgp.peer_group }}
{% endfor %}
EOF
```

</When>

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

```bash
printf 'username: admin\npassword: admin\n' > inventory/defaults.yaml
cat > templates/arista_eos.j2 <<'EOF'
hostname {{ hostname }}
ip routing
{% for i in interfaces %}
interface {{ i.name }}
{% if not i.loopback %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in interfaces if i.loopback %}
   network {{ i.address }}
{% endfor %}
EOF
```

</When>

```bash
cat > render.py <<'EOF'
import os
import pynetbox
from nornir import InitNornir
from nornir_jinja2.plugins.tasks import template_file
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.files import write_file

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])

def facts(host):
    dev = nb.dcim.devices.get(host.data["id"])
    interfaces, peers, router_id = [], [], None
    for i in nb.dcim.interfaces.filter(device_id=dev.id, mgmt_only=False):
        ip = nb.ipam.ip_addresses.get(interface_id=i.id)
        if ip is None:
            continue
        loopback = i.type.value == "virtual"
        interfaces.append({"name": i.name, "address": ip.address, "loopback": loopback})
        if loopback:
            router_id = ip.address.split("/")[0]
        for peer in i.link_peers:
            peer_dev = nb.dcim.devices.get(peer.device.id)
            peer_ip = nb.ipam.ip_addresses.get(interface_id=peer.id)
            peers.append({"name": peer_dev.name, "address": peer_ip.address.split("/")[0],
                          "asn": peer_dev.config_context["bgp"]["asn"]})
    return {"hostname": dev.name, "interfaces": interfaces, "peers": peers,
            "router_id": router_id, "bgp": dev.config_context["bgp"]}

def render(task):
    cfg = task.run(template_file, template=f"{task.host.platform}.j2", path="templates", **facts(task.host)).result
    task.run(write_file, filename=f"configs/{task.host.name}.cfg", content=cfg)

nr = InitNornir(config_file="config.yaml")
print_result(nr.run(task=render), severity_level=30)
EOF
cat > push.py <<'EOF'
import os, sys
from nornir import InitNornir
from nornir_scrapli.tasks import send_command, send_configs
from nornir_utils.plugins.functions import print_result

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"

def push(task):
    lines = open(f"configs/{task.host.name}.cfg").read().splitlines()
    if mode == "--dry-run":
        return "\n".join(lines)
    if task.host.platform == "nokia_srl":
        tail = ["diff", "discard now"] if mode == "--diff" else ["commit now"]
        task.run(send_configs, configs=lines + tail)
    else:
        task.run(send_configs, configs=lines)
        task.run(send_command, command="write memory")

print_result(InitNornir(config_file="config.yaml").run(task=push))
EOF
.venv/bin/python render.py
.venv/bin/python push.py --dry-run
.venv/bin/python push.py --commit
git add config.yaml inventory templates render.py push.py && git -c user.name=lab -c user.email=lab@localhost commit -qm "render and push from NetBox" || true
```

</When>

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

```bash
#!/usr/bin/env bash
set -euo pipefail
# Lab NetDevOps — rendu et push depuis ${NETBOX_URL} avec Ansible (lab ${LAB_NAME})
cd ${LAB_DIR}
.venv/bin/pip install -q ansible pynetbox netaddr
.venv/bin/ansible-galaxy collection install -q netbox.netbox ansible.netcommon ansible.utils arista.eos nokia.srlinux
mkdir -p inventory group_vars templates configs
cat > inventory/netbox.yml <<'EOF'
plugin: netbox.netbox.nb_inventory
api_endpoint: "${NETBOX_URL}"
validate_certs: true
config_context: true
interfaces: true
query_filters:
  - tag: "${LAB_NAME}"
group_by: [device_roles, platforms]
EOF
cat > ansible.cfg <<'EOF'
[defaults]
inventory = inventory/netbox.yml
host_key_checking = False
interpreter_python = auto_silent
EOF
```

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

```bash
cat > group_vars/platforms_nokia_srl.yml <<'EOF'
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: nokia.srlinux.srlinux
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
ansible_user: admin
ansible_password: NokiaSrl1!
EOF
cat > templates/nokia_srl.j2 <<'EOF'
{
  "interface": [
{% for i in fabric %}
    {"name": "{{ i.name }}", "admin-state": "enable", "subinterface": [{"index": 0, "admin-state": "enable",
      "ipv4": {"admin-state": "enable", "address": [{"ip-prefix": "{{ i.address }}"}]}}]}{{ "," if not loop.last }}
{% endfor %}
  ],
  "routing-policy": {"policy": [{"name": "all", "default-action": {"policy-result": "accept"}}]},
  "network-instance": [{"name": "default",
    "interface": [{% for i in fabric %}{"name": "{{ i.name }}.0"}{{ "," if not loop.last }}{% endfor %}],
    "protocols": {"bgp": {"admin-state": "enable", "autonomous-system": {{ config_context.bgp.asn }}, "router-id": "{{ router_id }}",
      "afi-safi": [{"afi-safi-name": "ipv4-unicast", "admin-state": "enable"}],
      "group": [{"group-name": "{{ config_context.bgp.peer_group }}", "admin-state": "enable", "export-policy": ["all"], "import-policy": ["all"]}],
      "neighbor": [{% for p in peers %}{"peer-address": "{{ p.address }}", "admin-state": "enable", "peer-as": {{ p.asn }}, "peer-group": "{{ config_context.bgp.peer_group }}"}{{ "," if not loop.last }}{% endfor %}]
    }}}]
}
EOF
cat > push.yml <<'EOF'
- hosts: platforms_nokia_srl
  gather_facts: false
  tasks:
    - name: Push the rendered configuration over JSON-RPC
      nokia.srlinux.config:
        update:
          - path: /
            value: "{{ lookup('file', 'configs/' ~ inventory_hostname ~ '.cfg') | from_json }}"
        save_when: changed
EOF
```

</When>

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

```bash
cat > group_vars/platforms_arista_eos.yml <<'EOF'
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: admin
ansible_become: true
ansible_become_method: enable
EOF
cat > templates/arista_eos.j2 <<'EOF'
hostname {{ inventory_hostname }}
ip routing
{% for i in fabric %}
interface {{ i.name }}
{% if i.type.value != 'virtual' %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ config_context.bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ config_context.bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ config_context.bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in fabric if i.type.value == 'virtual' %}
   network {{ i.address }}
{% endfor %}
EOF
cat > push.yml <<'EOF'
- hosts: platforms_arista_eos
  gather_facts: false
  tasks:
    - name: Push the rendered configuration
      arista.eos.eos_config:
        src: "configs/{{ inventory_hostname }}.cfg"
        save_when: changed
      diff: true
EOF
```

</When>

```bash
cat > render.yml <<'EOF'
- hosts: all
  gather_facts: false
  tasks:
    - name: Fabric interfaces (everything but management, with an address)
      set_fact:
        fabric: "{{ interfaces | rejectattr('mgmt_only') | selectattr('ip_addresses') | list }}"
    - name: Attach the first address to each interface
      set_fact:
        addressed: "{{ (addressed | default([])) + [i | combine({'address': i.ip_addresses[0].address})] }}"
      loop: "{{ fabric }}"
      loop_control: { loop_var: i }
    - name: Router ID from the loopback, BGP peers from the cables
      set_fact:
        fabric: "{{ addressed }}"
        router_id: "{{ (addressed | selectattr('type.value', 'equalto', 'virtual') | first).address | ansible.utils.ipaddr('address') }}"
        peers: "{{ (peers | default([])) + [{'name': p.device.name, 'asn': hostvars[p.device.name].config_context.bgp.asn,
                  'address': (hostvars[p.device.name].interfaces | selectattr('name', 'equalto', p.name) | first).ip_addresses[0].address | ansible.utils.ipaddr('address')}] }}"
      loop: "{{ addressed | map(attribute='link_peers') | flatten }}"
      loop_control: { loop_var: p }
    - name: Render
      template:
        src: "templates/{{ platforms[0] }}.j2"
        dest: "configs/{{ inventory_hostname }}.cfg"
      delegate_to: localhost
EOF
.venv/bin/ansible-playbook render.yml
.venv/bin/ansible-playbook push.yml --check --diff
.venv/bin/ansible-playbook push.yml
git add ansible.cfg inventory group_vars templates render.yml push.yml && git -c user.name=lab -c user.email=lab@localhost commit -qm "render and push from NetBox" || true
```

</When>

<Warn>Le push remplace la configuration des interfaces et de BGP sur chaque équipement tagué <V name="LAB_NAME" /> dans NetBox. Le filtre sur le tag est la seule chose entre ce script et tout ce que ton NetBox connaît d'autre : regarde `configs/` avant de pousser, à chaque fois.</Warn>

</Run>

## Avant de commencer

<Guided>Le lab de la première page est déployé et le script de seed de la deuxième a tourné. Tout se passe ici dans <V name="LAB_DIR" /> sur la machine de lab, dans le même environnement virtuel, avec `NETBOX_TOKEN` dans l'environnement. Les identifiants par défaut des nœuds sont <When is="NOS" equals="srlinux">`admin` / `NokiaSrl1!`</When><When is="NOS" equals="ceos">`admin` / `admin`</When>.</Guided>

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

```bash
cd ${LAB_DIR} && source .venv/bin/activate
pip install nornir nornir-netbox nornir-scrapli nornir-jinja2 nornir-utils scrapli-community pynetbox
mkdir -p inventory templates configs
export NETBOX_TOKEN=${NETBOX_TOKEN}
```

<Deep>Cinq petits paquets, chacun fait une chose : `nornir` exécute des tâches sur un inventaire en parallèle ; `nornir-netbox` construit cet inventaire depuis NetBox ; `nornir-scrapli` se connecte en SSH et connaît les prompts et les modes de chaque plateforme ; `nornir-jinja2` rend les templates ; `nornir-utils` affiche les résultats et écrit des fichiers. `scrapli-community` ajoute la plateforme `nokia_srl`, que scrapli seul ne livre pas ; `arista_eos` est dans le cœur. `pynetbox` reste pour la seule chose qu'un plugin d'inventaire ne donne pas : l'arbre des interfaces, adresses et bouts de câble.</Deep>

<Check cmd="pip show nornir nornir-scrapli | grep -c '^Name:'" expect="2" />

</When>

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

```bash
cd ${LAB_DIR} && source .venv/bin/activate
pip install ansible pynetbox netaddr
ansible-galaxy collection install netbox.netbox ansible.netcommon ansible.utils arista.eos nokia.srlinux
mkdir -p inventory group_vars templates configs
export NETBOX_TOKEN=${NETBOX_TOKEN}
```

<Deep>`netbox.netbox` apporte le plugin d'inventaire ; `ansible.netcommon` les plugins de connexion réseau (`network_cli`, `httpapi`) ; `ansible.utils` le filtre `ipaddr` (qui a besoin de `netaddr`) ; `arista.eos` le module `eos_config` et `nokia.srlinux` les modules JSON-RPC pour SR Linux. `ansible` le paquet, pas `ansible-core` seul, pour que les collections déjà embarquées par une installation fraîche ne te surprennent pas avec des versions plus anciennes.</Deep>

<Check cmd="ansible-galaxy collection list 2>/dev/null | grep -c 'netbox.netbox\|arista.eos\|nokia.srlinux'" expect="3" />

</When>

## L'inventaire depuis NetBox

<Guided>Pas de fichier `hosts` : l'inventaire, *c'est* NetBox. Le plugin demande tous les équipements tagués <V name="LAB_NAME" />, prend l'IP primaire comme adresse de connexion et le slug de la plateforme comme nom de driver. Change un équipement dans NetBox et le prochain passage le voit.</Guided>

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

<Tabs group="nornir-inventory">

<Tab label="config.yaml">

```yaml title="${LAB_DIR}/config.yaml" {2,4-6}
inventory:
  plugin: NetBoxInventory2
  options:
    nb_url: "${NETBOX_URL}"
    filter_parameters: { tag: "${LAB_NAME}" }
    use_platform_slug: true
    defaults_file: inventory/defaults.yaml
runner:
  plugin: threaded
  options: { num_workers: 3 }
```

</Tab>

<Tab label="inventory/defaults.yaml">

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

```yaml title="${LAB_DIR}/inventory/defaults.yaml"
username: admin
password: NokiaSrl1!
```

</When>

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

```yaml title="${LAB_DIR}/inventory/defaults.yaml"
username: admin
password: admin
```

</When>

</Tab>

</Tabs>

<Note>Le jeton n'est pas dans le fichier : `nornir-netbox` lit `NB_TOKEN` dans l'environnement quand `nb_token` est absent, et les scripts plus bas y copient `NETBOX_TOKEN`. Confirme les noms des options (`use_platform_slug`, `defaults_file`, `filter_parameters`) dans le README du plugin pour ta version.</Note>

```bash
export NB_TOKEN=$NETBOX_TOKEN
python -c 'from nornir import InitNornir; nr = InitNornir(config_file="config.yaml"); [print(h.name, h.hostname, h.platform) for h in nr.inventory.hosts.values()]'
```

<Deep>`NetBoxInventory2` fait un appel à `/api/dcim/devices/` avec ton filtre et un hôte par résultat : `hostname` est l'IP primaire sans son masque, `platform` le slug (`nokia_srl` ou `arista_eos`, que scrapli prend comme nom de driver : c'est pour ça que le slug a été choisi à la page précédente), et tout le JSON de l'équipement, config context compris, est dans `host.data`. Les identifiants viennent de `defaults.yaml`, la couche la plus basse de l'inventaire Nornir ; un fichier de groupes pourrait les surcharger par rôle. Des identifiants de lab par défaut dans git, très bien ; les vrais passent par des variables d'environnement dans un `defaults` que tu génères.</Deep>

<Check cmd={"cd ${LAB_DIR} && NB_TOKEN=$NETBOX_TOKEN .venv/bin/python -c 'from nornir import InitNornir; print(len(InitNornir(config_file=\"config.yaml\").inventory.hosts))'"} expect="3" />

</When>

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

<Tabs group="ansible-inventory">

<Tab label="inventory/netbox.yml">

```yaml title="${LAB_DIR}/inventory/netbox.yml" {1,4-7}
plugin: netbox.netbox.nb_inventory
api_endpoint: "${NETBOX_URL}"
validate_certs: true
config_context: true
interfaces: true
query_filters:
  - tag: "${LAB_NAME}"
group_by: [device_roles, platforms]
```

</Tab>

<Tab label="ansible.cfg">

```ini title="${LAB_DIR}/ansible.cfg"
[defaults]
inventory = inventory/netbox.yml
host_key_checking = False
interpreter_python = auto_silent
```

</Tab>

<Tab label="group_vars">

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

```yaml title="${LAB_DIR}/group_vars/platforms_nokia_srl.yml"
ansible_connection: ansible.netcommon.httpapi
ansible_network_os: nokia.srlinux.srlinux
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: false
ansible_httpapi_port: 443
ansible_user: admin
ansible_password: NokiaSrl1!
```

</When>

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

```yaml title="${LAB_DIR}/group_vars/platforms_arista_eos.yml"
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: arista.eos.eos
ansible_user: admin
ansible_password: admin
ansible_become: true
ansible_become_method: enable
```

</When>

</Tab>

</Tabs>

<Note>Le jeton vient de la variable d'environnement `NETBOX_TOKEN`, que le plugin lit par défaut. `config_context: true` et `interfaces: true` mettent le contexte rendu et la liste des interfaces (avec adresses et bouts de câble) dans les variables de chaque hôte ; confirme les noms exacts des options dans la documentation de la collection pour ta version.</Note>

```bash
ansible-inventory --graph
ansible-inventory --host spine1 | head -40
```

<Deep>Le plugin groupe les hôtes selon ce que tu listes dans `group_by` : `device_roles_spine`, `platforms_arista_eos`… et les fichiers de `group_vars/` nommés d'après ces groupes règlent la connexion. `ansible_host` est l'IP primaire. <When is="NOS" equals="srlinux">Pour SR Linux il n'y a pas de module CLI-sur-SSH dans la collection : elle parle JSON-RPC au serveur de management sur 443, que containerlab active par défaut avec un certificat auto-signé, d'où `validate_certs: false`.</When><When is="NOS" equals="ceos">`network_cli`, c'est SSH avec un analyseur de prompt ; `become` avec `enable` est ce qui te fait passer de `>` à `#`.</When> Des identifiants de lab par défaut dans git, très bien ; les vrais vont dans Ansible Vault ou l'environnement.</Deep>

<Check cmd={"cd ${LAB_DIR} && .venv/bin/ansible-inventory --list | python3 -c 'import sys, json; print(len(json.load(sys.stdin)[\"_meta\"][\"hostvars\"]))'"} expect="3" />

</When>

## Un template par OS réseau

<Guided>Un template, c'est la configuration avec les valeurs en moins. Il reçoit, par équipement, les interfaces avec leur adresse, les numéros BGP du config context, et les pairs : pour chaque interface câblée, l'adresse et l'AS du bout d'en face. Que ce bout soit un spine ou une leaf, le template s'en moque : la source de vérité le sait déjà.</Guided>

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

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

```text title="${LAB_DIR}/templates/nokia_srl.j2"
{% for i in interfaces %}
set / interface {{ i.name }} admin-state enable
set / interface {{ i.name }} subinterface 0 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 admin-state enable
set / interface {{ i.name }} subinterface 0 ipv4 address {{ i.address }}
set / network-instance default interface {{ i.name }}.0
{% endfor %}
set / routing-policy policy all default-action policy-result accept
set / network-instance default protocols bgp admin-state enable
set / network-instance default protocols bgp autonomous-system {{ bgp.asn }}
set / network-instance default protocols bgp router-id {{ router_id }}
set / network-instance default protocols bgp afi-safi ipv4-unicast admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} admin-state enable
set / network-instance default protocols bgp group {{ bgp.peer_group }} export-policy [ all ]
set / network-instance default protocols bgp group {{ bgp.peer_group }} import-policy [ all ]
{% for p in peers %}
set / network-instance default protocols bgp neighbor {{ p.address }} admin-state enable
set / network-instance default protocols bgp neighbor {{ p.address }} peer-as {{ p.asn }}
set / network-instance default protocols bgp neighbor {{ p.address }} peer-group {{ bgp.peer_group }}
{% endfor %}
```

</When>

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

```text title="${LAB_DIR}/templates/nokia_srl.j2"
{
  "interface": [
{% for i in fabric %}
    {"name": "{{ i.name }}", "admin-state": "enable", "subinterface": [{"index": 0, "admin-state": "enable",
      "ipv4": {"admin-state": "enable", "address": [{"ip-prefix": "{{ i.address }}"}]}}]}{{ "," if not loop.last }}
{% endfor %}
  ],
  "routing-policy": {"policy": [{"name": "all", "default-action": {"policy-result": "accept"}}]},
  "network-instance": [{"name": "default",
    "interface": [{% for i in fabric %}{"name": "{{ i.name }}.0"}{{ "," if not loop.last }}{% endfor %}],
    "protocols": {"bgp": {"admin-state": "enable", "autonomous-system": {{ config_context.bgp.asn }}, "router-id": "{{ router_id }}",
      "afi-safi": [{"afi-safi-name": "ipv4-unicast", "admin-state": "enable"}],
      "group": [{"group-name": "{{ config_context.bgp.peer_group }}", "admin-state": "enable", "export-policy": ["all"], "import-policy": ["all"]}],
      "neighbor": [{% for p in peers %}{"peer-address": "{{ p.address }}", "admin-state": "enable", "peer-as": {{ p.asn }}, "peer-group": "{{ config_context.bgp.peer_group }}"}{{ "," if not loop.last }}{% endfor %}]
    }}}]
}
```

<Note>La collection Ansible pour SR Linux parle JSON-RPC, donc le template rend l'arbre YANG en JSON plutôt qu'en lignes CLI. Les clés sont les mêmes mots que les commandes `set`. Valide le fichier rendu avec `python3 -m json.tool configs/spine1.cfg` avant de pousser.</Note>

</When>

<Deep>Deux choses font marcher eBGP sur SR Linux que d'autres constructeurs font par défaut. D'abord, depuis 23.x les sessions eBGP rejettent toutes les routes en entrée et en sortie tant qu'une politique ne dit pas le contraire (`ebgp-default-policy`), d'où la politique `all` avec `default-action accept` appliquée en import et en export sur le groupe ; un vrai réseau filtrerait sur des listes de préfixes. Ensuite, `afi-safi ipv4-unicast` doit être activée au niveau BGP. La politique d'export annonce aussi les routes locales, loopback comprise, c'est pourquoi aucun `network` n'est nécessaire. `export-policy [ all ]` est la forme leaf-list de 24.x ; en 23.x c'était une valeur simple `export-policy all`. Regarde `info flat network-instance default protocols bgp` sur un nœud après le premier push et compare.</Deep>

</When>

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

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

```text title="${LAB_DIR}/templates/arista_eos.j2"
hostname {{ hostname }}
ip routing
{% for i in interfaces %}
interface {{ i.name }}
{% if not i.loopback %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in interfaces if i.loopback %}
   network {{ i.address }}
{% endfor %}
```

</When>

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

```text title="${LAB_DIR}/templates/arista_eos.j2"
hostname {{ inventory_hostname }}
ip routing
{% for i in fabric %}
interface {{ i.name }}
{% if i.type.value != 'virtual' %}
   no switchport
{% endif %}
   ip address {{ i.address }}
{% endfor %}
router bgp {{ config_context.bgp.asn }}
   router-id {{ router_id }}
   neighbor {{ config_context.bgp.peer_group }} peer group
{% for p in peers %}
   neighbor {{ p.address }} peer group {{ config_context.bgp.peer_group }}
   neighbor {{ p.address }} remote-as {{ p.asn }}
{% endfor %}
{% for i in fabric if i.type.value == 'virtual' %}
   network {{ i.address }}
{% endfor %}
```

</When>

<Deep>EOS active la famille IPv4 unicast pour chaque voisin par défaut, donc pas de bloc `address-family` ; le `network`, si, parce qu'EOS ne redistribue pas les routes connectées sans qu'on le lui dise. Un peer group garde au même endroit les réglages communs des voisins ; ici il ne contient encore rien, mais c'est là que les timers, mots de passe et route-maps iront plus tard. `hostname` est dans le template exprès : le nom du nœud est un fait NetBox, pas quelque chose que le lab décide.</Deep>

</When>

## Rendre

<Guided>Le rendu écrit un fichier par équipement dans `configs/`, à partir des données de NetBox et du template. Aucun nœud n'est touché. Lis les fichiers : c'est exactement ce que tu aurais tapé à la première page, plus BGP.</Guided>

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

<Annotated>

```python title="${LAB_DIR}/render.py" {8,12,14,21-22,28,35}
import os
import pynetbox
from nornir import InitNornir
from nornir_jinja2.plugins.tasks import template_file
from nornir_utils.plugins.functions import print_result
from nornir_utils.plugins.tasks.files import write_file

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])                 # (1)
nb = pynetbox.api("${NETBOX_URL}", token=os.environ["NETBOX_TOKEN"])

def facts(host):
    dev = nb.dcim.devices.get(host.data["id"])                                 # (2)
    interfaces, peers, router_id = [], [], None
    for i in nb.dcim.interfaces.filter(device_id=dev.id, mgmt_only=False):     # (3)
        ip = nb.ipam.ip_addresses.get(interface_id=i.id)
        if ip is None:
            continue
        loopback = i.type.value == "virtual"
        interfaces.append({"name": i.name, "address": ip.address, "loopback": loopback})
        if loopback:
            router_id = ip.address.split("/")[0]                               # (4)
        for peer in i.link_peers:                                              # (5)
            peer_dev = nb.dcim.devices.get(peer.device.id)
            peer_ip = nb.ipam.ip_addresses.get(interface_id=peer.id)
            peers.append({"name": peer_dev.name, "address": peer_ip.address.split("/")[0],
                          "asn": peer_dev.config_context["bgp"]["asn"]})
    return {"hostname": dev.name, "interfaces": interfaces, "peers": peers,
            "router_id": router_id, "bgp": dev.config_context["bgp"]}          # (6)

def render(task):
    cfg = task.run(template_file, template=f"{task.host.platform}.j2", path="templates", **facts(task.host)).result
    task.run(write_file, filename=f"configs/{task.host.name}.cfg", content=cfg)

nr = InitNornir(config_file="config.yaml")
print_result(nr.run(task=render), severity_level=30)                           # (7)
```

1. Un jeton, deux noms : le plugin de Nornir veut `NB_TOKEN`, tout le reste de cette série utilise `NETBOX_TOKEN`.
2. L'inventaire contient déjà le JSON de l'équipement ; on le recharge via pynetbox pour avoir un objet vivant avec `config_context` et parcourir ses relations.
3. `mgmt_only=False` est toute la raison pour laquelle l'interface de management a été marquée à la page précédente : le template ne la voit jamais.
4. Le router-id est l'adresse de loopback, même convention sur les deux plateformes.
5. `link_peers` est le bout d'en face du câble. On en tire l'équipement pair (pour son AS, dans son config context rendu) et l'adresse sur l'interface paire. C'est cette boucle qui rend le template indépendant du constructeur et du rôle.
6. Le config context rendu de l'équipement lui-même : `bgp.asn` (venu du rôle ou du contexte local) et `bgp.peer_group`.
7. Nornir lance `render` sur chaque hôte en parallèle ; `severity_level=30` n'affiche que les avertissements et les échecs, un passage vert est silencieux.

</Annotated>

```bash
python render.py
ls configs/
cat configs/spine1.cfg
```

<Deep>Chaque appel à `facts()`, c'est une poignée de requêtes API : trois équipements prennent une seconde, trente en prennent dix. Très bien pour un lab, et c'est le moment où un vrai projet passe à une seule requête GraphQL pour tout le site (page précédente, niveau Deep), ou aux config templates de NetBox lui-même, qui rendent du Jinja2 côté serveur depuis les mêmes données. Garde les templates dans le dépôt dans tous les cas : un template dans git a un diff, un relecteur et un test ; un template dans une base a un formulaire.</Deep>

</When>

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

<Annotated>

```yaml title="${LAB_DIR}/render.yml" {6,15-17,22}
- hosts: all
  gather_facts: false
  tasks:
    - name: Fabric interfaces (everything but management, with an address)
      set_fact:
        fabric: "{{ interfaces | rejectattr('mgmt_only') | selectattr('ip_addresses') | list }}"   # (1)
    - name: Attach the first address to each interface
      set_fact:
        addressed: "{{ (addressed | default([])) + [i | combine({'address': i.ip_addresses[0].address})] }}"
      loop: "{{ fabric }}"
      loop_control: { loop_var: i }
    - name: Router ID from the loopback, BGP peers from the cables
      set_fact:
        fabric: "{{ addressed }}"
        router_id: "{{ (addressed | selectattr('type.value', 'equalto', 'virtual') | first).address | ansible.utils.ipaddr('address') }}"   # (2)
        peers: "{{ (peers | default([])) + [{'name': p.device.name, 'asn': hostvars[p.device.name].config_context.bgp.asn,
                  'address': (hostvars[p.device.name].interfaces | selectattr('name', 'equalto', p.name) | first).ip_addresses[0].address | ansible.utils.ipaddr('address')}] }}"   # (3)
      loop: "{{ addressed | map(attribute='link_peers') | flatten }}"
      loop_control: { loop_var: p }
    - name: Render
      template:
        src: "templates/{{ platforms[0] }}.j2"                                 # (4)
        dest: "configs/{{ inventory_hostname }}.cfg"
      delegate_to: localhost
```

1. `interfaces` vient du plugin d'inventaire ; `mgmt_only` est le drapeau posé à la page précédente, donc l'interface de management n'atteint jamais un template.
2. Le router-id est l'adresse de loopback sans son masque ; `ipaddr('address')` l'enlève.
3. Pour chaque bout de câble : l'AS de l'équipement d'en face dans *son* config context, et l'adresse sur *son* interface, les deux lus dans `hostvars` puisque tous les équipements du lab sont dans l'inventaire. Pas de second appel API.
4. `platforms` est la liste des slugs de plateforme que le plugin pose par hôte : le fichier de template porte ce nom.

</Annotated>

```bash
ansible-playbook render.yml
ls configs/
cat configs/spine1.cfg
```

<Deep>Tout ici, ce sont des filtres sur des données que le plugin d'inventaire a déjà récupérées : un appel API par passage, quel que soit le nombre d'équipements. Le prix, c'est du Jinja dans du YAML, qui cesse d'être lisible vers le troisième `selectattr` ; au-delà, un petit plugin de filtre dans `filter_plugins/` fait la même chose en Python avec un nom. `delegate_to: localhost` parce que le template est rendu sur la machine de contrôle, pas sur un switch. Confirme la forme de `interfaces` et `link_peers` dans `ansible-inventory --host spine1` pour ta version de NetBox avant de faire confiance aux filtres.</Deep>

</When>

<Check cmd="ls ${LAB_DIR}/configs | wc -l" expect="3" />

<Check cmd={"grep -o '10\\.1\\.0\\.[13]' ${LAB_DIR}/configs/spine1.cfg | sort -u | wc -l"} expect="2" />

<Details summary="Si le rendu échoue sur une clé manquante">
Un `KeyError: 'bgp'` ou un `config_context.bgp` indéfini veut dire que l'équipement n'a pas de contexte rendu : les config contexts de la page précédente sont attachés aux rôles, vérifie le rôle de l'équipement et que les contextes sont *actifs*. Une interface sans `link_peers`, c'est un câble qui manque. Un `interfaces` vide veut dire que le filtre sur le tag n'a rien trouvé : `tag=<V name="LAB_NAME" />` doit être le slug, pas le nom.
</Details>

## Pousser

<Guided>Le push envoie chaque fichier rendu à son équipement. D'abord un dry-run qui affiche ce qui serait envoyé sans rien toucher, puis pour de vrai. À partir d'ici, la configuration des nœuds est ce que NetBox dit qu'elle doit être.</Guided>

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

```python title="${LAB_DIR}/push.py" {11-15}
import os, sys
from nornir import InitNornir
from nornir_scrapli.tasks import send_command, send_configs
from nornir_utils.plugins.functions import print_result

os.environ.setdefault("NB_TOKEN", os.environ["NETBOX_TOKEN"])
mode = sys.argv[1] if len(sys.argv) > 1 else "--dry-run"

def push(task):
    lines = open(f"configs/{task.host.name}.cfg").read().splitlines()
    if mode == "--dry-run":
        return "\n".join(lines)
    if task.host.platform == "nokia_srl":
        tail = ["diff", "discard now"] if mode == "--diff" else ["commit now"]
        task.run(send_configs, configs=lines + tail)
    else:
        task.run(send_configs, configs=lines)
        task.run(send_command, command="write memory")

print_result(InitNornir(config_file="config.yaml").run(task=push))
```

```bash
python push.py --dry-run
python push.py --commit
```

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

<Note>`send_configs` sur la plateforme community `nokia_srl` entre dans la configuration candidate et envoie les lignes ; le `commit now` à la fin est ce qui les applique, `diff` puis `discard now` montre le changement et le jette (`python push.py --diff`). Vérifie comment le driver community gère le mode candidat dans la documentation de scrapli-community pour ta version : s'il commite tout seul en sortant, le `commit now` explicite ne gêne pas.</Note>

</When>

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

<Note>`send_configs` entre en `configure terminal`, envoie les lignes et ressort ; `write memory` sauvegarde. EOS a aussi un vrai diff : `configure session`, les mêmes lignes, `show session-config diffs`, puis `commit` ou `abort`. scrapli gère les sessions via `register_configuration_session` ; branche-le quand un push aveugle cessera d'être acceptable, c'est-à-dire avant la production.</Note>

</When>

<Deep>scrapli est un screen-scraper fait avec soin : il connaît les prompts et les niveaux de privilège de chaque plateforme, envoie une ligne, attend le prompt, cherche dans la sortie les chaînes d'erreur de la plateforme et fait échouer la tâche à la première, donc une faute dans un template arrête le push sur cet équipement au lieu d'en appliquer la moitié en silence. Nornir traite les hôtes en parallèle, trois threads ici, et `print_result` affiche la sortie par hôte avec les échecs en rouge. Un hôte en échec n'arrête pas les autres ; `nr.data.failed_hosts` les liste pour rejouer.</Deep>

</When>

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

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

```yaml title="${LAB_DIR}/push.yml" {5-9}
- hosts: platforms_nokia_srl
  gather_facts: false
  tasks:
    - name: Push the rendered configuration over JSON-RPC
      nokia.srlinux.config:
        update:
          - path: /
            value: "{{ lookup('file', 'configs/' ~ inventory_hostname ~ '.cfg') | from_json }}"
        save_when: changed
```

<Note>`nokia.srlinux.config` envoie le JSON en `update` à la racine de la configuration via JSON-RPC et commite ; `--check --diff` demande le diff au nœud sans commiter. Confirme les noms des paramètres (`update`, `save_when`) et le support du mode check dans le README de la collection pour ta version, le module était jeune au moment d'écrire ces lignes.</Note>

</When>

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

```yaml title="${LAB_DIR}/push.yml" {5-8}
- hosts: platforms_arista_eos
  gather_facts: false
  tasks:
    - name: Push the rendered configuration
      arista.eos.eos_config:
        src: "configs/{{ inventory_hostname }}.cfg"
        save_when: changed
      diff: true
```

<Note>`eos_config` compare le fichier à la configuration courante ligne par ligne et n'envoie que ce qui diffère ; `--check --diff` montre cette différence sans l'envoyer. `save_when: changed` écrit `startup-config` seulement quand quelque chose a été poussé.</Note>

</When>

```bash
ansible-playbook push.yml --check --diff
ansible-playbook push.yml
```

<Deep>`--check` est le dry-run d'Ansible : les modules qui le supportent calculent ce qu'ils changeraient et le rapportent ; avec `--diff` ils l'affichent. Les modules de configuration réseau le font, ce qui fait de la paire la première commande standard de tout push. Le playbook vise le groupe de plateforme, pas `all` : un équipement d'une autre plateforme tagué dans le lab par erreur ne reçoit aucune config plutôt qu'une mauvaise. Lance avec `-v` pour voir les lignes envoyées par chaque module, et `-l spine1` pour pousser sur un seul équipement.</Deep>

</When>

## Vérifier BGP

<Guided>Deux sessions sur le spine, une sur chaque leaf, toutes *Established* ; puis un ping de la loopback de leaf1 vers celle de leaf2, qui traverse le spine et prouve que les deux loopbacks sont annoncées. Avec le <V name="LOOPBACK_PREFIX" /> par défaut, leaf1 est `10.0.0.2` et leaf2 est `10.0.0.3`.</Guided>

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

```text
A:spine1# show network-instance default protocols bgp neighbor
A:leaf1# show network-instance default route-table ipv4-unicast summary
A:leaf1# ping -c 3 10.0.0.3 -I 10.0.0.2 network-instance default
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 sr_cli 'info from state network-instance default protocols bgp neighbor * session-state' | grep -c 'session-state established'"} expect="2" />

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

</When>

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

```text
spine1#show ip bgp summary
leaf1#show ip route bgp
leaf1#ping 10.0.0.3 source 10.0.0.2 repeat 3
```

<Check cmd={"docker exec clab-${LAB_NAME}-spine1 Cli -p 15 -c 'show ip bgp summary' | grep -c Estab"} expect="2" />

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

</When>

<Details summary="Si une session reste en Active ou Connect">
Par ordre de probabilité : le /31 ne pingue pas (la partie interfaces du push a échoué, regarde la sortie du push pour cet hôte) ; l'AS du pair est faux (compare `configs/leaf1.cfg` avec ce que le spine attend : l'AS de la leaf est dans son contexte *local*) ; <When is="NOS" equals="srlinux">la famille `ipv4-unicast` ou le groupe n'est pas en `admin-state enable` ;</When><When is="NOS" equals="ceos">`ip routing` manque, donc l'adresse du voisin est injoignable depuis la table de routage ;</When> ou la session est montée mais aucune route ne passe : la politique d'export manque d'un côté. Corrige le template ou NetBox, jamais le nœud, puis rends et pousse à nouveau.
</Details>

## Terminé

Le lab tourne maintenant avec une configuration que personne n'a tapée : NetBox porte l'intention, les templates portent la syntaxe constructeur, `configs/` porte le résultat, et le push a mis les nœuds en conformité. Change une loopback dans NetBox, relance le rendu et le push, et la fabric suit. Toute la chaîne est dans git :

```bash
cd ${LAB_DIR} && git add -A && git commit -m "render and push from NetBox" && git log --oneline | head -3
```

La dernière page fait tourner cette chaîne toute seule : un pipeline qui linte le dépôt, déploie un lab neuf, rend et pousse, teste BGP et les pings, et démonte le lab, à chaque changement.
````

````yaml title="content/netdevops-lab/render-and-push-configs/diagram.yaml"
# The gist: NetBox on the left, the nodes on the right, and in between the two
# steps that never touch a node until the last arrow: render to files, then push.
# Quick: five boxes. Guided adds the inventory and the templates. Deep adds the
# libraries, the protocols and the ports.
title: { en: "From the model to the nodes", fr: "Du modèle aux nœuds" }
caption:
  en: "The inventory comes from NetBox, the template turns each device's interfaces, addresses and BGP numbers into configuration text under configs/, and only then is the text pushed to the three nodes."
  fr: "L'inventaire vient de NetBox, le template transforme les interfaces, adresses et numéros BGP de chaque équipement en texte de configuration sous configs/, et seulement ensuite le texte est poussé sur les trois nœuds."

groups:
  - id: repo
    label: { en: "Lab repository", fr: "Dépôt du lab" }
    desc:
      en: "${LAB_DIR}: templates, the render and push tooling, and the rendered configs/ folder."
      fr: "${LAB_DIR} : les templates, l'outillage de rendu et de push, et le dossier configs/ rendu."
  - id: lab
    label: { en: "Lab ${LAB_NAME}", fr: "Lab ${LAB_NAME}" }
    desc:
      en: "The three containerlab nodes, reached on their management IPs from ${MGMT_SUBNET}."
      fr: "Les trois nœuds containerlab, joints sur leurs IP de management dans ${MGMT_SUBNET}."

nodes:
  - id: netbox
    kind: store
    label: { en: "NetBox", fr: "NetBox" }
    sub: "devices · interfaces · IPs · cables · contexts"
    desc:
      en: "The source of truth from the previous page: every device tagged ${LAB_NAME}, with its interfaces, addresses, cable peers and rendered config context."
      fr: "La source de vérité de la page précédente : chaque équipement tagué ${LAB_NAME}, avec ses interfaces, adresses, bouts de câble et config context rendu."
    deep:
      sub: "${NETBOX_URL} · /api/dcim/devices/?tag=${LAB_NAME} · config_context · link_peers"
  - id: inventory
    kind: file
    label: { en: "Inventory", fr: "Inventaire" }
    sub: "config.yaml · NetBoxInventory2"
    in: repo
    level: guided
    when: { is: AUTOMATION, equals: nornir }
    desc:
      en: "No hosts file: the plugin asks NetBox for the tagged devices; primary IP as hostname, platform slug as scrapli driver."
      fr: "Pas de fichier hosts : le plugin demande à NetBox les équipements tagués ; IP primaire comme hostname, slug de plateforme comme driver scrapli."
    deep:
      sub: "nornir-netbox · NB_TOKEN · use_platform_slug · inventory/defaults.yaml"
  - id: inventory-a
    kind: file
    label: { en: "Inventory", fr: "Inventaire" }
    sub: "inventory/netbox.yml · nb_inventory"
    in: repo
    level: guided
    when: { is: AUTOMATION, equals: ansible }
    desc:
      en: "No hosts file: the plugin asks NetBox for the tagged devices, groups them by role and platform, and hands each host its interfaces and config context."
      fr: "Pas de fichier hosts : le plugin demande à NetBox les équipements tagués, les groupe par rôle et plateforme, et donne à chaque hôte ses interfaces et son config context."
    deep:
      sub: "netbox.netbox.nb_inventory · NETBOX_TOKEN · group_vars/platforms_* · interfaces: true"
  - id: template
    kind: file
    label: { en: "Template", fr: "Template" }
    sub: "templates/<platform>.j2"
    in: repo
    level: guided
    desc:
      en: "The vendor syntax with the values taken out. One file per platform slug; the same data feeds both."
      fr: "La syntaxe constructeur avec les valeurs en moins. Un fichier par slug de plateforme ; les mêmes données nourrissent les deux."
    deep:
      sub: "Jinja2 · interfaces · router_id · peers (address, asn) · bgp.peer_group"
  - id: render
    kind: service
    label: { en: "Render", fr: "Rendu" }
    sub: "render.py"
    in: repo
    focus: true
    when: { is: AUTOMATION, equals: nornir }
    desc:
      en: "For each host: the interfaces with an address, the loopback as router ID, the cable peers with their AS; then the template, then a file."
      fr: "Pour chaque hôte : les interfaces avec une adresse, la loopback comme router-id, les bouts de câble avec leur AS ; puis le template, puis un fichier."
    deep:
      sub: "Nornir threaded · pynetbox facts() · nornir_jinja2 template_file · write_file"
  - id: render-a
    kind: service
    label: { en: "Render", fr: "Rendu" }
    sub: "render.yml"
    in: repo
    focus: true
    when: { is: AUTOMATION, equals: ansible }
    desc:
      en: "set_fact filters over the inventory's data: fabric interfaces, router ID, peers from link_peers and hostvars; then the template module."
      fr: "Des filtres set_fact sur les données de l'inventaire : interfaces de fabric, router-id, pairs depuis link_peers et hostvars ; puis le module template."
    deep:
      sub: "ansible-playbook render.yml · selectattr · hostvars[peer] · template delegate_to localhost"
  - id: configs
    kind: file
    label: { en: "configs/", fr: "configs/" }
    sub: "spine1.cfg · leaf1.cfg · leaf2.cfg"
    in: repo
    desc:
      en: "One file per device, readable and diffable, exactly what will be sent. Look here before every push."
      fr: "Un fichier par équipement, lisible et diffable, exactement ce qui sera envoyé. Regarde ici avant chaque push."
  - id: push
    kind: service
    label: { en: "Push", fr: "Push" }
    sub: "dry-run → commit"
    in: repo
    when: { is: AUTOMATION, equals: nornir }
    desc:
      en: "push.py: prints what it would send, then sends it with scrapli and commits or saves."
      fr: "push.py : affiche ce qu'il enverrait, puis l'envoie avec scrapli et commite ou sauvegarde."
    deep:
      sub: "nornir_scrapli send_configs · commit now / write memory · --diff"
  - id: push-a
    kind: service
    label: { en: "Push", fr: "Push" }
    sub: "--check --diff → run"
    in: repo
    when: { is: AUTOMATION, equals: ansible }
    desc:
      en: "push.yml: check mode shows the diff, the real run sends it and saves when something changed."
      fr: "push.yml : le mode check montre le diff, le vrai passage l'envoie et sauvegarde quand quelque chose a changé."
    deep:
      sub: "eos_config src= · nokia.srlinux.config update · save_when: changed"
  - id: nodes
    kind: net
    label: { en: "spine1 · leaf1 · leaf2", fr: "spine1 · leaf1 · leaf2" }
    sub: "eBGP · loopbacks"
    in: lab
    desc:
      en: "After the push: two eBGP sessions on the spine, one on each leaf, all Established; the loopbacks from ${LOOPBACK_PREFIX} ping across the spine."
      fr: "Après le push : deux sessions eBGP sur le spine, une sur chaque leaf, toutes Established ; les loopbacks de ${LOOPBACK_PREFIX} se pinguent à travers le spine."
    deep:
      sub: "eBGP · AS from config contexts · /31 links · ${LOOPBACK_PREFIX}"

edges:
  - from: netbox
    to: render
    label: "REST"
    max: quick
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "Devices, interfaces, addresses and cable peers, read with the token.", fr: "Équipements, interfaces, adresses et bouts de câble, lus avec le jeton." }
  - from: netbox
    to: render-a
    label: "REST"
    max: quick
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "Devices, interfaces, addresses and cable peers, read with the token.", fr: "Équipements, interfaces, adresses et bouts de câble, lus avec le jeton." }
  - from: netbox
    to: inventory
    label: "REST"
    level: guided
    deep: { label: "GET /api/dcim/devices/?tag=${LAB_NAME}" }
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "One call per run: the hosts, their primary IP and platform.", fr: "Un appel par passage : les hôtes, leur IP primaire et leur plateforme." }
  - from: netbox
    to: inventory-a
    label: "REST"
    level: guided
    deep: { label: "GET /api/dcim/devices/?tag=${LAB_NAME} · interfaces · config_context" }
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "One call per run: the hosts with their interfaces, addresses, peers and context.", fr: "Un appel par passage : les hôtes avec leurs interfaces, adresses, pairs et contexte." }
  - from: inventory
    to: render
    level: guided
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "Nornir runs the render task once per host, in parallel.", fr: "Nornir lance la tâche de rendu une fois par hôte, en parallèle." }
  - from: inventory-a
    to: render-a
    level: guided
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "The playbook runs once per host, with that host's variables.", fr: "Le playbook tourne une fois par hôte, avec les variables de cet hôte." }
  - from: template
    to: render
    label: "Jinja2"
    dashed: true
    level: guided
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "Chosen by platform slug: nokia_srl.j2 or arista_eos.j2.", fr: "Choisi par slug de plateforme : nokia_srl.j2 ou arista_eos.j2." }
  - from: template
    to: render-a
    label: "Jinja2"
    dashed: true
    level: guided
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "Chosen by platform slug: nokia_srl.j2 or arista_eos.j2.", fr: "Choisi par slug de plateforme : nokia_srl.j2 ou arista_eos.j2." }
  - from: render
    to: configs
    label: "writes"
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "One file per device. Nothing has touched a node yet.", fr: "Un fichier par équipement. Rien n'a encore touché un nœud." }
  - from: render-a
    to: configs
    label: "writes"
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "One file per device. Nothing has touched a node yet.", fr: "Un fichier par équipement. Rien n'a encore touché un nœud." }
  - from: configs
    to: push
    label: "reads"
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "The push sends the file as is: what you read is what goes out.", fr: "Le push envoie le fichier tel quel : ce que tu lis est ce qui part." }
  - from: configs
    to: push-a
    label: "reads"
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "The push sends the file as is: what you read is what goes out.", fr: "Le push envoie le fichier tel quel : ce que tu lis est ce qui part." }
  - from: push
    to: nodes
    label: "SSH"
    guided: { label: "SSH · scrapli" }
    deep: { label: "SSH TCP 22 · nokia_srl / arista_eos driver · candidate + commit" }
    when: { is: AUTOMATION, equals: nornir }
    desc: { en: "scrapli sends line by line, checks each answer for the platform's error strings, and stops that host on the first one.", fr: "scrapli envoie ligne par ligne, cherche dans chaque réponse les chaînes d'erreur de la plateforme, et arrête cet hôte à la première." }
  - from: push-a
    to: nodes
    label: "SSH / JSON-RPC"
    guided: { label: "network_cli · httpapi" }
    deep: { label: "EOS: SSH TCP 22 network_cli · SR Linux: JSON-RPC TCP 443 httpapi" }
    when: { is: AUTOMATION, equals: ansible }
    desc: { en: "The module compares the file with the running config and sends only the difference.", fr: "Le module compare le fichier à la configuration courante et n'envoie que la différence." }
````

---

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