f-50: lab: rôles des nœuds, adresses du lien, loopback et installation de FRR #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
0c36ed8977
commit
8d1691ae94
21 changed files with 962 additions and 58 deletions
|
|
@ -130,7 +130,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int {
|
||||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized})
|
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})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"lab evpn-2hv: nodes 4, segments 1, cables 3",
|
"lab evpn-2hv: nodes 4, segments 1, cables 3",
|
||||||
"gateway 10.250.0.1",
|
"gateway 192.168.14.1",
|
||||||
"hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2",
|
"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",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(stdout, want) {
|
if !strings.Contains(stdout, want) {
|
||||||
t.Errorf("output does not contain %q:\n%s", want, stdout)
|
t.Errorf("output does not contain %q:\n%s", want, stdout)
|
||||||
|
|
@ -299,3 +300,55 @@ func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) {
|
||||||
t.Error("the topology of a running lab was replaced")
|
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] }
|
||||||
|
`
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,20 @@ images:
|
||||||
segments:
|
segments:
|
||||||
underlay:
|
underlay:
|
||||||
switch: sw1
|
switch: sw1
|
||||||
cidr: 10.250.0.0/24
|
cidr: 192.168.14.0/24
|
||||||
mtu: 9000
|
mtu: 9000
|
||||||
|
|
||||||
nodes:
|
nodes:
|
||||||
sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 }
|
sw1:
|
||||||
rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] }
|
{ role: switch, image: debian12, cpus: 2, memory: 1024,
|
||||||
hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
|
secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
|
||||||
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
|
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 }
|
||||||
|
hv2:
|
||||||
|
{ role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay],
|
||||||
|
addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf }
|
||||||
|
|
|
||||||
19
conf/lab/frr/hv1.conf
Normal file
19
conf/lab/frr/hv1.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
!
|
||||||
19
conf/lab/frr/hv2.conf
Normal file
19
conf/lab/frr/hv2.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
!
|
||||||
43
conf/lab/frr/rr1.conf
Normal file
43
conf/lab/frr/rr1.conf
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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
|
||||||
|
!
|
||||||
28
conf/lab/frr/sw1.conf
Normal file
28
conf/lab/frr/sw1.conf
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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
|
||||||
|
!
|
||||||
|
|
@ -116,8 +116,9 @@ Commandes
|
||||||
que si l'entrée standard en est un
|
que si l'entrée standard en est un
|
||||||
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
|
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
|
/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
|
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
|
||||||
~/<topologie> ; ensuite : ssh './lab up <topologie>'
|
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
|
||||||
|
référence) ; ensuite : ssh './lab up topology/<topologie>'
|
||||||
down supprime tous les serveurs de lab du projet et attend leur disparition
|
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
|
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
|
||||||
|
|
||||||
|
|
@ -178,13 +179,15 @@ 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 up
|
||||||
scripts/lab-host.sh push conf/lab/evpn-2hv.yml
|
scripts/lab-host.sh push conf/lab/evpn-2hv.yml
|
||||||
scripts/lab-host.sh ssh './lab up evpn-2hv.yml'
|
scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml'
|
||||||
scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1
|
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 ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé
|
||||||
scripts/lab-host.sh down
|
scripts/lab-host.sh down
|
||||||
|
|
||||||
``push`` transfère par la connexion SSH du script (``cat`` côté serveur, fichier renommé une fois
|
``push`` transfère par la connexion SSH du script — mêmes options, même clé, même
|
||||||
complet) : mêmes options, même clé, même ``known_hosts`` que ``ssh``.
|
``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.
|
||||||
|
|
||||||
Topologie
|
Topologie
|
||||||
---------
|
---------
|
||||||
|
|
@ -212,9 +215,23 @@ Ce que le fichier déclare :
|
||||||
``nodes``
|
``nodes``
|
||||||
``role`` (``switch``, ``rr`` ou ``hypervisor``), ``image``, ``cpus``, ``memory`` en Mio
|
``role`` (``switch``, ``rr`` ou ``hypervisor``), ``image``, ``cpus``, ``memory`` en Mio
|
||||||
(256 au moins), ``segments`` auxquels le nœud est relié, et ``addresses`` pour fixer
|
(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: 10.250.0.50}``). Un switch ne
|
l'adresse d'un nœud sur un segment (``addresses: {underlay: 192.168.14.50}``). Un switch ne
|
||||||
déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``.
|
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`_).
|
||||||
|
|
||||||
|
``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 :
|
Ce que l'outil en déduit, de façon déterministe — même fichier, même plan :
|
||||||
|
|
||||||
.. list-table::
|
.. list-table::
|
||||||
|
|
@ -257,11 +274,18 @@ 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
|
hv1 hypervisor debian12 4 16384 MiB 127.0.0.1:2202
|
||||||
hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203
|
hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203
|
||||||
|
|
||||||
segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1
|
roles
|
||||||
node interface address mac udp switch port mac udp
|
name loopback secondary frr
|
||||||
rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001
|
sw1 - underlay 169.254.0.1/28 sw1.conf
|
||||||
hv1 underlay 10.250.0.3/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003
|
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf
|
||||||
hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005
|
hv1 - - hv1.conf
|
||||||
|
hv2 - - hv2.conf
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Un fichier invalide est refusé avec **toutes** ses erreurs à la fois, et un code de sortie 1. Les
|
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 :
|
champs inconnus et les clés en double sont refusés aussi :
|
||||||
|
|
@ -274,8 +298,49 @@ 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]
|
segment underlay: cidr 10.250.0.0/31 prefix length out of range [/8, /30]
|
||||||
node sw1: switch carries no segment
|
node sw1: switch carries no segment
|
||||||
|
|
||||||
Les plages d'adresses de l'exemple sont des valeurs de travail : le plan d'adressage du lab reste à
|
Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs
|
||||||
définir (#50).
|
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.
|
||||||
|
|
||||||
|
.. 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.
|
||||||
|
|
||||||
Rendu des VM
|
Rendu des VM
|
||||||
------------
|
------------
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}})
|
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})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +88,21 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
|
||||||
return nodes, nil
|
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) {
|
func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) {
|
||||||
private := filepath.Join(dir, KeyFile)
|
private := filepath.Join(dir, KeyFile)
|
||||||
if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) {
|
||||||
|
|
|
||||||
|
|
@ -315,3 +315,73 @@ func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) {
|
||||||
t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
internal/lab/render/frrouting.gpg
Normal file
BIN
internal/lab/render/frrouting.gpg
Normal file
Binary file not shown.
|
|
@ -30,6 +30,7 @@ var Nameservers = []string{"1.1.1.1", "8.8.8.8"}
|
||||||
type Options struct {
|
type Options struct {
|
||||||
RunDir string
|
RunDir string
|
||||||
AuthorizedKeys []string
|
AuthorizedKeys []string
|
||||||
|
FRR map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
|
|
@ -45,6 +46,11 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
|
||||||
if err := o.validate(); err != nil {
|
if err := o.validate(); err != nil {
|
||||||
return nil, err
|
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
|
var nodes []Node
|
||||||
for index, n := range p.Nodes {
|
for index, n := range p.Nodes {
|
||||||
dir := filepath.Join(o.RunDir, n.Name)
|
dir := filepath.Join(o.RunDir, n.Name)
|
||||||
|
|
@ -52,7 +58,7 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user, err := userData(p, n, o.AuthorizedKeys)
|
user, err := userData(p, n, o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
internal/lab/render/roles_test.go
Normal file
188
internal/lab/render/roles_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"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 }
|
||||||
|
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
|
||||||
|
`
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
wantCmds := []string{"systemctl daemon-reload", "systemctl enable --now lab-node.service", "/usr/local/sbin/lab-frr"}
|
||||||
|
if got := runcmd(t, rr1); !reflect.DeepEqual(got, wantCmds) {
|
||||||
|
t.Errorf("runcmd = %q, want %q", got, wantCmds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_FRRInstallRunsLast(t *testing.T) {
|
||||||
|
nodes := renderRoles(t)
|
||||||
|
for name, want := range map[string][]string{
|
||||||
|
"sw1": {"systemctl daemon-reload", "systemctl enable --now lab-switch.service", "/usr/local/sbin/lab-frr"},
|
||||||
|
"hv1": {"/usr/local/sbin/lab-frr"},
|
||||||
|
} {
|
||||||
|
if got := runcmd(t, nodeNamed(t, nodes, name)); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("%s runcmd = %q, want %q", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoles_NodeWithoutRoleFieldsGetsNothingExtra(t *testing.T) {
|
||||||
|
cfg := user(t, nodeNamed(t, renderRoles(t), "hv2"))
|
||||||
|
if len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 {
|
||||||
|
t.Errorf("hv2 write_files %d, runcmd %q", len(cfg.WriteFiles), cfg.Runcmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package render
|
package render
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
|
@ -13,8 +15,21 @@ const (
|
||||||
SwitchScript = "/usr/local/sbin/lab-switch"
|
SwitchScript = "/usr/local/sbin/lab-switch"
|
||||||
SwitchNFT = "/etc/lab-switch.nft"
|
SwitchNFT = "/etc/lab-switch.nft"
|
||||||
SwitchUnit = "/etc/systemd/system/lab-switch.service"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed frrouting.gpg
|
||||||
|
var frrKey []byte
|
||||||
|
|
||||||
type metaDoc struct {
|
type metaDoc struct {
|
||||||
InstanceID string `yaml:"instance-id"`
|
InstanceID string `yaml:"instance-id"`
|
||||||
LocalHostname string `yaml:"local-hostname"`
|
LocalHostname string `yaml:"local-hostname"`
|
||||||
|
|
@ -23,6 +38,7 @@ type metaDoc struct {
|
||||||
type writeFile struct {
|
type writeFile struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
Permissions string `yaml:"permissions"`
|
Permissions string `yaml:"permissions"`
|
||||||
|
Encoding string `yaml:"encoding,omitempty"`
|
||||||
Content string `yaml:"content"`
|
Content string `yaml:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,24 +84,42 @@ func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) {
|
||||||
return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
|
return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, error) {
|
func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) {
|
||||||
cfg := cloudConfig{
|
cfg := cloudConfig{
|
||||||
Hostname: n.Name,
|
Hostname: n.Name,
|
||||||
SSHPwauth: false,
|
SSHPwauth: false,
|
||||||
DisableRoot: true,
|
DisableRoot: true,
|
||||||
SSHAuthorizedKeys: keys,
|
SSHAuthorizedKeys: o.AuthorizedKeys,
|
||||||
}
|
}
|
||||||
if n.Role == topology.RoleSwitch {
|
switch {
|
||||||
|
case n.Role == topology.RoleSwitch:
|
||||||
cfg.Packages = []string{"nftables"}
|
cfg.Packages = []string{"nftables"}
|
||||||
cfg.WriteFiles = []writeFile{
|
cfg.WriteFiles = []writeFile{
|
||||||
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)},
|
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n)},
|
||||||
{Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
|
{Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
|
||||||
{Path: SwitchUnit, Permissions: "0644", Content: switchUnit()},
|
{Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)},
|
||||||
}
|
}
|
||||||
cfg.Runcmd = [][]string{
|
cfg.Runcmd = [][]string{
|
||||||
{"systemctl", "daemon-reload"},
|
{"systemctl", "daemon-reload"},
|
||||||
{"systemctl", "enable", "--now", "lab-switch.service"},
|
{"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-node.service"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)},
|
||||||
|
)
|
||||||
|
cfg.Runcmd = append(cfg.Runcmd, []string{FRRScript})
|
||||||
}
|
}
|
||||||
body, err := yaml.Marshal(cfg)
|
body, err := yaml.Marshal(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -94,6 +128,35 @@ func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, err
|
||||||
return append([]byte("#cloud-config\n"), body...), nil
|
return append([]byte("#cloud-config\n"), body...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("n=0\nuntil apt-get update -qq --error-on=any; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n")
|
||||||
|
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) {
|
func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) {
|
||||||
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
|
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
|
||||||
admin := ethernet{
|
admin := ethernet{
|
||||||
|
|
@ -121,6 +184,9 @@ func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, er
|
||||||
MTU: c.MTU,
|
MTU: c.MTU,
|
||||||
Addresses: []string{c.NodeAddress.String()},
|
Addresses: []string{c.NodeAddress.String()},
|
||||||
}
|
}
|
||||||
|
for _, prefix := range n.Secondary[c.Segment] {
|
||||||
|
e.Addresses = append(e.Addresses, prefix.String())
|
||||||
|
}
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
|
e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
|
||||||
e.Nameservers = &nameservers{Addresses: Nameservers}
|
e.Nameservers = &nameservers{Addresses: Nameservers}
|
||||||
|
|
@ -139,7 +205,8 @@ func gatewayOf(p *topology.Plan, segment string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchScript(p *topology.Plan, name string) string {
|
func switchScript(p *topology.Plan, n topology.NodePlan) string {
|
||||||
|
name := n.Name
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
|
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
|
||||||
for _, s := range switchSegments(p, name) {
|
for _, s := range switchSegments(p, name) {
|
||||||
|
|
@ -153,8 +220,12 @@ func switchScript(p *topology.Plan, name string) string {
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU)
|
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)
|
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)
|
fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge)
|
||||||
}
|
}
|
||||||
|
b.WriteString(loopbackLines(n))
|
||||||
fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
|
fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
@ -175,9 +246,9 @@ table ip lab_nat {
|
||||||
`, strings.Join(networks, ", "), AdminInterface)
|
`, strings.Join(networks, ", "), AdminInterface)
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchUnit() string {
|
func unit(description, script string) string {
|
||||||
return fmt.Sprintf(`[Unit]
|
return fmt.Sprintf(`[Unit]
|
||||||
Description=Lab switch: bridges, gateways and NAT
|
Description=%s
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
|
|
||||||
|
|
@ -188,5 +259,5 @@ ExecStart=%s
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`, SwitchScript)
|
`, description, script)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package topology
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,6 +20,20 @@ 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)
|
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 != "" {
|
||||||
|
extras = append(extras, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(extras) > 0 {
|
||||||
|
fmt.Fprintf(tw, "\nroles\n")
|
||||||
|
fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\n")
|
||||||
|
for _, n := range extras {
|
||||||
|
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range p.Segments {
|
for _, s := range p.Segments {
|
||||||
fmt.Fprintf(tw, "\nsegment %s: %s, mtu %d, switch %s, bridge %s, gateway %s\n",
|
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)
|
s.Name, s.Network, s.MTU, s.Switch, s.Bridge, s.Gateway)
|
||||||
|
|
@ -32,3 +49,32 @@ func (p *Plan) Write(w io.Writer) error {
|
||||||
}
|
}
|
||||||
return tw.Flush()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,15 @@ type SegmentPlan struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodePlan struct {
|
type NodePlan struct {
|
||||||
Name string
|
Name string
|
||||||
Role string
|
Role string
|
||||||
Image string
|
Image string
|
||||||
CPUs int
|
CPUs int
|
||||||
Memory int
|
Memory int
|
||||||
SSHPort int
|
SSHPort int
|
||||||
|
Secondary map[string][]netip.Prefix
|
||||||
|
Loopback netip.Prefix
|
||||||
|
FRR string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Cable struct {
|
type Cable struct {
|
||||||
|
|
@ -65,14 +68,27 @@ func Compute(t *Topology) (*Plan, error) {
|
||||||
|
|
||||||
p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
|
p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
|
||||||
for i, n := range t.Nodes {
|
for i, n := range t.Nodes {
|
||||||
p.Nodes = append(p.Nodes, NodePlan{
|
node := NodePlan{
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
Role: n.Role,
|
Role: n.Role,
|
||||||
Image: n.Image,
|
Image: n.Image,
|
||||||
CPUs: n.CPUs,
|
CPUs: n.CPUs,
|
||||||
Memory: n.Memory,
|
Memory: n.Memory,
|
||||||
SSHPort: SSHBasePort + i,
|
SSHPort: SSHBasePort + i,
|
||||||
})
|
FRR: n.FRR,
|
||||||
|
}
|
||||||
|
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
|
var errs []error
|
||||||
|
|
|
||||||
163
internal/lab/topology/roles_test.go
Normal file
163
internal/lab/topology/roles_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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 }
|
||||||
|
`
|
||||||
|
|
||||||
|
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] }
|
||||||
|
` + 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] }
|
||||||
|
`
|
||||||
|
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] }
|
||||||
|
`
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`
|
||||||
|
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
|
||||||
|
if err := compute(t, twoHypervisors).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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
)
|
)
|
||||||
|
|
@ -45,6 +46,9 @@ type Node struct {
|
||||||
Memory int
|
Memory int
|
||||||
Segments []string
|
Segments []string
|
||||||
Addresses map[string]string
|
Addresses map[string]string
|
||||||
|
Secondary map[string][]string
|
||||||
|
Loopback string
|
||||||
|
FRR string
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileImage struct {
|
type fileImage struct {
|
||||||
|
|
@ -59,12 +63,15 @@ type fileSegment struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileNode struct {
|
type fileNode struct {
|
||||||
Role string `yaml:"role"`
|
Role string `yaml:"role"`
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image"`
|
||||||
CPUs int `yaml:"cpus"`
|
CPUs int `yaml:"cpus"`
|
||||||
Memory int `yaml:"memory"`
|
Memory int `yaml:"memory"`
|
||||||
Segments []string `yaml:"segments"`
|
Segments []string `yaml:"segments"`
|
||||||
Addresses map[string]string `yaml:"addresses"`
|
Addresses map[string]string `yaml:"addresses"`
|
||||||
|
Secondary map[string][]string `yaml:"secondary"`
|
||||||
|
Loopback string `yaml:"loopback"`
|
||||||
|
FRR string `yaml:"frr"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type file struct {
|
type file struct {
|
||||||
|
|
@ -83,6 +90,11 @@ func Load(path string) (*Topology, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
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
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,6 +138,9 @@ func Parse(data []byte) (*Topology, error) {
|
||||||
Memory: n.Memory,
|
Memory: n.Memory,
|
||||||
Segments: n.Segments,
|
Segments: n.Segments,
|
||||||
Addresses: n.Addresses,
|
Addresses: n.Addresses,
|
||||||
|
Secondary: n.Secondary,
|
||||||
|
Loopback: n.Loopback,
|
||||||
|
FRR: n.FRR,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const (
|
||||||
MinPrefix = 8
|
MinPrefix = 8
|
||||||
|
|
||||||
ReservedInterface = "mgmt0"
|
ReservedInterface = "mgmt0"
|
||||||
|
LoopbackInterface = "lo1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -68,6 +69,9 @@ func (t *Topology) Validate() error {
|
||||||
if s.Name == ReservedInterface {
|
if s.Name == ReservedInterface {
|
||||||
add("segment %s: name is reserved for the administration interface", s.Name)
|
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]
|
sw, ok := nodes[s.Switch]
|
||||||
switch {
|
switch {
|
||||||
case s.Switch == "":
|
case s.Switch == "":
|
||||||
|
|
@ -116,6 +120,7 @@ func (t *Topology) Validate() error {
|
||||||
if n.Memory < MinMemory {
|
if n.Memory < MinMemory {
|
||||||
add("node %s: memory must be at least %d MiB", n.Name, MinMemory)
|
add("node %s: memory must be at least %d MiB", n.Name, MinMemory)
|
||||||
}
|
}
|
||||||
|
validateExtras(n, segments, add)
|
||||||
if n.Role == RoleSwitch {
|
if n.Role == RoleSwitch {
|
||||||
if len(n.Segments) > 0 || len(n.Addresses) > 0 {
|
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)
|
add("node %s: a switch carries its segments through segments.<name>.switch, not through segments or addresses", n.Name)
|
||||||
|
|
@ -165,6 +170,53 @@ func (t *Topology) Validate() error {
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
func sortedKeys(m map[string]string) []string {
|
||||||
keys := make([]string, 0, len(m))
|
keys := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,16 @@ usage: ${0##*/} <commande> [arguments]
|
||||||
|
|
||||||
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
|
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
|
et le prix ; ne crée rien
|
||||||
up crée le serveur de lab, attend la fin de son installation et son SSH
|
up crée le serveur de lab, attend la fin de son installation et son SSH,
|
||||||
|
puis le prépare (voir prepare)
|
||||||
status liste les serveurs de lab du projet
|
status liste les serveurs de lab du projet
|
||||||
ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé
|
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
|
que si l'entrée standard en est un
|
||||||
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
|
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
|
/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
|
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
|
||||||
~/<topologie> ; ensuite : ssh './lab up <topologie>'
|
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
|
||||||
|
référence) ; ensuite : ssh './lab up topology/<topologie>'
|
||||||
down supprime tous les serveurs de lab du projet et attend leur disparition
|
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
|
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
|
||||||
|
|
||||||
|
|
@ -332,6 +334,12 @@ push_file () {
|
||||||
ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}"
|
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 -xf - && rm -rf topology && mv topology.part topology"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_push () {
|
cmd_push () {
|
||||||
local TOPOLOGY="${1:-}"
|
local TOPOLOGY="${1:-}"
|
||||||
local NAME="${TOPOLOGY##*/}"
|
local NAME="${TOPOLOGY##*/}"
|
||||||
|
|
@ -344,8 +352,8 @@ cmd_push () {
|
||||||
(cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \
|
(cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \
|
||||||
|| die "compilation de lab échouée"
|
|| die "compilation de lab échouée"
|
||||||
push_file "${BINARY}" lab 755 || die "envoi de lab échoué"
|
push_file "${BINARY}" lab 755 || die "envoi de lab échoué"
|
||||||
push_file "${TOPOLOGY}" "${NAME}" 644 || die "envoi de ${NAME} échoué"
|
push_dir "$(dirname "${TOPOLOGY}")" || die "envoi du répertoire de ${NAME} échoué"
|
||||||
info "déposés sur le serveur : ~/lab, ~/${NAME} — ensuite : ${0##*/} ssh './lab up ${NAME}'"
|
info "déposés sur le serveur : ~/lab, ~/topology/ — ensuite : ${0##*/} ssh './lab up topology/${NAME}'"
|
||||||
}
|
}
|
||||||
|
|
||||||
delete_server () {
|
delete_server () {
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ case "$*" in
|
||||||
*"bash -s"*)
|
*"bash -s"*)
|
||||||
cat > "${FAKE_DIR}/prepare.sh"
|
cat > "${FAKE_DIR}/prepare.sh"
|
||||||
exit "${FAKE_PREPARE_RC:-0}" ;;
|
exit "${FAKE_PREPARE_RC:-0}" ;;
|
||||||
*"cat > "*)
|
*"cat > "*|*"-xf -"*)
|
||||||
N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.')
|
N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.')
|
||||||
cat > "${FAKE_DIR}/pushed.$(( N + 1 ))"
|
cat > "${FAKE_DIR}/pushed.$(( N + 1 ))"
|
||||||
exit "${FAKE_PUSH_RC:-0}" ;;
|
exit "${FAKE_PUSH_RC:-0}" ;;
|
||||||
|
|
@ -659,18 +659,27 @@ test_prepare_without_known_server_is_refused () {
|
||||||
}
|
}
|
||||||
|
|
||||||
test_push_builds_for_linux_and_sends_binary_and_topology () {
|
test_push_builds_for_linux_and_sends_binary_and_topology () {
|
||||||
setup "push : compile lab pour linux/amd64, envoie le binaire et la topologie"
|
setup "push : compile lab pour linux/amd64, envoie le binaire et le répertoire de la topologie"
|
||||||
known_server
|
known_server
|
||||||
printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml"
|
mkdir -p "${WORK}/conf/frr"
|
||||||
run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?"
|
printf 'name: evpn-2hv\n' > "${WORK}/conf/evpn-2hv.yml"
|
||||||
local REPO
|
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
|
||||||
REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)"
|
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" ]] \
|
[[ $(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")"
|
|| 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 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 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"
|
grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \
|
||||||
|
|| fail "envoi du répertoire absent"
|
||||||
[[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")"
|
[[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")"
|
||||||
[[ $(cat "${WORK}/pushed.2") == "name: evpn-2hv" ]] || fail "contenu de la topologie : $(cat "${WORK}/pushed.2")"
|
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"
|
||||||
teardown
|
teardown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue