From 8d1691ae94c52a9e6a2c80a2f60bc943e17fa894 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:17:02 +0200 Subject: [PATCH] =?UTF-8?q?f-50:=20lab:=20r=C3=B4les=20des=20n=C5=93uds,?= =?UTF-8?q?=20adresses=20du=20lien,=20loopback=20et=20installation=20de=20?= =?UTF-8?q?FRR=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main.go | 7 +- cmd/lab/main_test.go | 57 ++++++- conf/lab/evpn-2hv.yml | 19 ++- conf/lab/frr/hv1.conf | 19 +++ conf/lab/frr/hv2.conf | 19 +++ conf/lab/frr/rr1.conf | 43 ++++++ conf/lab/frr/sw1.conf | 28 ++++ docs/developpement/lab.rst | 91 +++++++++-- internal/lab/provision/provision.go | 21 ++- internal/lab/provision/provision_test.go | 70 +++++++++ internal/lab/render/frrouting.gpg | Bin 0 -> 16112 bytes internal/lab/render/render.go | 8 +- internal/lab/render/roles_test.go | 188 +++++++++++++++++++++++ internal/lab/render/seed.go | 89 +++++++++-- internal/lab/topology/format.go | 46 ++++++ internal/lab/topology/plan.go | 32 +++- internal/lab/topology/roles_test.go | 163 ++++++++++++++++++++ internal/lab/topology/topology.go | 27 +++- internal/lab/topology/validate.go | 52 +++++++ scripts/lab-host.sh | 18 ++- scripts/lab-host_test.sh | 23 ++- 21 files changed, 962 insertions(+), 58 deletions(-) create mode 100644 conf/lab/frr/hv1.conf create mode 100644 conf/lab/frr/hv2.conf create mode 100644 conf/lab/frr/rr1.conf create mode 100644 conf/lab/frr/sw1.conf create mode 100644 internal/lab/render/frrouting.gpg create mode 100644 internal/lab/render/roles_test.go create mode 100644 internal/lab/topology/roles_test.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 3dc6425..df86e39 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -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 diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index e8972b8..257211c 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -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) + } +} diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index 3d389f6..b60ecb3 100644 --- a/conf/lab/evpn-2hv.yml +++ b/conf/lab/evpn-2hv.yml @@ -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 } diff --git a/conf/lab/frr/hv1.conf b/conf/lab/frr/hv1.conf new file mode 100644 index 0000000..ecf9e12 --- /dev/null +++ b/conf/lab/frr/hv1.conf @@ -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 +! diff --git a/conf/lab/frr/hv2.conf b/conf/lab/frr/hv2.conf new file mode 100644 index 0000000..5d0e0d9 --- /dev/null +++ b/conf/lab/frr/hv2.conf @@ -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 +! diff --git a/conf/lab/frr/rr1.conf b/conf/lab/frr/rr1.conf new file mode 100644 index 0000000..44081b1 --- /dev/null +++ b/conf/lab/frr/rr1.conf @@ -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 +! diff --git a/conf/lab/frr/sw1.conf b/conf/lab/frr/sw1.conf new file mode 100644 index 0000000..55df925 --- /dev/null +++ b/conf/lab/frr/sw1.conf @@ -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 +! diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 8f56789..b151dbd 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -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 compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et - ~/ ; ensuite : ssh './lab up ' + push 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/' 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 ------------ diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go index 98c1b2f..b1ee4d0 100644 --- a/internal/lab/provision/provision.go +++ b/internal/lab/provision/provision.go @@ -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) { diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go index b05d693..f36e514 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -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) +} diff --git a/internal/lab/render/frrouting.gpg b/internal/lab/render/frrouting.gpg new file mode 100644 index 0000000000000000000000000000000000000000..c38f82b554920a929fc607fab1dfab32bd278c9c GIT binary patch literal 16112 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

FmpBWJH_)EkUgb^EF8h$>Y$XZPeB}$4 z-vI%Q1>iZzfVRpz+wdGQ#)IQjib#frHS4txgKPB`{?#l1DzQg}pj4(_W)>f4FM4!O zjXbK1it_3>Uq;|HrRsAnIVpMIQjnc(CA&Wle26x{V!IdTEitIp%WS-9?%b>ve@m9} zp$l%yqynjh?n{f&*h0R8aG^{r6Ia#r2cU^H2;5OqrijvYMK&@7%ZhrX+8_@$Ag0J1= z*T<5QQ1ndj!E>^LWVdO*n{tV85S`aWpZV2WOEH9v%VD%|+tGdxxwo;9iL*($#%>@6 zJKhCa_&l%x$iD8cJ(LvL_)B*)D_u>`UP}=xmjlyH!3<(>XlF^%%*40_$d7!4QPu)Y zuBXB?8albQ#Wwwx$V_=fM__=##cZU;p%Ke2*6yV;@S7=PA?Fk8+MxhkEdYtYHvQ<3 zm3otd@|>VKVx{<3Iyaj$l569WlB|OMu<2FnslUz2h$rGMn5E&n%QVf}7)z zxR`J8ffOdmI;?(DaX%x6SL0@7t+nfVF0GoM#}+kWRZAxC+=Ij0$EGN3_yq zMH+NH44whePkU+C5oN2-Vb)IKY#Em~HPXKB%R;<;I7CC5Z!$x(g%x(7h&y zi1SXb@TeqeH}f11@%CI#tAQeT1n}hO;fxx=qK4Z^qb%3cV0X?u#l zY70?o4%Q=~WrdKe>x%u7)L*D8eAhXn1uRHRxoU|A$0yDOjzgxpag8c6>{>%AFsR&8 zUJ!{UfUHzvNf`1ztn*w1AkO-C%yg!hz0s3KQKHZmPN9)bYSXa{UoeVZNnkT53;Ddp zOC&u0mxaOqBqRZ$|5_OQ?MVJ}VbG7<%myTIZDI_Bzq`+=tWiwpi}HuP2?+gTVet1! zqdFo`hXZf^hlbY!rg+$vSMDQUt6(T!Dg-GwE|5(5=~*nvw@R}#1~kPKrUiKmREM$V z4v691b>;i<5Pdh$6m|)|MygvdcUZR$bV+G|^Xq0jk=_-lBPFtFj1bGH%(&!)+?LVJ z`-_)9j09GUS?zADSDlQrO5zVVmHu21Lrwl7#U&s^jGR5U5Ll(>5;mI~hML8a69`5w z1csKN2uyXrKCZOI>eNsa7~S~(y3F^U`C559(oz=w#-W5m&QxZ1NR^uK9yy3PA{>gI zEo8ei|NGQ9STnJbP46O z<_jw--acb=ftUFp%avj+2ZMyh^8DupcsHno$S%0>j3G}rXubKw)Fs}G;))J(IWWLO z;t1Fy!t3N~BJa414@&N2;wyL#2F>I$=6{l{0gxx(W77IIv+~x~^q?`72pm>7TwO-fPsZDou%RIU^{earlwPDV0dU{A9-2z5woQMRE<_Pmz{~~f~e6E zl|k!<%YpG9qM4!2z)U4IG&tH9QB`qb4@GGMx6G#UiHY2?3Cv@mXJD$JQ%$_fSA1G& z7a)>Z`H7}s0Un#63DkOcXXwrdY!2F1)oYb$aR?&A9Y+IoZ>;p6kf?iA6N@ z1e;BvP{+9Qp;F>6o^Yc>0y6mE8pj5!{OaBZUXq9c&(zb^DdQ~$(#mU61j9%bM)(bz z;54$B`_(2|>mhL`kDXoNUg;aY*X@$S;ieY_!aM*3Q)OK&6IlW!s?#!anK ztTaq8vsE2EvJGgng4BpF@OK#;)(=-#fs42vRF{f;X?nzk_HOVA@n~~Y;(v(xAEN$; tsQ)4Ae~9`YqW*`d{~_vsi25I*{)ec)YyCsi{}A;*MEws@|Nj&9{{amGE{p&G literal 0 HcmV?d00001 diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go index 49b3089..6a11f0d 100644 --- a/internal/lab/render/render.go +++ b/internal/lab/render/render.go @@ -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 } diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go new file mode 100644 index 0000000..8ebb715 --- /dev/null +++ b/internal/lab/render/roles_test.go @@ -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) + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go index c959d3c..6fd41e9 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -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) } diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go index e501bff..1940d8c 100644 --- a/internal/lab/topology/format.go +++ b/internal/lab/topology/format.go @@ -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 +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 0fd0ee6..c7a8147 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -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 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go new file mode 100644 index 0000000..bbd4baf --- /dev/null +++ b/internal/lab/topology/roles_test.go @@ -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()) + } +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go index bd184d7..8b3b35a 100644 --- a/internal/lab/topology/topology.go +++ b/internal/lab/topology/topology.go @@ -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 diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 6a7d53e..d51914b 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -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..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 { diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh index 412fd6a..a0d1f7b 100755 --- a/scripts/lab-host.sh +++ b/scripts/lab-host.sh @@ -28,14 +28,16 @@ usage: ${0##*/} [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 compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et - ~/ ; ensuite : ssh './lab up ' + push 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/' 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 () { diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh index 9db9776..1e82388 100755 --- a/scripts/lab-host_test.sh +++ b/scripts/lab-host_test.sh @@ -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 }