Valider chaque changement en CI

Une étape de lint sur un runner hébergé, une étape lab sur un runner auto-hébergé qui déploie la topologie, rend, pousse, lance un pytest avec scrapli et démonte toujours, des artefacts, et une branche main protégée qui ne fusionne que ce que le pipeline a prouvé.

avancé~40 min de manipulation
#ci#github-actions#gitlab-ci#pytest#scrapli#containerlab#netdevops

Pas encore validée de bout en bout — sois le premier.Signaler un problème

Brouillon — pas encore exécuté de bout en bout. Cette page est écrite mais son auteur ne l'a pas encore déroulée sur une vraie machine. Des commandes peuvent être fausses : lis avant de lancer, et dis-nous ce qui casse.

En un coup d'œilD'une pull request à un changement prouvé
Ta forge
Machine de lab
pushpipelinedeploy · pushSSHgreen → merge
Toigit push · pull request
Dépôt
job labrunner
Lab neufcontainerlab deploy --reconfigure
pytestBGP established · loopback ping

Un push ouvre un pipeline : lint sur le runner de la forge, puis le job lab sur le runner labellisé ${RUNNER_LABEL} de la machine de lab, qui déploie une topologie neuve, rend et pousse depuis ${NETBOX_URL}, lance les tests, publie les artefacts et démonte le lab. main ne fusionne que ce qui est passé.

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

Avant de commencer

$cd
$git remote add origin
$git branch -M main
$git push -u origin main
Vérification
$git -C  remote get-url origin
Retour attendu

L'arborescence du dépôt

text
/
├── topology.clab.yml # page 1
├── sot/seed.py # page 2
├── templates/, configs/ # page 3
├── render.py · push.py # page 3 (ou render.yml · push.yml, inventory/, group_vars/)
├── tests/test_lab.py # cette page
├── Makefile · requirements.txt · .yamllint
└── .github/workflows/lab.yml ou .gitlab-ci.yml
${LAB_DIR}/Makefile
.RECIPEPREFIX = >
VENV = .venv/bin
.PHONY: deps lint deploy render push test destroy
deps:
> python3 -m venv .venv && $(VENV)/pip install -q -r requirements.txt
lint:
> $(VENV)/yamllint . && $(VENV)/ruff check .
deploy:
> sudo containerlab deploy -t topology.clab.yml --reconfigure
render:
> $(VENV)/python render.py
push:
> $(VENV)/python push.py --commit
test:
> $(VENV)/pytest -q tests --junitxml=report.xml
destroy:
> sudo containerlab destroy -t topology.clab.yml --cleanup
$printf 'report.xml\n' >> .gitignore

Linter en local

${LAB_DIR}/.yamllint
extends: default
rules:
line-length: { max: 200 }
truthy: { check-keys: false }
document-start: disable
comments: { min-spaces-from-content: 1 }
$make deps
$make lint
Vérification
$cd  && make lint >/dev/null && echo lint OK
Retour attendu
lint OK

Un runner sur la machine de lab

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

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

$mkdir -p ~/actions-runner && cd ~/actions-runner
$# curl … | tar xzf … ← les deux lignes de l'interface, avec la version du moment
$./config.sh --url --token <jeton à usage unique de l'interface> --labels --unattended
$sudo ./svc.sh install && sudo ./svc.sh start
Vérification
$systemctl list-units 'actions.runner.*' --no-legend | grep -c running
Retour attendu
1

Les tests

${LAB_DIR}/tests/test_lab.py
import ipaddress
import time
import pytest
import yaml
from scrapli import Scrapli
NODES = yaml.safe_load(open("topology.clab.yml"))["topology"]["nodes"]
LOOPBACKS = list(ipaddress.ip_network("").hosts())
PEERS = {"spine1": 2, "leaf1": 1, "leaf2": 1}
PLATFORM, AUTH, BGP, PING = …
def run(node, command):
with Scrapli(host=NODES[node]["mgmt-ipv4"], platform=PLATFORM, auth_username=AUTH[0],
auth_password=AUTH[1], auth_strict_key=False) as conn:
return conn.send_command(command).result
def eventually(check, tries=12, wait=5):
for _ in range(tries):
if check():
return True
time.sleep(wait)
return False
@pytest.mark.parametrize("node", list(NODES))
def test_bgp_established(node):
assert eventually(lambda: run(node, BGP[0]).count(BGP[1]) >= PEERS[node])
def test_loopback_ping():
assert "3 received" in run("leaf1", PING.format(dst=LOOPBACKS[2], src=LOOPBACKS[1]))
python
PLATFORM, AUTH = "nokia_srl", ("admin", "NokiaSrl1!")
BGP = ("info from state network-instance default protocols bgp neighbor * session-state", "session-state established")
PING = "ping -c 3 {dst} -I {src} network-instance default"
$make deploy render push
$make test
Vérification
$cd  && .venv/bin/pytest -q tests 2>/dev/null | tail -1 | grep -o '[0-9]* passed'
Retour attendu
4 passed
Si le test BGP expire

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

Le pipeline

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

${LAB_DIR}/.github/workflows/lab.yml
name: lab
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 3 * * *"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: make deps lint
lab:
needs: lint
runs-on: [self-hosted, ]
timeout-minutes: 30
concurrency: { group: lab-, cancel-in-progress: false }
env: { NETBOX_TOKEN: "${{ secrets.NETBOX_TOKEN }}" }
steps:
- uses: actions/checkout@v4
- run: make deps deploy
- run: make render push
- run: make test
- uses: actions/upload-artifact@v4
if: always()
with: { name: "lab-${{ github.sha }}", path: "configs/\nreport.xml" }
- if: always()
run: make destroy
$git add -A && git commit -m "ci: lint, lab and tests" && git push
Vérification
$cd  && python3 -c 'import yaml; print(sorted(yaml.safe_load(open(".github/workflows/lab.yml"))["jobs"]))'
Retour attendu
['lab', 'lint']
Vérification
$gh run list --workflow lab.yml -L 1 --json conclusion -q '.[0].conclusion'
Retour attendu
success

Protéger main

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

$gh api -X PUT repos/{owner}/{repo}/branches/main/protection --input - <<'EOF'
${"required_status_checks": {"strict": true, "contexts": ["lint", "lab"]},
$ "enforce_admins": true, "required_pull_request_reviews": {"required_approving_review_count": 0},
$ "restrictions": null}
$EOF
Vérification
$gh api repos/{owner}/{repo}/branches/main/protection -q '.required_status_checks.contexts | sort | join(",")'
Retour attendu
lab,lint

Terminé

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

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

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

Tout a fonctionné ?

Si tu as suivi cette page jusqu'au bout sur une vraie machine, dis-le. Ta validation est datée et enregistre ta stack : le prochain lecteur sur le même chemin sait que ça marche toujours.

Cette copie est en lecture seule. Pour dire que ça marche, ou que ça ne marche pas, ouvre une issue

Seuls tes choix de stack sont enregistrés, jamais tes valeurs. Le pseudo reste sur ce navigateur.