Compare commits

..

No commits in common. "f9aadc00fc56240967e7988bfedfb976c6cce7e3" and "32940c57b4a28e2794ce4ae253087f39ceae2e29" have entirely different histories.

33 changed files with 202 additions and 1749 deletions

View file

@ -130,12 +130,7 @@ func renderCmd(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
frr, err := provision.ReadFRR(p)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr})
nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized})
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1

View file

@ -33,9 +33,8 @@ func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) {
}
for _, want := range []string{
"lab evpn-2hv: nodes 4, segments 1, cables 3",
"gateway 192.168.14.1",
"hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2",
"rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf",
"gateway 10.250.0.1",
"hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2",
} {
if !strings.Contains(stdout, want) {
t.Errorf("output does not contain %q:\n%s", want, stdout)
@ -300,55 +299,3 @@ func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) {
t.Error("the topology of a running lab was replaced")
}
}
func TestRun_RenderShipsTheExampleFRRConfigs(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "lab.pub")
if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "run")
code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out)
if code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr)
}
for node, want := range map[string]string{
"sw1": "router bgp 65100",
"rr1": "bgp listen range 192.168.14.0/24 peer-group fabric",
"hv1": "bgp router-id 192.168.14.11",
"hv2": "bgp router-id 192.168.14.12",
} {
data, err := os.ReadFile(filepath.Join(out, node, "user-data"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), want) {
t.Errorf("%s user-data does not carry %q", node, want)
}
}
}
func TestRun_RenderReportsAMissingFRRConfig(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "lab.pub")
if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O x\n"), 0o600); err != nil {
t.Fatal(err)
}
topo := filepath.Join(dir, "lab.yml")
doc := `name: x
images:
deb: { url: https://example.invalid/deb.qcow2, sums: https://example.invalid/SHA512SUMS }
segments:
underlay: { switch: sw1, cidr: 10.1.0.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 1, memory: 512, frr: frr/absent.conf }
hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay], release: 0.2.0rc002 }
`
if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
code, _, stderr := runLab("render", "-key", key, topo, filepath.Join(dir, "run"))
if code != 1 || !strings.Contains(stderr, "node sw1: ") || !strings.Contains(stderr, filepath.Join(dir, "frr", "absent.conf")) {
t.Errorf("code %d, stderr %q", code, stderr)
}
}

View file

@ -8,20 +8,11 @@ images:
segments:
underlay:
switch: sw1
cidr: 192.168.14.0/24
cidr: 10.250.0.0/24
mtu: 9000
nodes:
sw1:
{ role: switch, image: debian12, cpus: 2, memory: 1024,
secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
rr1:
{ role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay],
addresses: { underlay: 192.168.14.2 }, secondary: { underlay: [169.254.0.3/28] },
loopback: 10.255.255.1/32, frr: frr/rr1.conf }
hv1:
{ role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay],
addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc002 }
hv2:
{ role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay],
addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc002 }
sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 }
rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] }
hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }

View file

@ -1,19 +0,0 @@
frr defaults traditional
hostname hv1
log syslog informational
!
router bgp 64600
bgp router-id 192.168.14.11
no bgp default ipv4-unicast
neighbor fabric peer-group
neighbor fabric remote-as 64600
neighbor fabric capability extended-nexthop
neighbor 10.255.255.1 peer-group fabric
!
address-family l2vpn evpn
neighbor fabric activate
advertise-all-vni
exit-address-family
!
exit
!

View file

@ -1,19 +0,0 @@
frr defaults traditional
hostname hv2
log syslog informational
!
router bgp 64600
bgp router-id 192.168.14.12
no bgp default ipv4-unicast
neighbor fabric peer-group
neighbor fabric remote-as 64600
neighbor fabric capability extended-nexthop
neighbor 10.255.255.1 peer-group fabric
!
address-family l2vpn evpn
neighbor fabric activate
advertise-all-vni
exit-address-family
!
exit
!

View file

@ -1,43 +0,0 @@
frr defaults traditional
hostname rr1
log syslog informational
!
ip prefix-list RR-LOOPBACK-OUT seq 10 permit 10.255.255.1/32
!
route-map NO-IN deny 999
description deny
exit
!
router bgp 65000
no bgp default ipv4-unicast
bgp router-id 10.255.255.1
bgp cluster-id 10.255.255.1
neighbor CLUSTER peer-group
neighbor CLUSTER remote-as 65100
neighbor CLUSTER bfd
neighbor 169.254.0.1 peer-group CLUSTER
neighbor 169.254.0.1 description router-1
neighbor 169.254.0.2 peer-group CLUSTER
neighbor 169.254.0.2 description router-2
neighbor fabric peer-group
neighbor fabric remote-as 64600
neighbor fabric local-as 64600 no-prepend replace-as
neighbor fabric capability extended-nexthop
neighbor fabric update-source 10.255.255.1
bgp listen range 192.168.14.0/24 peer-group fabric
bgp listen limit 200
!
address-family ipv4 unicast
network 10.255.255.1/32
neighbor CLUSTER activate
neighbor CLUSTER prefix-list RR-LOOPBACK-OUT out
neighbor CLUSTER route-map NO-IN in
exit-address-family
!
address-family l2vpn evpn
neighbor fabric activate
neighbor fabric route-reflector-client
exit-address-family
!
exit
!

View file

@ -1,28 +0,0 @@
frr defaults traditional
hostname sw1
log syslog informational
!
ip prefix-list RR-LOOPBACK-IN seq 10 permit 10.255.255.1/32
!
route-map RR-IN permit 10
match ip address prefix-list RR-LOOPBACK-IN
exit
!
route-map NO-OUT deny 999
exit
!
router bgp 65100
no bgp default ipv4-unicast
bgp router-id 169.254.0.1
neighbor 169.254.0.3 remote-as 65000
neighbor 169.254.0.3 description rr1
neighbor 169.254.0.3 bfd
!
address-family ipv4 unicast
neighbor 169.254.0.3 activate
neighbor 169.254.0.3 route-map RR-IN in
neighbor 169.254.0.3 route-map NO-OUT out
exit-address-family
!
exit
!

View file

@ -4,23 +4,21 @@ Architecture du cluster
Topologie
---------
.. figure:: /schemas/architecture-cluster.svg
:alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle
.. figure:: /schemas/topologie-cluster.svg
:alt: Topologie du cluster : routeurs, route reflector et hyperviseurs
:align: center
:width: 100%
:class: only-light
Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle
(sessions BGP). Pointillé gris : interfaces créées par l'agent.
Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle.
.. figure:: /schemas/architecture-cluster-dark.svg
:alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle
.. figure:: /schemas/topologie-cluster-dark.svg
:alt: Topologie du cluster : routeurs, route reflector et hyperviseurs
:align: center
:width: 100%
:class: only-dark
Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle
(sessions BGP). Pointillé gris : interfaces créées par l'agent.
Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle.
Deux plans distincts, à ne pas confondre au moment du diagnostic :

View file

@ -250,7 +250,7 @@ champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une sessio
datasource_list: [ NoCloud ]
datasource:
NoCloud:
seedfrom: 'http://169.254.169.254:80/'
seedfrom: 'http://169.254.169.254:80'
timeout: 5
max_wait: 10
ENDFILE
@ -315,20 +315,12 @@ Points de vigilance
aucun espace à l'host : les qcow2 ne se rétractent pas. L'activation reste utile pour le jour
où l'option sera ajoutée côté agent, mais ne pas compter dessus pour la place disque.
.. warning::
.. note::
**La barre oblique finale de** ``seedfrom`` **est indispensable avant cloud-init 23.1.**
cloud-init construit l'URL des documents en concaténant ``seedfrom`` avec ``meta-data``,
``user-data`` et ``vendor-data`` (``util.read_seeded``). Jusqu'à la 22.4 — celle de Debian 12,
22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la
source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de
23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre
oblique manquante, ce qui explique qu'une image récente fonctionne sans elle.
Vérifié le 2026-10-04 dans le lab (#50), sur une VM Debian 12 lancée par l'agent : sans barre
oblique, ``Datasource DataSourceNone`` ; avec, ``DataSourceNoCloudNet
[seed=…http://169.254.169.254…]`` et le nom d'hôte de la VM. Avec la barre oblique, la
configuration fonctionne quelle que soit la version de cloud-init.
**À vérifier** — ``seedfrom`` sans barre oblique finale. cloud-init construit l'URL des
documents en concaténant ``seedfrom`` avec ``meta-data`` et ``user-data``. Confirmer sur une
VM réelle que les deux documents sont bien récupérés, et corriger en
``http://169.254.169.254:80/`` si ce n'est pas le cas.
.. note::

View file

@ -8,106 +8,15 @@ reflector, qui redistribue.
Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter
explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster.
Adressage
---------
Le route reflector est **sur le même segment L2 que les hyperviseurs**, avec trois adresses :
.. list-table::
:header-rows: 1
:widths: 30 25 45
* - Adresse
- Interface
- Rôle
* - une adresse de ``192.168.14.0/24``
- interface principale
- celle de n'importe quelle machine du segment ; les réponses aux hyperviseurs en partent
* - ``169.254.0.3/28``
- interface principale, **adresse secondaire**
- le lien avec les routeurs de cluster (``169.254.0.1`` et ``.2``), sur le même L2
* - ``10.255.255.1/32``
- ``lo1``, interface ``dummy``
- ``router-id``, ``cluster-id`` et source des sessions EVPN ; seule route annoncée aux routeurs
Les hyperviseurs ne connaissent que la loopback : ils ouvrent leur session vers ``10.255.255.1``
par leur passerelle (``192.168.14.1``), les routeurs l'ayant apprise du route reflector en eBGP.
Aucune adresse d'hyperviseur n'est déclarée côté route reflector : il accepte toute session venant
du segment (``bgp listen range``).
L'adresse du lien est une **adresse secondaire** de l'interface principale, pas une interface à
part : même L2, même MAC sur le fil, une interface de moins qu'avec un ``ipvlan``. Elle doit
survivre aux renouvellements DHCP de l'adresse principale : la déclarer dans la configuration
réseau du système, pas la poser à la main.
.. note::
**Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est :
**À rédiger.** À documenter :
.. code-block:: bash
nmcli connection modify <connexion> +ipv4.addresses 169.254.0.3/28
nmcli connection add type dummy ifname lo1 con-name lo1 \
ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled
Le lab, en Debian, pose ces adresses par cloud-init et un script rejoué au démarrage ; ces
commandes restent à valider sur l'image golden.
Configuration de FRR
--------------------
``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du
lab** (``conf/lab/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom
d'hôte diffère. Le lab qualifie donc exactement ce fichier.
.. literalinclude:: ../../conf/lab/frr/rr1.conf
:language: text
Ce qu'elle établit :
* **Routeurs de cluster** (groupe ``CLUSTER``) : eBGP vers l'AS 65100, avec BFD, en IPv4
unicast. Le route reflector n'annonce que sa loopback (``RR-LOOPBACK-OUT``) et **n'accepte
rien** (``NO-IN``) : il ne reçoit aucune route des routeurs.
* **Hyperviseurs** (groupe ``fabric``) : voisins dynamiques, toute session venant de
``192.168.14.0/24`` étant acceptée, jusqu'à 200. ``local-as 64600 no-prepend replace-as`` fait
que, du point de vue des hyperviseurs, la session est en iBGP dans l'AS 64600 — celui de leur
configuration — alors que le route reflector est en AS 65000 face aux routeurs.
* **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients
(``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres.
Vérifié dans le lab
~~~~~~~~~~~~~~~~~~~
Le 2026-10-04, FRR 10.7.1, route reflector en Debian 12 sur le segment des hyperviseurs, avec
l'adresse du lien en secondaire et la loopback sur ``lo1`` :
.. list-table::
:header-rows: 1
:widths: 55 45
* - Vérification
- Résultat
* - session avec le routeur, IPv4 unicast
- Established ; le routeur reçoit **un seul** préfixe, la loopback, et l'installe via
``169.254.0.3``
* - BFD avec le routeur
- up des deux côtés
* - sessions des deux hyperviseurs vers ``10.255.255.1``
- Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée
* - routes EVPN échangées
- aucune : rien à annoncer tant que two n'a pas créé de VXLAN — voir
:doc:`premier-hyperviseur`
Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs
de cluster (:doc:`routeurs`).
.. note::
**À rédiger.** Reste à documenter :
* la création de la VM : ressources, subnet et mode utilisés (``bridge``, pour pouvoir la
lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les
autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ;
* la création de la VM : ressources, subnet et mode utilisés, et s'il s'agit d'une VM créée
par l'agent comme les autres ou d'un cas particulier — l'image, elle, est l'image golden de
:doc:`image-qcow2` ;
* son adressage, et comment les hyperviseurs le connaissent ;
* la configuration du démon de routage qu'elle héberge ;
* la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ;
* la procédure de reconstruction, et l'état du cluster pendant que le route reflector est
absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de

View file

@ -116,9 +116,8 @@ Commandes
que si l'entrée standard en est un
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
/dev/kvm et la virtualisation imbriquée ; lancé aussi par up
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
référence) ; ensuite : ssh './lab up topology/<topologie>'
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et
~/<topologie> ; ensuite : ssh './lab up <topologie>'
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
@ -179,15 +178,13 @@ Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécu
scripts/lab-host.sh up
scripts/lab-host.sh push conf/lab/evpn-2hv.yml
scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml'
scripts/lab-host.sh ssh './lab up evpn-2hv.yml'
scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1
scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé
scripts/lab-host.sh down
``push`` transfère par la connexion SSH du script — mêmes options, même clé, même
``known_hosts`` que ``ssh`` : le binaire par ``cat``, le répertoire de la topologie par ``tar``
(sans les métadonnées macOS), chacun renommé une fois complet. Tout le répertoire part, pour que
les fichiers que la topologie référence (``frr/*.conf``) arrivent avec elle.
``push`` transfère par la connexion SSH du script (``cat`` côté serveur, fichier renommé une fois
complet) : mêmes options, même clé, même ``known_hosts`` que ``ssh``.
Topologie
---------
@ -215,26 +212,9 @@ Ce que le fichier déclare :
``nodes``
``role`` (``switch``, ``rr`` ou ``hypervisor``), ``image``, ``cpus``, ``memory`` en Mio
(256 au moins), ``segments`` auxquels le nœud est relié, et ``addresses`` pour fixer
l'adresse d'un nœud sur un segment (``addresses: {underlay: 192.168.14.50}``). Un switch ne
l'adresse d'un nœud sur un segment (``addresses: {underlay: 10.250.0.50}``). Un switch ne
déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``.
Champs de rôle, facultatifs :
* ``secondary`` — des adresses supplémentaires par segment, avec leur longueur de préfixe
(``secondary: {underlay: [169.254.0.3/28]}``), posées sur la même interface que l'adresse
principale : même L2, même MAC. Elles doivent être **hors** du CIDR du segment, pour ne
jamais croiser l'attribution automatique. Sur un switch, elles vont sur le bridge du
segment ;
* ``loopback`` — une adresse sur une interface ``dummy`` nommée ``lo1``
(``loopback: 10.255.255.1/32``) ;
* ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé
au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_).
* ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de
two que ``deploy.sh`` installe (``release: 0.2.0rc002``). Sans lui, ``deploy.sh`` prendrait la
dernière release, et le lab ne serait plus reproductible.
``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms.
Ce que l'outil en déduit, de façon déterministe — même fichier, même plan :
.. list-table::
@ -277,18 +257,11 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe
hv1 hypervisor debian12 4 16384 MiB 127.0.0.1:2202
hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203
roles
name loopback secondary frr release
sw1 - underlay 169.254.0.1/28 sw1.conf -
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf -
hv1 - - hv1.conf 0.2.0rc002
hv2 - - hv2.conf 0.2.0rc002
segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1
node interface address mac udp switch port mac udp
rr1 underlay 192.168.14.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001
hv1 underlay 192.168.14.11/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003
hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005
segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1
node interface address mac udp switch port mac udp
rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001
hv1 underlay 10.250.0.3/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003
hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005
Un fichier invalide est refusé avec **toutes** ses erreurs à la fois, et un code de sortie 1. Les
champs inconnus et les clés en double sont refusés aussi :
@ -301,122 +274,8 @@ champs inconnus et les clés en double sont refusés aussi :
segment underlay: cidr 10.250.0.0/31 prefix length out of range [/8, /30]
node sw1: switch carries no segment
Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs
de l'exemple sont **ceux de la production** (décision du 2026-10-04, #50) : les fichiers de
``conf/lab/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont
**fixées** par ``addresses`` — le route reflector en ``.2``, les hyperviseurs à partir de ``.11`` —
pour que le modèle se lise sans le plan et ne dépende pas de l'ordre de déclaration : le
``frr.conf`` d'un hyperviseur, écrit à la main, porte son adresse en ``router-id``. Seul le switch
n'en déclare pas : il porte toujours la passerelle, la première adresse du segment.
Rôles
~~~~~
Les configurations FRR du lab vivent dans ``conf/lab/frr/``, une par nœud, **écrites à la main** :
ce sont les mêmes fichiers que la documentation de déploiement inclut, pour que le lab qualifie
exactement ce qu'elle prescrit. Celle du route reflector :
.. literalinclude:: ../../conf/lab/frr/rr1.conf
:language: text
Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les
paquets recommandés), active ``bgpd`` — et ``bfdd`` sur le switch et le route reflector —, puis
dépose le ``frr.conf`` du nœud et redémarre FRR. La mise à jour des index de paquets est réessayée
pendant cinq minutes : un nœud peut démarrer avant que le switch, par lequel il sort, n'ait posé
son NAT.
La **clé du dépôt FRR** n'est pas téléchargée au démarrage : elle est enregistrée dans ``lab``
(``internal/lab/render/frrouting.gpg``) et déposée par cloud-init. Elle a été récupérée le
2026-10-04 sur ``deb.frrouting.org`` ; les empreintes de ses clés primaires sont publiées sous la
même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` :
.. code-block:: text
3D99 68AC 9AE7 BE11 6928 8DDB 1FD5 8398 95F5 7FDA David Lamparter
4A56 C773 8BB3 F815 95A8 05D2 A832 7699 08F1 3ED1 FRRouting Debian Repository
A90F C36D 9429 4097 98E9 C2D8 74DE ED43 AB19 4DBF Jafar Al-Gharaibeh
Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier
après avoir vérifié les nouvelles empreintes.
**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**,
embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans
``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` :
.. code-block:: text
deploy.sh --noup_script -i -u <segment> -t <release>
``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab
teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut —
celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que
``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh``
télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur
réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final.
**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script
``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un
FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc
``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle
que lui, et la première étape en échec met cloud-init en erreur.
**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif
sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que
cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de
secours, et la VM redémarrée ne rejoue pas ``runcmd``.
.. warning::
**Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en
tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est
exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le
lab : ``lab down`` puis ``lab up``.
Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` :
.. code-block:: text
$ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml'
sw1: started
rr1: started
hv1: started
hv2: started
sw1: ready
rr1: ready
hv1: ready
hv2: ready
.. list-table::
:header-rows: 1
:widths: 55 45
* - Vérification
- Résultat
* - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle
- 6 min 15
* - session switch ↔ route reflector, IPv4 unicast, BFD
- Established, BFD up ; le switch reçoit la seule loopback du route reflector
* - sessions EVPN des deux hyperviseurs vers la loopback du route reflector
- Established, voisins dynamiques, stables
* - réseau d'un hyperviseur après ``deploy.sh``
- adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond
* - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1
- ``login:`` en 20 s, en KVM imbriqué
* - DHCP et routes (option 121) servis par two à la VM
- conformes, route ``/32`` vers ``169.254.169.254`` comprise
* - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2`
- ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de
``seedfrom`` (voir cette page)
* - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan``
- **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN —
`#51 <https://git.g3e.fr/syonad/two/issues/51>`_ ; avec l'adresse posée, ping et MTU 1500
passent
.. note::
La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite
pour l'essai du 2026-10-04, elle se contente d'établir la session avec le route reflector et de
n'accepter que sa loopback. Elle sera remplacée par la configuration réelle des routeurs.
Les plages d'adresses de l'exemple sont des valeurs de travail : le plan d'adressage du lab reste à
définir (#50).
Rendu des VM
------------
@ -737,10 +596,6 @@ Sécurité
* **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent
à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un
serveur auquel on s'est authentifié.
* **Code exécuté sans épinglage par** ``deploy.sh`` : la bibliothèque ``shflags`` est récupérée
par ``curl`` sur la branche ``main`` d'un autre dépôt (``H6N/tools``) et exécutée par ``eval``,
sans vérification d'intégrité — dans le lab comme en production. Les artefacts de la release
sont, eux, vérifiés contre ``SHA256SUMS``.
* **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification
protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian
(``SHA512SUMS.sign``) n'est pas encore vérifiée.

View file

@ -1,155 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 736" width="1000" height="736" role="img" aria-label="Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #e6edf3; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #9198a1; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #9198a1; letter-spacing: .04em; }
.ctrlt { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #c297ff; }
.datat { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; font-weight: 600; fill: #4493f8; }
.box { fill: #22272e; stroke: #6e7781; stroke-width: 1.5; }
.group { fill: #1c2128; stroke: #444c56; stroke-width: 1.5; }
.pill { fill: #22272e; stroke: #9198a1; stroke-width: 1.5; }
.bus { stroke: #9198a1; stroke-width: 4; fill: none; stroke-linecap: round; }
.link { stroke: #9198a1; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #c297ff; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
.data { stroke: #4493f8; stroke-width: 3; fill: none; }
.made { stroke: #6e7781; stroke-width: 1.5; fill: none; stroke-dasharray: 2 3; }
.head-ctrl { fill: #c297ff; }
.head-data { fill: #4493f8; }
.head-made { fill: #6e7781; }
</style>
<defs>
<marker id="a-ctrl" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-ctrl" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path class="head-data" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-made" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-made" d="M0 0 L10 5 L0 10 z"/></marker>
</defs>
<rect class="group" x="40" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="60" y="44">ROUTEURS DE CLUSTER · AS 65100</text>
<rect class="box" x="60" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="145" y="92" text-anchor="middle">routeur 1</text>
<text class="sub" x="145" y="111" text-anchor="middle">169.254.0.1/28</text>
<rect class="box" x="250" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="335" y="92" text-anchor="middle">routeur 2</text>
<text class="sub" x="335" y="111" text-anchor="middle">169.254.0.2/28</text>
<text class="sub" x="240" y="150" text-anchor="middle">passerelle 192.168.14.1/24</text>
<rect class="group" x="560" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="580" y="44">VM ROUTE REFLECTOR · AS 65000</text>
<text class="lbl" x="580" y="70">lo1 · 10.255.255.1/32</text>
<text class="sub" x="580" y="87">router-id, cluster-id, source des sessions EVPN</text>
<text class="lbl" x="580" y="111">192.168.14.2/24 · 169.254.0.3/28</text>
<text class="sub" x="580" y="128">principale, et secondaire pour le lien avec les routeurs</text>
<text class="sub" x="580" y="150">hébergée par le cluster lui-même</text>
<path class="ctrl" d="M440 92 L560 92"/>
<text class="ctrlt" x="500" y="84" text-anchor="middle">eBGP · BFD (×2)</text>
<text class="ctrlt" x="500" y="108" text-anchor="middle">annonce lo1 seule</text>
<text class="ctrlt" x="500" y="122" text-anchor="middle">n'accepte rien</text>
<path class="link" d="M145 126 L145 196"/>
<path class="link" d="M335 126 L335 196"/>
<path class="link" d="M760 164 L760 196"/>
<path class="bus" d="M40 196 L960 196"/>
<rect class="pill" x="376" y="177" width="216" height="38" rx="19"/>
<text class="lbl" x="484" y="194" text-anchor="middle">segment underlay · L2</text>
<text class="sub" x="484" y="209" text-anchor="middle">192.168.14.0/24 · 169.254.0.0/28</text>
<rect class="group" x="40" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="60" y="284">HYPERVISEUR 1</text>
<rect class="box" x="274" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="364" y="334" text-anchor="middle">uplink · 192.168.14.11</text>
<rect class="box" x="274" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="364" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M364 344 L364 366"/>
<text class="sub" x="372" y="359">VTEP</text>
<path class="link" d="M364 412 L364 434"/>
<text class="sub" x="372" y="427">veth</text>
<path class="link" d="M364 480 L364 502"/>
<text class="sub" x="372" y="495">tap</text>
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="636" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M636 344 L636 366"/>
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
<path class="link" d="M636 412 L636 434"/>
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
<path class="link" d="M636 480 L636 502"/>
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
<path class="link" d="M364 298 L364 196"/>
<path class="link" d="M636 298 L636 196"/>
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
<path class="ctrl" d="M854 298 L854 164"/>
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
<text class="ctrlt" x="862" y="240">iBGP</text>
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
<path class="link" d="M40 604 L76 604"/>
<text class="sub" x="84" y="608">lien L2 / interface</text>
<path class="data" d="M220 604 L256 604"/>
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
<path class="ctrl" d="M470 604 L506 604"/>
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
<path class="made" d="M716 604 L752 604"/>
<text class="sub" x="760" y="608">créé par l'agent</text>
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,155 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 736" width="1000" height="736" role="img" aria-label="Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #1f2328; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #57606a; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #57606a; letter-spacing: .04em; }
.ctrlt { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #8250df; }
.datat { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; font-weight: 600; fill: #0969da; }
.box { fill: #ffffff; stroke: #8c959f; stroke-width: 1.5; }
.group { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1.5; }
.pill { fill: #ffffff; stroke: #57606a; stroke-width: 1.5; }
.bus { stroke: #57606a; stroke-width: 4; fill: none; stroke-linecap: round; }
.link { stroke: #57606a; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #8250df; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
.data { stroke: #0969da; stroke-width: 3; fill: none; }
.made { stroke: #8c959f; stroke-width: 1.5; fill: none; stroke-dasharray: 2 3; }
.head-ctrl { fill: #8250df; }
.head-data { fill: #0969da; }
.head-made { fill: #8c959f; }
</style>
<defs>
<marker id="a-ctrl" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-ctrl" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path class="head-data" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-made" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-made" d="M0 0 L10 5 L0 10 z"/></marker>
</defs>
<rect class="group" x="40" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="60" y="44">ROUTEURS DE CLUSTER · AS 65100</text>
<rect class="box" x="60" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="145" y="92" text-anchor="middle">routeur 1</text>
<text class="sub" x="145" y="111" text-anchor="middle">169.254.0.1/28</text>
<rect class="box" x="250" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="335" y="92" text-anchor="middle">routeur 2</text>
<text class="sub" x="335" y="111" text-anchor="middle">169.254.0.2/28</text>
<text class="sub" x="240" y="150" text-anchor="middle">passerelle 192.168.14.1/24</text>
<rect class="group" x="560" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="580" y="44">VM ROUTE REFLECTOR · AS 65000</text>
<text class="lbl" x="580" y="70">lo1 · 10.255.255.1/32</text>
<text class="sub" x="580" y="87">router-id, cluster-id, source des sessions EVPN</text>
<text class="lbl" x="580" y="111">192.168.14.2/24 · 169.254.0.3/28</text>
<text class="sub" x="580" y="128">principale, et secondaire pour le lien avec les routeurs</text>
<text class="sub" x="580" y="150">hébergée par le cluster lui-même</text>
<path class="ctrl" d="M440 92 L560 92"/>
<text class="ctrlt" x="500" y="84" text-anchor="middle">eBGP · BFD (×2)</text>
<text class="ctrlt" x="500" y="108" text-anchor="middle">annonce lo1 seule</text>
<text class="ctrlt" x="500" y="122" text-anchor="middle">n'accepte rien</text>
<path class="link" d="M145 126 L145 196"/>
<path class="link" d="M335 126 L335 196"/>
<path class="link" d="M760 164 L760 196"/>
<path class="bus" d="M40 196 L960 196"/>
<rect class="pill" x="376" y="177" width="216" height="38" rx="19"/>
<text class="lbl" x="484" y="194" text-anchor="middle">segment underlay · L2</text>
<text class="sub" x="484" y="209" text-anchor="middle">192.168.14.0/24 · 169.254.0.0/28</text>
<rect class="group" x="40" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="60" y="284">HYPERVISEUR 1</text>
<rect class="box" x="274" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="364" y="334" text-anchor="middle">uplink · 192.168.14.11</text>
<rect class="box" x="274" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="364" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M364 344 L364 366"/>
<text class="sub" x="372" y="359">VTEP</text>
<path class="link" d="M364 412 L364 434"/>
<text class="sub" x="372" y="427">veth</text>
<path class="link" d="M364 480 L364 502"/>
<text class="sub" x="372" y="495">tap</text>
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="636" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M636 344 L636 366"/>
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
<path class="link" d="M636 412 L636 434"/>
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
<path class="link" d="M636 480 L636 502"/>
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
<path class="link" d="M364 298 L364 196"/>
<path class="link" d="M636 298 L636 196"/>
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
<path class="ctrl" d="M854 298 L854 164"/>
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
<text class="ctrlt" x="862" y="240">iBGP</text>
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
<path class="link" d="M40 604 L76 604"/>
<text class="sub" x="84" y="608">lien L2 / interface</text>
<path class="data" d="M220 604 L256 604"/>
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
<path class="ctrl" d="M470 604 L506 604"/>
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
<path class="made" d="M716 604 L752 604"/>
<text class="sub" x="760" y="608">créé par l'agent</text>
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 500" width="900" height="500" role="img" aria-label="Topologie du cluster">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #e6edf3; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #9198a1; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #9198a1; letter-spacing: .04em; }
.box { fill: #22272e; stroke: #6e7781; stroke-width: 1.5; }
.group { fill: #1c2128; stroke: #444c56; stroke-width: 1.5; stroke-dasharray: 0; }
.link { stroke: #9198a1; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #c297ff; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
</style>
<rect class="group" x="250" y="24" width="400" height="96" rx="8"/>
<text class="grp" x="270" y="46">ROUTEURS DE CLUSTER</text>
<rect class="box" x="275" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="355" y="84" text-anchor="middle">routeur A</text>
<rect class="box" x="465" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="545" y="84" text-anchor="middle">routeur B</text>
<rect class="box" x="350" y="168" width="200" height="56" rx="6"/>
<text class="lbl" x="450" y="191" text-anchor="middle">VM route reflector</text>
<text class="sub" x="450" y="210" text-anchor="middle">plan de contrôle</text>
<rect class="group" x="40" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="60" y="300">HYPERVISEUR 1</text>
<rect class="box" x="145" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="145" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="145" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="449" text-anchor="middle">agent</text>
<path class="link" d="M225 352 L225 372"/>
<path class="link" d="M225 412 L225 424"/>
<rect class="group" x="490" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="510" y="300">HYPERVISEUR 2</text>
<rect class="box" x="595" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="595" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="595" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="449" text-anchor="middle">agent</text>
<path class="link" d="M675 352 L675 372"/>
<path class="link" d="M675 412 L675 424"/>
<path class="link" d="M225 312 L225 148 L330 148 L330 120"/>
<path class="link" d="M675 312 L675 148 L570 148 L570 120"/>
<text class="sub" x="232" y="266">uplink</text>
<text class="sub" x="682" y="266">uplink</text>
<path class="ctrl" d="M350 196 L322 196 L322 392 L305 392"/>
<path class="ctrl" d="M550 196 L578 196 L578 392 L595 392"/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 500" width="900" height="500" role="img" aria-label="Topologie du cluster">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #1f2328; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #57606a; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #57606a; letter-spacing: .04em; }
.box { fill: #ffffff; stroke: #8c959f; stroke-width: 1.5; }
.group { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1.5; stroke-dasharray: 0; }
.link { stroke: #57606a; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #8250df; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
</style>
<rect class="group" x="250" y="24" width="400" height="96" rx="8"/>
<text class="grp" x="270" y="46">ROUTEURS DE CLUSTER</text>
<rect class="box" x="275" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="355" y="84" text-anchor="middle">routeur A</text>
<rect class="box" x="465" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="545" y="84" text-anchor="middle">routeur B</text>
<rect class="box" x="350" y="168" width="200" height="56" rx="6"/>
<text class="lbl" x="450" y="191" text-anchor="middle">VM route reflector</text>
<text class="sub" x="450" y="210" text-anchor="middle">plan de contrôle</text>
<rect class="group" x="40" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="60" y="300">HYPERVISEUR 1</text>
<rect class="box" x="145" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="145" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="145" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="449" text-anchor="middle">agent</text>
<path class="link" d="M225 352 L225 372"/>
<path class="link" d="M225 412 L225 424"/>
<rect class="group" x="490" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="510" y="300">HYPERVISEUR 2</text>
<rect class="box" x="595" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="595" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="595" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="449" text-anchor="middle">agent</text>
<path class="link" d="M675 352 L675 372"/>
<path class="link" d="M675 412 L675 424"/>
<path class="link" d="M225 312 L225 148 L330 148 L330 120"/>
<path class="link" d="M675 312 L675 148 L570 148 L570 120"/>
<text class="sub" x="232" y="266">uplink</text>
<text class="sub" x="682" y="266">uplink</text>
<path class="ctrl" d="M350 196 L322 196 L322 392 L305 392"/>
<path class="ctrl" d="M550 196 L578 196 L578 392 L595 392"/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -125,10 +125,6 @@ func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Dur
errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
continue
}
if err := l.checkServices(ctx, n); err != nil {
errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
continue
}
fmt.Fprintf(l.Out, "%s: ready\n", n.Name)
}
return errors.Join(errs...)
@ -167,26 +163,6 @@ func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error {
}
}
func services(n topology.NodePlan) []string {
var units []string
if n.Role == topology.RoleHypervisor {
units = append(units, "agent.service")
}
if n.FRR != "" {
units = append(units, "frr.service")
}
return units
}
func (l Lab) checkServices(ctx context.Context, n topology.NodePlan) error {
for _, unit := range services(n) {
if err := l.Runner.Run(ctx, "ssh", l.sshArgs(n, true, []string{"systemctl", "is-active", "--quiet", unit})...); err != nil {
return fmt.Errorf("%s is not active: %w", unit, err)
}
}
return nil
}
func exitCode(err error) int {
var coded interface{ ExitCode() int }
if errors.As(err, &coded) {

View file

@ -34,10 +34,9 @@ func (e exitErr) ExitCode() int { return int(e) }
type fakeRunner struct {
mu sync.Mutex
calls [][]string
fail string
ssh map[string][]error
always map[string]error
inactive string
fail string
ssh map[string][]error
always map[string]error
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
@ -56,9 +55,6 @@ func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644)
case "ssh":
port := args[indexOf(args, "-p")+1]
if args[len(args)-1] == f.inactive {
return exitErr(3)
}
answers := f.ssh[port]
if len(answers) == 0 {
return f.always[port]
@ -128,7 +124,7 @@ nodes:
}
const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
`
@ -413,8 +409,8 @@ func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) {
}
ssh := f.runner.commands("ssh")
if len(ssh) != 4 {
t.Fatalf("%d ssh calls, want 4", len(ssh))
if len(ssh) != 3 {
t.Fatalf("%d ssh calls, want 3", len(ssh))
}
want := []string{"ssh",
"-i", filepath.Join(f.lab.RunDir, "lab_ed25519"),
@ -444,8 +440,8 @@ func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) {
if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
t.Fatalf("Up: %v", err)
}
if n := len(f.runner.commands("ssh")); n != 6 {
t.Errorf("%d ssh calls, want 6", n)
if n := len(f.runner.commands("ssh")); n != 5 {
t.Errorf("%d ssh calls, want 5", n)
}
}
@ -545,44 +541,3 @@ func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) {
}
}
}
func TestUp_ChecksTheServicesOfEachRole(t *testing.T) {
f := newFixture(t, switchLast)
conf := filepath.Join(t.TempDir(), "sw1.conf")
if err := os.WriteFile(conf, []byte("hostname sw1\n"), 0o600); err != nil {
t.Fatal(err)
}
f.lab.Plan.Nodes[2].FRR = conf
if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
t.Fatalf("Up: %v", err)
}
var checks []string
for _, c := range f.runner.commands("ssh") {
if c[len(c)-4] == "systemctl" {
checks = append(checks, c[indexOf(c, "-p")+1]+" "+strings.Join(c[len(c)-4:], " "))
}
}
want := []string{
"2201 systemctl is-active --quiet agent.service",
"2202 systemctl is-active --quiet frr.service",
}
if !reflect.DeepEqual(checks, want) {
t.Errorf("checks = %q, want %q", checks, want)
}
}
func TestUp_ReportsAnInactiveServiceAndWaitsForTheOthers(t *testing.T) {
f := newFixture(t, switchLast)
f.runner.inactive = "agent.service"
err := f.lab.Up(context.Background(), f.fetcher, time.Second)
if err == nil || err.Error() != "node hv1: agent.service is not active: exit status 3" {
t.Errorf("error = %v", err)
}
if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") || strings.Contains(f.out.String(), "hv1: ready") {
t.Errorf("output = %q", f.out.String())
}
}

View file

@ -72,11 +72,7 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
if err != nil {
return nil, err
}
frr, err := ReadFRR(p)
if err != nil {
return nil, err
}
nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr})
nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}})
if err != nil {
return nil, err
}
@ -88,21 +84,6 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
return nodes, nil
}
func ReadFRR(p *topology.Plan) (map[string]string, error) {
configs := map[string]string{}
for _, n := range p.Nodes {
if n.FRR == "" {
continue
}
data, err := os.ReadFile(n.FRR)
if err != nil {
return nil, fmt.Errorf("node %s: %w", n.Name, err)
}
configs[n.Name] = string(data)
}
return configs, nil
}
func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) {
private := filepath.Join(dir, KeyFile)
if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) {

View file

@ -60,8 +60,8 @@ segments:
nodes:
sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 }
rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] }
hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
`
topo, err := topology.Parse([]byte(doc))
if err != nil {
@ -315,73 +315,3 @@ func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) {
t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err)
}
}
func TestReadFRR_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "rr1.conf")
if err := os.WriteFile(conf, []byte("hostname rr1\n"), 0o600); err != nil {
t.Fatal(err)
}
m := newMirror(t)
p := labPlan(t, m)
p.Nodes[1].FRR = conf
got, err := ReadFRR(p)
if err != nil || !reflect.DeepEqual(got, map[string]string{"rr1": "hostname rr1\n"}) {
t.Errorf("ReadFRR = %q, %v", got, err)
}
}
func TestReadFRR_NamesTheNodeOfAMissingFile(t *testing.T) {
m := newMirror(t)
p := labPlan(t, m)
p.Nodes[2].FRR = filepath.Join(t.TempDir(), "absent.conf")
if _, err := ReadFRR(p); err == nil || !strings.Contains(err.Error(), "node hv1: ") || !strings.Contains(err.Error(), "absent.conf") {
t.Errorf("error = %v", err)
}
}
func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) {
m := newMirror(t)
publish(m, []byte("qcow2 image"))
root := t.TempDir()
conf := filepath.Join(root, "hv1.conf")
if err := os.WriteFile(conf, []byte("hostname hv1\n"), 0o600); err != nil {
t.Fatal(err)
}
p := labPlan(t, m)
p.Nodes[2].FRR = conf
nodes, err := Prepare(context.Background(), p, Options{
RunDir: filepath.Join(root, "run"),
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
Runner: &fakeRunner{},
})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
data, err := os.ReadFile(filepath.Join(nodes[2].Dir, "user-data"))
if err != nil {
t.Fatal(err)
}
var cfg struct {
Files []struct {
Path string `yaml:"path"`
Content string `yaml:"content"`
} `yaml:"write_files"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
for _, f := range cfg.Files {
if f.Path == "/etc/lab/frr.conf" {
if f.Content != "hostname hv1\n" {
t.Errorf("frr.conf = %q", f.Content)
}
return
}
}
t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data)
}

Binary file not shown.

View file

@ -30,7 +30,6 @@ var Nameservers = []string{"1.1.1.1", "8.8.8.8"}
type Options struct {
RunDir string
AuthorizedKeys []string
FRR map[string]string
}
type Node struct {
@ -46,11 +45,6 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
if err := o.validate(); err != nil {
return nil, err
}
for _, n := range p.Nodes {
if _, ok := o.FRR[n.Name]; n.FRR != "" && !ok {
return nil, fmt.Errorf("node %s: frr configuration %s was not read", n.Name, n.FRR)
}
}
var nodes []Node
for index, n := range p.Nodes {
dir := filepath.Join(o.RunDir, n.Name)
@ -58,7 +52,7 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
if err != nil {
return nil, err
}
user, err := userData(p, n, o)
user, err := userData(p, n, o.AuthorizedKeys)
if err != nil {
return nil, err
}

View file

@ -22,8 +22,8 @@ segments:
nodes:
sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
`
const twoSegments = `name: two-seg
@ -36,7 +36,7 @@ segments:
blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red] }
`
func plan(t *testing.T, doc string) *topology.Plan {
@ -317,7 +317,7 @@ func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) {
func TestUserData_Hypervisor(t *testing.T) {
cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1"))
if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.Packages) != 0 || !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 || len(cfg.Packages) != 0 {
t.Errorf("hv1 user-data = %+v", cfg)
}
if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) {
@ -376,12 +376,9 @@ nft -f /etc/lab-switch.nft
t.Errorf("unit misses %q", want)
}
}
if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) {
t.Errorf("runcmd = %v", cfg.Runcmd)
}
if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-switch.service\n" {
t.Errorf("lab-provision:\n%s", got)
}
}
func TestUserData_SwitchWithTwoSegments(t *testing.T) {

View file

@ -1,264 +0,0 @@
package render
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"reflect"
"strings"
"testing"
)
const withRoles = `name: evpn-2hv
images:
deb:
url: https://example.invalid/deb.qcow2
sums: https://example.invalid/SHA512SUMS
segments:
underlay: { switch: sw1, cidr: 192.168.14.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf }
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: rr1.conf }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf, release: 0.2.0rc002 }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
`
var frrConfigs = map[string]string{
"sw1": "hostname sw1\nrouter bgp 65100\n",
"rr1": "hostname rr1\nrouter bgp 65000\n",
"hv1": "hostname hv1\nrouter bgp 64600\n",
}
func renderRoles(t *testing.T) map[string]Node {
t.Helper()
nodes, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
if err != nil {
t.Fatalf("Render: %v", err)
}
out := map[string]Node{}
for _, n := range nodes {
out[n.Name] = n
}
return out
}
func runcmd(t *testing.T, n Node) []string {
t.Helper()
var out []string
for _, c := range user(t, n).Runcmd {
out = append(out, strings.Join(c, " "))
}
return out
}
const frrScriptHead = `#!/bin/sh
set -eu
export DEBIAN_FRONTEND=noninteractive
. /etc/os-release
echo "deb [signed-by=/usr/share/keyrings/frrouting.gpg] https://deb.frrouting.org/frr ${VERSION_CODENAME} frr-stable" > /etc/apt/sources.list.d/frr.list
n=0
until apt-get update -qq --error-on=any; do
n=$((n + 1))
[ "$n" -lt 30 ] || exit 1
sleep 10
done
apt-get install -y -qq --no-install-recommends frr frr-pythontools
sed -i 's/^bgpd=no/bgpd=yes/' /etc/frr/daemons
`
func TestRoles_SecondaryAddressFollowsThePrimaryOnTheNode(t *testing.T) {
doc := network(t, nodeNamed(t, renderRoles(t), "rr1"))
got := iface(t, doc, "underlay").Addresses
if !reflect.DeepEqual(got, []string{"192.168.14.2/24", "169.254.0.3/28"}) {
t.Errorf("rr1 underlay addresses = %v", got)
}
}
func TestRoles_SwitchCarriesItsSecondaryOnTheBridge(t *testing.T) {
script := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "sw1")), "/usr/local/sbin/lab-switch").Content
want := "ip addr replace 192.168.14.1/24 dev br-underlay\nip addr replace 169.254.0.1/28 dev br-underlay\nip link set dev br-underlay up\n"
if !strings.Contains(script, want) {
t.Errorf("lab-switch:\n%s\ndoes not contain:\n%s", script, want)
}
}
func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) {
rr1 := nodeNamed(t, renderRoles(t), "rr1")
cfg := user(t, rr1)
script := fileAt(t, cfg, "/usr/local/sbin/lab-node")
want := "#!/bin/sh\nset -eu\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.255.1/32 dev lo1\nip link set dev lo1 up\n"
if script.Content != want || script.Permissions != "0755" {
t.Errorf("lab-node (%s):\n%s", script.Permissions, script.Content)
}
unit := fileAt(t, cfg, "/etc/systemd/system/lab-node.service").Content
if !strings.Contains(unit, "ExecStart=/usr/local/sbin/lab-node\n") || !strings.Contains(unit, "WantedBy=multi-user.target\n") {
t.Errorf("lab-node.service:\n%s", unit)
}
want = "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-node.service\n/usr/local/sbin/lab-frr\n"
if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != want {
t.Errorf("lab-provision:\n%s\nwant:\n%s", got, want)
}
}
func TestRoles_FRRConfigIsWrittenVerbatimAndPrivately(t *testing.T) {
for _, name := range []string{"sw1", "rr1", "hv1"} {
f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), name)), "/etc/lab/frr.conf")
if f.Content != frrConfigs[name] || f.Permissions != "0640" {
t.Errorf("%s frr.conf (%s) = %q", name, f.Permissions, f.Content)
}
}
}
func TestRoles_FRRDaemonsDependOnTheRole(t *testing.T) {
tail := "install -o frr -g frr -m 0640 /etc/lab/frr.conf /etc/frr/frr.conf\nsystemctl restart frr\n"
bfd := "sed -i 's/^bfdd=no/bfdd=yes/' /etc/frr/daemons\n"
nodes := renderRoles(t)
for name, want := range map[string]string{
"sw1": frrScriptHead + bfd + tail,
"rr1": frrScriptHead + bfd + tail,
"hv1": frrScriptHead + tail,
} {
f := fileAt(t, user(t, nodeNamed(t, nodes, name)), "/usr/local/sbin/lab-frr")
if f.Content != want || f.Permissions != "0755" {
t.Errorf("%s lab-frr (%s):\n%s\nwant:\n%s", name, f.Permissions, f.Content, want)
}
}
}
func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) {
f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "hv1")), "/usr/share/keyrings/frrouting.gpg")
if f.Encoding != "b64" || f.Permissions != "0644" {
t.Errorf("key file: encoding %q, permissions %q", f.Encoding, f.Permissions)
}
key, err := base64.StdEncoding.DecodeString(f.Content)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(key)
if got := hex.EncodeToString(sum[:]); got != "bf10935b9296e2ce7c5d9855fa29ef30c35810b0fc4b1f53005494a04a33554d" {
t.Errorf("key sha256 = %s", got)
}
}
func TestRoles_ProvisioningIsOneScriptThatStopsAtTheFirstFailure(t *testing.T) {
nodes := renderRoles(t)
for name, steps := range map[string]string{
"sw1": "systemctl daemon-reload\nsystemctl enable --now lab-switch.service\n/usr/local/sbin/lab-frr\n",
"hv1": "/usr/local/sbin/lab-deploy\n/usr/local/sbin/lab-frr\n",
"hv2": "/usr/local/sbin/lab-deploy\n",
} {
cfg := user(t, nodeNamed(t, nodes, name))
if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
t.Errorf("%s runcmd = %q", name, cfg.Runcmd)
}
f := fileAt(t, cfg, "/usr/local/sbin/lab-provision")
if f.Content != "#!/bin/sh\nset -eu\n"+steps || f.Permissions != "0755" {
t.Errorf("%s lab-provision (%s):\n%s", name, f.Permissions, f.Content)
}
}
}
func TestRoles_HypervisorDeploysTheReleaseThroughTheRepositoryScripts(t *testing.T) {
cfg := user(t, nodeNamed(t, renderRoles(t), "hv1"))
f := fileAt(t, cfg, "/usr/local/sbin/lab-deploy")
want := `#!/bin/sh
set -eu
n=0
until curl -fsS -o /dev/null https://git.g3e.fr/; do
n=$((n + 1))
[ "$n" -lt 30 ] || exit 1
sleep 10
done
cd /opt/two/scripts
bash ./deploy.sh --noup_script -i -u underlay -t 0.2.0rc002
`
if f.Content != want || f.Permissions != "0755" {
t.Errorf("lab-deploy (%s):\n%s\nwant:\n%s", f.Permissions, f.Content, want)
}
for path, source := range map[string]string{
"/opt/two/scripts/deploy.sh": "../../../scripts/deploy.sh",
"/opt/two/scripts/bootstrap_kvm.sh": "../../../scripts/bootstrap_kvm.sh",
} {
f := fileAt(t, cfg, path)
got, err := base64.StdEncoding.DecodeString(f.Content)
if err != nil {
t.Fatal(err)
}
repo, err := os.ReadFile(source)
if err != nil {
t.Fatal(err)
}
if f.Encoding != "b64" || f.Permissions != "0755" || !bytes.Equal(got, repo) {
t.Errorf("%s: encoding %q, permissions %q, identical to %s: %v", path, f.Encoding, f.Permissions, source, bytes.Equal(got, repo))
}
}
}
func TestRoles_UplinkIsTheInterfaceCarryingTheDefaultRoute(t *testing.T) {
hv := nodeNamed(t, renderAll(t, twoSegments), "hv")
var withDefault []string
for name, e := range network(t, hv).Ethernets {
for _, r := range e.Routes {
if r.To == "0.0.0.0/0" {
withDefault = append(withDefault, name)
}
}
}
if !reflect.DeepEqual(withDefault, []string{"red"}) {
t.Fatalf("default route on %v, want [red]", withDefault)
}
if got := fileAt(t, user(t, hv), "/usr/local/sbin/lab-deploy").Content; !strings.HasSuffix(got, "bash ./deploy.sh --noup_script -i -u red -t 0.2.0rc002\n") {
t.Errorf("lab-deploy:\n%s", got)
}
}
func TestRoles_OnlyHypervisorsDeployTwo(t *testing.T) {
nodes := renderRoles(t)
for _, name := range []string{"sw1", "rr1"} {
for _, f := range user(t, nodeNamed(t, nodes, name)).WriteFiles {
if strings.HasPrefix(f.Path, "/opt/two/") || f.Path == "/usr/local/sbin/lab-deploy" {
t.Errorf("%s receives %s", name, f.Path)
}
}
}
}
func TestRoles_NodeWithoutRoleFieldsGetsNoFRR(t *testing.T) {
for _, f := range user(t, nodeNamed(t, renderRoles(t), "hv2")).WriteFiles {
if strings.Contains(f.Path, "frr") {
t.Errorf("hv2 receives %s", f.Path)
}
}
}
func TestRoles_RefusesAnUnreadFRRConfig(t *testing.T) {
configs := map[string]string{"sw1": "x", "rr1": "y"}
_, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: configs})
if err == nil || err.Error() != "node hv1: frr configuration hv1.conf was not read" {
t.Errorf("error = %v", err)
}
}
func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) {
doc := strings.Replace(withRoles, "secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf", "secondary: { underlay: [169.254.0.1/28] }, loopback: 10.255.254.1/32, frr: sw1.conf", 1)
nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
if err != nil {
t.Fatalf("Render: %v", err)
}
var sw1 Node
for _, n := range nodes {
if n.Name == "sw1" {
sw1 = n
}
}
script := fileAt(t, user(t, sw1), "/usr/local/sbin/lab-switch").Content
want := "ip link set dev br-underlay up\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.254.1/32 dev lo1\nip link set dev lo1 up\nnft -f /etc/lab-switch.nft\n"
if !strings.HasSuffix(script, want) {
t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want)
}
}

View file

@ -1,41 +1,20 @@
package render
import (
_ "embed"
"encoding/base64"
"fmt"
"strings"
"go.yaml.in/yaml/v3"
"git.g3e.fr/syonad/two/internal/lab/topology"
"git.g3e.fr/syonad/two/scripts"
)
const (
SwitchScript = "/usr/local/sbin/lab-switch"
SwitchNFT = "/etc/lab-switch.nft"
SwitchUnit = "/etc/systemd/system/lab-switch.service"
NodeScript = "/usr/local/sbin/lab-node"
NodeUnit = "/etc/systemd/system/lab-node.service"
FRRKey = "/usr/share/keyrings/frrouting.gpg"
FRRConfig = "/etc/lab/frr.conf"
FRRScript = "/usr/local/sbin/lab-frr"
FRRSuite = "frr-stable"
FRRRepo = "https://deb.frrouting.org/frr"
FRRPackages = "frr frr-pythontools"
ProvisionScript = "/usr/local/sbin/lab-provision"
DeployScript = "/usr/local/sbin/lab-deploy"
TwoScriptsDir = "/opt/two/scripts"
TwoGitServer = "https://git.g3e.fr/"
)
//go:embed frrouting.gpg
var frrKey []byte
type metaDoc struct {
InstanceID string `yaml:"instance-id"`
LocalHostname string `yaml:"local-hostname"`
@ -44,7 +23,6 @@ type metaDoc struct {
type writeFile struct {
Path string `yaml:"path"`
Permissions string `yaml:"permissions"`
Encoding string `yaml:"encoding,omitempty"`
Content string `yaml:"content"`
}
@ -90,51 +68,24 @@ func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) {
return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
}
func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) {
func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, error) {
cfg := cloudConfig{
Hostname: n.Name,
SSHPwauth: false,
DisableRoot: true,
SSHAuthorizedKeys: o.AuthorizedKeys,
SSHAuthorizedKeys: keys,
}
var steps []string
switch {
case n.Role == topology.RoleSwitch:
if n.Role == topology.RoleSwitch {
cfg.Packages = []string{"nftables"}
cfg.WriteFiles = []writeFile{
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n)},
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)},
{Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
{Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)},
{Path: SwitchUnit, Permissions: "0644", Content: switchUnit()},
}
steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-switch.service")
case n.Loopback.IsValid():
cfg.WriteFiles = []writeFile{
{Path: NodeScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + loopbackLines(n)},
{Path: NodeUnit, Permissions: "0644", Content: unit("Lab node: loopback", NodeScript)},
cfg.Runcmd = [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "lab-switch.service"},
}
steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service")
}
if n.Role == topology.RoleHypervisor {
cfg.WriteFiles = append(cfg.WriteFiles,
writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)},
writeFile{Path: TwoScriptsDir + "/bootstrap_kvm.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.BootstrapKVM)},
writeFile{Path: DeployScript, Permissions: "0755", Content: deployScript(p, n)},
)
steps = append(steps, DeployScript)
}
if n.FRR != "" {
cfg.WriteFiles = append(cfg.WriteFiles,
writeFile{Path: FRRKey, Permissions: "0644", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(frrKey)},
writeFile{Path: FRRConfig, Permissions: "0640", Content: o.FRR[n.Name]},
writeFile{Path: FRRScript, Permissions: "0755", Content: frrScript(n)},
)
steps = append(steps, FRRScript)
}
if len(steps) > 0 {
cfg.WriteFiles = append(cfg.WriteFiles,
writeFile{Path: ProvisionScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + strings.Join(steps, "\n") + "\n"},
)
cfg.Runcmd = [][]string{{ProvisionScript}}
}
body, err := yaml.Marshal(cfg)
if err != nil {
@ -143,47 +94,6 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error)
return append([]byte("#cloud-config\n"), body...), nil
}
func retry(command string) string {
return "n=0\nuntil " + command + "; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n"
}
func deployScript(p *topology.Plan, n topology.NodePlan) string {
uplink := nodeCables(p, n.Name)[0].NodeInterface
return "#!/bin/sh\nset -eu\n" +
retry("curl -fsS -o /dev/null "+TwoGitServer) +
"cd " + TwoScriptsDir + "\n" +
fmt.Sprintf("bash ./deploy.sh --noup_script -i -u %s -t %s\n", uplink, n.Release)
}
func frrDaemons(n topology.NodePlan) []string {
if n.Role == topology.RoleHypervisor {
return []string{"bgpd"}
}
return []string{"bgpd", "bfdd"}
}
func frrScript(n topology.NodePlan) string {
var b strings.Builder
b.WriteString("#!/bin/sh\nset -eu\nexport DEBIAN_FRONTEND=noninteractive\n. /etc/os-release\n")
fmt.Fprintf(&b, "echo \"deb [signed-by=%s] %s ${VERSION_CODENAME} %s\" > /etc/apt/sources.list.d/frr.list\n", FRRKey, FRRRepo, FRRSuite)
b.WriteString(retry("apt-get update -qq --error-on=any"))
fmt.Fprintf(&b, "apt-get install -y -qq --no-install-recommends %s\n", FRRPackages)
for _, d := range frrDaemons(n) {
fmt.Fprintf(&b, "sed -i 's/^%s=no/%s=yes/' /etc/frr/daemons\n", d, d)
}
fmt.Fprintf(&b, "install -o frr -g frr -m 0640 %s /etc/frr/frr.conf\n", FRRConfig)
b.WriteString("systemctl restart frr\n")
return b.String()
}
func loopbackLines(n topology.NodePlan) string {
if !n.Loopback.IsValid() {
return ""
}
return fmt.Sprintf("ip link add %s type dummy 2>/dev/null || true\nip addr replace %s dev %s\nip link set dev %s up\n",
topology.LoopbackInterface, n.Loopback, topology.LoopbackInterface, topology.LoopbackInterface)
}
func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) {
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
admin := ethernet{
@ -211,9 +121,6 @@ func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, er
MTU: c.MTU,
Addresses: []string{c.NodeAddress.String()},
}
for _, prefix := range n.Secondary[c.Segment] {
e.Addresses = append(e.Addresses, prefix.String())
}
if i == 0 {
e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
e.Nameservers = &nameservers{Addresses: Nameservers}
@ -232,8 +139,7 @@ func gatewayOf(p *topology.Plan, segment string) string {
return ""
}
func switchScript(p *topology.Plan, n topology.NodePlan) string {
name := n.Name
func switchScript(p *topology.Plan, name string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
for _, s := range switchSegments(p, name) {
@ -247,12 +153,8 @@ func switchScript(p *topology.Plan, n topology.NodePlan) string {
}
fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU)
fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge)
for _, prefix := range n.Secondary[s.Name] {
fmt.Fprintf(&b, "ip addr replace %s dev %s\n", prefix, s.Bridge)
}
fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge)
}
b.WriteString(loopbackLines(n))
fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
return b.String()
}
@ -273,9 +175,9 @@ table ip lab_nat {
`, strings.Join(networks, ", "), AdminInterface)
}
func unit(description, script string) string {
func switchUnit() string {
return fmt.Sprintf(`[Unit]
Description=%s
Description=Lab switch: bridges, gateways and NAT
Wants=network-online.target
After=network-online.target
@ -286,5 +188,5 @@ ExecStart=%s
[Install]
WantedBy=multi-user.target
`, description, script)
`, SwitchScript)
}

View file

@ -3,9 +3,6 @@ package topology
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
)
@ -20,20 +17,6 @@ func (p *Plan) Write(w io.Writer) error {
fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%d MiB\t127.0.0.1:%d\n", n.Name, n.Role, n.Image, n.CPUs, n.Memory, n.SSHPort)
}
var extras []NodePlan
for _, n := range p.Nodes {
if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" {
extras = append(extras, n)
}
}
if len(extras) > 0 {
fmt.Fprintf(tw, "\nroles\n")
fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\n")
for _, n := range extras {
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release))
}
}
for _, s := range p.Segments {
fmt.Fprintf(tw, "\nsegment %s: %s, mtu %d, switch %s, bridge %s, gateway %s\n",
s.Name, s.Network, s.MTU, s.Switch, s.Bridge, s.Gateway)
@ -49,32 +32,3 @@ func (p *Plan) Write(w io.Writer) error {
}
return tw.Flush()
}
func loopback(n NodePlan) string {
if !n.Loopback.IsValid() {
return ""
}
return LoopbackInterface + " " + n.Loopback.String()
}
func secondary(n NodePlan) string {
segments := make([]string, 0, len(n.Secondary))
for s := range n.Secondary {
segments = append(segments, s)
}
sort.Strings(segments)
var parts []string
for _, s := range segments {
for _, prefix := range n.Secondary[s] {
parts = append(parts, s+" "+prefix.String())
}
}
return strings.Join(parts, ", ")
}
func orDash(s string) string {
if s == "" || s == "." {
return "-"
}
return s
}

View file

@ -34,16 +34,12 @@ type SegmentPlan struct {
}
type NodePlan struct {
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
Secondary map[string][]netip.Prefix
Loopback netip.Prefix
FRR string
Release string
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
}
type Cable struct {
@ -69,28 +65,14 @@ func Compute(t *Topology) (*Plan, error) {
p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
for i, n := range t.Nodes {
node := NodePlan{
p.Nodes = append(p.Nodes, NodePlan{
Name: n.Name,
Role: n.Role,
Image: n.Image,
CPUs: n.CPUs,
Memory: n.Memory,
SSHPort: SSHBasePort + i,
FRR: n.FRR,
Release: n.Release,
}
for segment, raws := range n.Secondary {
for _, raw := range raws {
if node.Secondary == nil {
node.Secondary = map[string][]netip.Prefix{}
}
node.Secondary[segment] = append(node.Secondary[segment], netip.MustParsePrefix(raw))
}
}
if n.Loopback != "" {
node.Loopback = netip.MustParsePrefix(n.Loopback)
}
p.Nodes = append(p.Nodes, node)
})
}
var errs []error

View file

@ -55,8 +55,8 @@ segments:
nodes:
sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
`
func TestCompute_TwoHypervisorsPlan(t *testing.T) {
@ -165,7 +165,7 @@ segments:
blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red] }
rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] }
`)
got := make([]string, 0, len(p.Cables))
@ -193,7 +193,7 @@ nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
n1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] }
n2: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] }
n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a], release: 0.2.0rc002 }
n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a] }
`)
macs := map[string]bool{}
ports := map[int]bool{}
@ -314,11 +314,6 @@ nodes
hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202
hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203
roles
name loopback secondary frr release
hv1 - - - 0.2.0rc002
hv2 - - - 0.2.0rc002
segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1
node interface address mac udp switch port mac udp
rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001

View file

@ -1,216 +0,0 @@
package topology
import (
"bytes"
"net/netip"
"os"
"path/filepath"
"reflect"
"testing"
)
const withRoles = header + `
segments:
underlay: { switch: sw1, cidr: 192.168.14.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: frr/rr1.conf }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf, release: 0.2.0rc002 }
`
func TestCompute_CarriesTheRoleFields(t *testing.T) {
p := compute(t, withRoles)
rr1 := nodeOf(t, p, "rr1")
if !reflect.DeepEqual(rr1.Secondary, map[string][]netip.Prefix{"underlay": {netip.MustParsePrefix("169.254.0.3/28")}}) {
t.Errorf("rr1 secondary = %v", rr1.Secondary)
}
if rr1.Loopback != netip.MustParsePrefix("10.255.255.1/32") {
t.Errorf("rr1 loopback = %v", rr1.Loopback)
}
if rr1.FRR != "frr/rr1.conf" {
t.Errorf("rr1 frr = %q", rr1.FRR)
}
hv1 := nodeOf(t, p, "hv1")
if hv1.Secondary != nil || hv1.Loopback.IsValid() {
t.Errorf("hv1 = %+v, want no secondary and no loopback", hv1)
}
}
func TestLoad_ResolvesFRRPathsAgainstTheTopologyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lab.yml")
if err := os.WriteFile(path, []byte(withRoles), 0o600); err != nil {
t.Fatal(err)
}
topo, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
want := map[string]string{
"sw1": filepath.Join(dir, "frr", "sw1.conf"),
"rr1": filepath.Join(dir, "frr", "rr1.conf"),
"hv1": "/etc/lab/hv1.conf",
}
for _, n := range topo.Nodes {
if n.FRR != want[n.Name] {
t.Errorf("%s frr = %q, want %q", n.Name, n.FRR, want[n.Name])
}
}
}
func TestValidate_RoleFieldRejections(t *testing.T) {
cases := map[string]struct {
node string
want string
}{
"secondary on a segment not attached": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { blue: [169.254.0.3/28] } }`,
"node rr1: secondary address given for segment blue it is not attached to",
},
"secondary without prefix length": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [169.254.0.3] } }`,
`node rr1: secondary address "169.254.0.3" on red`,
},
"secondary in IPv6": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: ["fd00::3/64"] } }`,
"node rr1: secondary address fd00::3/64 on red is not IPv4",
},
"secondary inside the segment": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [10.1.0.9/24] } }`,
"node rr1: secondary address 10.1.0.9/24 is inside segment red (10.1.0.0/24), use addresses instead",
},
"loopback without prefix length": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: 10.255.255.1 }`,
`node rr1: loopback "10.255.255.1"`,
},
"loopback in IPv6": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: "fd00::1/128" }`,
"node rr1: loopback fd00::1/128 is not IPv4",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
blue: { switch: sw, cidr: 10.2.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 }
` + c.node + `
`
requireContains(t, validationError(t, doc), c.want)
})
}
}
func TestValidate_SwitchSecondaryOnlyOnItsOwnSegments(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
blue: { switch: other, cidr: 10.2.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512, secondary: { red: [169.254.0.1/28], blue: [169.254.1.1/28] } }
other: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 }
`
msg := validationError(t, doc)
requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to")
if bytes.Contains([]byte(msg), []byte("segment red")) {
t.Errorf("the switch's own segment was refused:\n%s", msg)
}
}
func TestValidate_LoopbackInterfaceNameIsReserved(t *testing.T) {
doc := header + `
segments:
lo1: { switch: sw, cidr: 10.1.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1], release: 0.2.0rc002 }
`
requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface")
}
func TestWrite_ShowsTheRoles(t *testing.T) {
var buf bytes.Buffer
if err := compute(t, withRoles).Write(&buf); err != nil {
t.Fatalf("Write: %v", err)
}
want := `
roles
name loopback secondary frr release
sw1 - underlay 169.254.0.1/28 sw1.conf -
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf -
hv1 - - hv1.conf 0.2.0rc002
`
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want)
}
}
func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) {
var buf bytes.Buffer
doc := header + `
segments:
underlay: { switch: sw1, cidr: 10.250.0.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 1, memory: 512 }
rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [underlay] }
`
if err := compute(t, doc).Write(&buf); err != nil {
t.Fatalf("Write: %v", err)
}
if bytes.Contains(buf.Bytes(), []byte("roles")) {
t.Errorf("plan shows a roles section:\n%s", buf.String())
}
}
func TestValidate_ReleaseRejections(t *testing.T) {
cases := map[string]struct {
node string
want string
}{
"hypervisor without release": {
`hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red] }`,
"node hv: a hypervisor needs the release of two to deploy (release: <tag>)",
},
"release with shell characters": {
`hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: "0.2.0; reboot" }`,
`node hv: release "0.2.0; reboot" must match`,
},
"release on a route reflector": {
`hv: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }`,
"node hv: release is only for hypervisors",
},
"release on a switch": {
`hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }
sw2: { role: switch, image: deb, cpus: 1, memory: 512, release: 0.2.0rc002 }`,
"node sw2: release is only for hypervisors",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
` + c.node + `
`
requireContains(t, validationError(t, doc), c.want)
})
}
}
func TestCompute_CarriesTheRelease(t *testing.T) {
if got := nodeOf(t, compute(t, withRoles), "hv1").Release; got != "0.2.0rc002" {
t.Errorf("hv1 release = %q", got)
}
if got := nodeOf(t, compute(t, withRoles), "rr1").Release; got != "" {
t.Errorf("rr1 release = %q", got)
}
}

View file

@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"go.yaml.in/yaml/v3"
)
@ -46,10 +45,6 @@ type Node struct {
Memory int
Segments []string
Addresses map[string]string
Secondary map[string][]string
Loopback string
FRR string
Release string
}
type fileImage struct {
@ -64,16 +59,12 @@ type fileSegment struct {
}
type fileNode struct {
Role string `yaml:"role"`
Image string `yaml:"image"`
CPUs int `yaml:"cpus"`
Memory int `yaml:"memory"`
Segments []string `yaml:"segments"`
Addresses map[string]string `yaml:"addresses"`
Secondary map[string][]string `yaml:"secondary"`
Loopback string `yaml:"loopback"`
FRR string `yaml:"frr"`
Release string `yaml:"release"`
Role string `yaml:"role"`
Image string `yaml:"image"`
CPUs int `yaml:"cpus"`
Memory int `yaml:"memory"`
Segments []string `yaml:"segments"`
Addresses map[string]string `yaml:"addresses"`
}
type file struct {
@ -92,11 +83,6 @@ func Load(path string) (*Topology, error) {
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for i, n := range t.Nodes {
if n.FRR != "" && !filepath.IsAbs(n.FRR) {
t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), n.FRR)
}
}
return t, nil
}
@ -140,10 +126,6 @@ func Parse(data []byte) (*Topology, error) {
Memory: n.Memory,
Segments: n.Segments,
Addresses: n.Addresses,
Secondary: n.Secondary,
Loopback: n.Loopback,
FRR: n.FRR,
Release: n.Release,
})
}
return t, nil

View file

@ -17,13 +17,11 @@ const (
MinPrefix = 8
ReservedInterface = "mgmt0"
LoopbackInterface = "lo1"
)
var (
namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`)
releasePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
func (t *Topology) Validate() error {
@ -70,9 +68,6 @@ func (t *Topology) Validate() error {
if s.Name == ReservedInterface {
add("segment %s: name is reserved for the administration interface", s.Name)
}
if s.Name == LoopbackInterface {
add("segment %s: name is reserved for the loopback interface", s.Name)
}
sw, ok := nodes[s.Switch]
switch {
case s.Switch == "":
@ -121,7 +116,6 @@ func (t *Topology) Validate() error {
if n.Memory < MinMemory {
add("node %s: memory must be at least %d MiB", n.Name, MinMemory)
}
validateExtras(n, segments, add)
if n.Role == RoleSwitch {
if len(n.Segments) > 0 || len(n.Addresses) > 0 {
add("node %s: a switch carries its segments through segments.<name>.switch, not through segments or addresses", n.Name)
@ -171,61 +165,6 @@ func (t *Topology) Validate() error {
return nil
}
func validateExtras(n Node, segments map[string]Segment, add func(string, ...any)) {
carried := map[string]bool{}
if n.Role == RoleSwitch {
for name, s := range segments {
if s.Switch == n.Name {
carried[name] = true
}
}
} else {
for _, name := range n.Segments {
carried[name] = true
}
}
names := make([]string, 0, len(n.Secondary))
for name := range n.Secondary {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if !carried[name] {
add("node %s: secondary address given for segment %s it is not attached to", n.Name, name)
continue
}
network, _ := netip.ParsePrefix(segments[name].CIDR)
for _, raw := range n.Secondary[name] {
prefix, err := netip.ParsePrefix(raw)
switch {
case err != nil:
add("node %s: secondary address %q on %s: %v", n.Name, raw, name, err)
case !prefix.Addr().Is4():
add("node %s: secondary address %s on %s is not IPv4", n.Name, raw, name)
case network.IsValid() && network.Contains(prefix.Addr()):
add("node %s: secondary address %s is inside segment %s (%s), use addresses instead", n.Name, raw, name, network)
}
}
}
switch {
case n.Role == RoleHypervisor && n.Release == "":
add("node %s: a hypervisor needs the release of two to deploy (release: <tag>)", n.Name)
case n.Role == RoleHypervisor && !releasePattern.MatchString(n.Release):
add("node %s: release %q must match %s", n.Name, n.Release, releasePattern)
case n.Role != RoleHypervisor && n.Release != "":
add("node %s: release is only for hypervisors", n.Name)
}
if n.Loopback != "" {
prefix, err := netip.ParsePrefix(n.Loopback)
switch {
case err != nil:
add("node %s: loopback %q: %v", n.Name, n.Loopback, err)
case !prefix.Addr().Is4():
add("node %s: loopback %s is not IPv4", n.Name, n.Loopback)
}
}
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {

View file

@ -1,9 +0,0 @@
package scripts
import _ "embed"
//go:embed deploy.sh
var Deploy []byte
//go:embed bootstrap_kvm.sh
var BootstrapKVM []byte

View file

@ -28,16 +28,14 @@ usage: ${0##*/} <commande> [arguments]
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
et le prix ; ne crée rien
up crée le serveur de lab, attend la fin de son installation et son SSH,
puis le prépare (voir prepare)
up crée le serveur de lab, attend la fin de son installation et son SSH
status liste les serveurs de lab du projet
ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé
que si l'entrée standard en est un
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
/dev/kvm et la virtualisation imbriquée ; lancé aussi par up
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
référence) ; ensuite : ssh './lab up topology/<topologie>'
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et
~/<topologie> ; ensuite : ssh './lab up <topologie>'
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
@ -334,12 +332,6 @@ push_file () {
ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}"
}
push_dir () {
local SOURCE="${1}"
COPYFILE_DISABLE=1 tar --no-xattrs -C "${SOURCE}" -cf - . \
| ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology"
}
cmd_push () {
local TOPOLOGY="${1:-}"
local NAME="${TOPOLOGY##*/}"
@ -352,8 +344,8 @@ cmd_push () {
(cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \
|| die "compilation de lab échouée"
push_file "${BINARY}" lab 755 || die "envoi de lab échoué"
push_dir "$(dirname "${TOPOLOGY}")" || die "envoi du répertoire de ${NAME} échoué"
info "déposés sur le serveur : ~/lab, ~/topology/ — ensuite : ${0##*/} ssh './lab up topology/${NAME}'"
push_file "${TOPOLOGY}" "${NAME}" 644 || die "envoi de ${NAME} échoué"
info "déposés sur le serveur : ~/lab, ~/${NAME} — ensuite : ${0##*/} ssh './lab up ${NAME}'"
}
delete_server () {

View file

@ -147,7 +147,7 @@ case "$*" in
*"bash -s"*)
cat > "${FAKE_DIR}/prepare.sh"
exit "${FAKE_PREPARE_RC:-0}" ;;
*"cat > "*|*"-xf -"*)
*"cat > "*)
N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.')
cat > "${FAKE_DIR}/pushed.$(( N + 1 ))"
exit "${FAKE_PUSH_RC:-0}" ;;
@ -659,27 +659,18 @@ test_prepare_without_known_server_is_refused () {
}
test_push_builds_for_linux_and_sends_binary_and_topology () {
setup "push : compile lab pour linux/amd64, envoie le binaire et le répertoire de la topologie"
setup "push : compile lab pour linux/amd64, envoie le binaire et la topologie"
known_server
mkdir -p "${WORK}/conf/frr"
printf 'name: evpn-2hv\n' > "${WORK}/conf/evpn-2hv.yml"
printf 'hostname rr1\n' > "${WORK}/conf/frr/rr1.conf"
xattr -w com.apple.test lab "${WORK}/conf/evpn-2hv.yml" 2>/dev/null || true
run_lab push "${WORK}/conf/evpn-2hv.yml" || fail "code de sortie $?"
local REPO LISTING
printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml"
run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?"
local REPO
REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)"
[[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \
|| fail "compilation : $(cat "${WORK}/go.log")"
grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent"
grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \
|| fail "envoi du répertoire absent"
grep -q "debian@203.0.113.7 cat > 'evpn-2hv.yml.part' && chmod 644 'evpn-2hv.yml.part' && mv 'evpn-2hv.yml.part' 'evpn-2hv.yml'" "${WORK}/ssh.log" || fail "envoi de la topologie absent"
[[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")"
LISTING=$(tar -tf "${WORK}/pushed.2" | sed 's|^\./||' | grep -v '/$' | grep -v '^$' | sort | tr '\n' ' ')
[[ "${LISTING}" == "evpn-2hv.yml frr/rr1.conf " ]] || fail "contenu de l'archive : ${LISTING}"
grep -aq 'LIBARCHIVE.xattr' "${WORK}/pushed.2" && fail "attributs étendus macOS dans l'archive"
mkdir "${WORK}/x" && tar -C "${WORK}/x" -xf "${WORK}/pushed.2"
[[ $(cat "${WORK}/x/frr/rr1.conf") == "hostname rr1" ]] || fail "frr/rr1.conf altéré"
grep -q "ssh './lab up topology/evpn-2hv.yml'" "${WORK}/out.log" || fail "consigne finale absente"
[[ $(cat "${WORK}/pushed.2") == "name: evpn-2hv" ]] || fail "contenu de la topologie : $(cat "${WORK}/pushed.2")"
teardown
}