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:
GnomeZworc 2026-10-04 17:17:02 +02:00
commit 8d1691ae94
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
21 changed files with 962 additions and 58 deletions

View file

@ -130,7 +130,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "lab: %v\n", err)
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 {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1

View file

@ -33,8 +33,9 @@ func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) {
}
for _, want := range []string{
"lab evpn-2hv: nodes 4, segments 1, cables 3",
"gateway 10.250.0.1",
"hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2",
"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",
} {
if !strings.Contains(stdout, want) {
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")
}
}
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)
}
}

View file

@ -8,11 +8,20 @@ images:
segments:
underlay:
switch: sw1
cidr: 10.250.0.0/24
cidr: 192.168.14.0/24
mtu: 9000
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] }
hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] }
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 }
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
View 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
View 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
View 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
View 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
!

View file

@ -116,8 +116,9 @@ 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
~/<topologie> ; ensuite : ssh './lab up <topologie>'
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>'
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
@ -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 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 ip -br a' # commande, code de retour propagé
scripts/lab-host.sh down
``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``.
``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.
Topologie
---------
@ -212,9 +215,23 @@ 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: 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``.
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 :
.. 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
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
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
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
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
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]
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 à
définir (#50).
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.
.. 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
------------

View file

@ -72,7 +72,11 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
if err != nil {
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 {
return nil, err
}
@ -84,6 +88,21 @@ 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

@ -315,3 +315,73 @@ 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,6 +30,7 @@ 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 {
@ -45,6 +46,11 @@ 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)
@ -52,7 +58,7 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
if err != nil {
return nil, err
}
user, err := userData(p, n, o.AuthorizedKeys)
user, err := userData(p, n, o)
if err != nil {
return nil, err
}

View 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)
}
}

View file

@ -1,6 +1,8 @@
package render
import (
_ "embed"
"encoding/base64"
"fmt"
"strings"
@ -13,8 +15,21 @@ 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"
)
//go:embed frrouting.gpg
var frrKey []byte
type metaDoc struct {
InstanceID string `yaml:"instance-id"`
LocalHostname string `yaml:"local-hostname"`
@ -23,6 +38,7 @@ type metaDoc struct {
type writeFile struct {
Path string `yaml:"path"`
Permissions string `yaml:"permissions"`
Encoding string `yaml:"encoding,omitempty"`
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})
}
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{
Hostname: n.Name,
SSHPwauth: false,
DisableRoot: true,
SSHAuthorizedKeys: keys,
SSHAuthorizedKeys: o.AuthorizedKeys,
}
if n.Role == topology.RoleSwitch {
switch {
case n.Role == topology.RoleSwitch:
cfg.Packages = []string{"nftables"}
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: SwitchUnit, Permissions: "0644", Content: switchUnit()},
{Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)},
}
cfg.Runcmd = [][]string{
{"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-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)
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
}
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) {
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
admin := ethernet{
@ -121,6 +184,9 @@ 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}
@ -139,7 +205,8 @@ func gatewayOf(p *topology.Plan, segment string) string {
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
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
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 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()
}
@ -175,9 +246,9 @@ table ip lab_nat {
`, strings.Join(networks, ", "), AdminInterface)
}
func switchUnit() string {
func unit(description, script string) string {
return fmt.Sprintf(`[Unit]
Description=Lab switch: bridges, gateways and NAT
Description=%s
Wants=network-online.target
After=network-online.target
@ -188,5 +259,5 @@ ExecStart=%s
[Install]
WantedBy=multi-user.target
`, SwitchScript)
`, description, script)
}

View file

@ -3,6 +3,9 @@ package topology
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"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)
}
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 {
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)
@ -32,3 +49,32 @@ 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,12 +34,15 @@ type SegmentPlan struct {
}
type NodePlan struct {
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
Secondary map[string][]netip.Prefix
Loopback netip.Prefix
FRR string
}
type Cable struct {
@ -65,14 +68,27 @@ func Compute(t *Topology) (*Plan, error) {
p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
for i, n := range t.Nodes {
p.Nodes = append(p.Nodes, NodePlan{
node := NodePlan{
Name: n.Name,
Role: n.Role,
Image: n.Image,
CPUs: n.CPUs,
Memory: n.Memory,
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

View 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())
}
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"go.yaml.in/yaml/v3"
)
@ -45,6 +46,9 @@ type Node struct {
Memory int
Segments []string
Addresses map[string]string
Secondary map[string][]string
Loopback string
FRR string
}
type fileImage struct {
@ -59,12 +63,15 @@ 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"`
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"`
}
type file struct {
@ -83,6 +90,11 @@ 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
}
@ -126,6 +138,9 @@ func Parse(data []byte) (*Topology, error) {
Memory: n.Memory,
Segments: n.Segments,
Addresses: n.Addresses,
Secondary: n.Secondary,
Loopback: n.Loopback,
FRR: n.FRR,
})
}
return t, nil

View file

@ -17,6 +17,7 @@ const (
MinPrefix = 8
ReservedInterface = "mgmt0"
LoopbackInterface = "lo1"
)
var (
@ -68,6 +69,9 @@ 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 == "":
@ -116,6 +120,7 @@ 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)
@ -165,6 +170,53 @@ 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)
}
}
}
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

@ -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
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
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
~/<topologie> ; ensuite : ssh './lab up <topologie>'
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>'
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
@ -332,6 +334,12 @@ 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 -xf - && rm -rf topology && mv topology.part topology"
}
cmd_push () {
local TOPOLOGY="${1:-}"
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) \
|| die "compilation de lab échouée"
push_file "${BINARY}" lab 755 || die "envoi de lab échoué"
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}'"
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}'"
}
delete_server () {

View file

@ -147,7 +147,7 @@ case "$*" in
*"bash -s"*)
cat > "${FAKE_DIR}/prepare.sh"
exit "${FAKE_PREPARE_RC:-0}" ;;
*"cat > "*)
*"cat > "*|*"-xf -"*)
N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.')
cat > "${FAKE_DIR}/pushed.$(( N + 1 ))"
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 () {
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
printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml"
run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?"
local REPO
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
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 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.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
}