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..fe17d78 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], release: 0.2.0rc002 } +` + if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + code, _, stderr := runLab("render", "-key", key, topo, filepath.Join(dir, "run")) + if code != 1 || !strings.Contains(stderr, "node sw1: ") || !strings.Contains(stderr, filepath.Join(dir, "frr", "absent.conf")) { + t.Errorf("code %d, stderr %q", code, stderr) + } +} diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index 3d389f6..a3ebfa5 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, release: 0.2.0rc002 } + hv2: + { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], + addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc002 } 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/deploiement/architecture-cluster.rst b/docs/deploiement/architecture-cluster.rst index d1eb9bf..3956bab 100644 --- a/docs/deploiement/architecture-cluster.rst +++ b/docs/deploiement/architecture-cluster.rst @@ -4,21 +4,23 @@ Architecture du cluster Topologie --------- -.. figure:: /schemas/topologie-cluster.svg - :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs +.. figure:: /schemas/architecture-cluster.svg + :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle :align: center :width: 100% :class: only-light - Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle + (sessions BGP). Pointillé gris : interfaces créées par l'agent. -.. figure:: /schemas/topologie-cluster-dark.svg - :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs +.. figure:: /schemas/architecture-cluster-dark.svg + :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle :align: center :width: 100% :class: only-dark - Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle + (sessions BGP). Pointillé gris : interfaces créées par l'agent. Deux plans distincts, à ne pas confondre au moment du diagnostic : diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index 1283f52..cba2e27 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -250,7 +250,7 @@ champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une sessio datasource_list: [ NoCloud ] datasource: NoCloud: - seedfrom: 'http://169.254.169.254:80' + seedfrom: 'http://169.254.169.254:80/' timeout: 5 max_wait: 10 ENDFILE @@ -315,12 +315,20 @@ Points de vigilance aucun espace à l'host : les qcow2 ne se rétractent pas. L'activation reste utile pour le jour où l'option sera ajoutée côté agent, mais ne pas compter dessus pour la place disque. -.. note:: +.. warning:: - **À vérifier** — ``seedfrom`` sans barre oblique finale. cloud-init construit l'URL des - documents en concaténant ``seedfrom`` avec ``meta-data`` et ``user-data``. Confirmer sur une - VM réelle que les deux documents sont bien récupérés, et corriger en - ``http://169.254.169.254:80/`` si ce n'est pas le cas. + **La barre oblique finale de** ``seedfrom`` **est indispensable avant cloud-init 23.1.** + cloud-init construit l'URL des documents en concaténant ``seedfrom`` avec ``meta-data``, + ``user-data`` et ``vendor-data`` (``util.read_seeded``). Jusqu'à la 22.4 — celle de Debian 12, + 22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la + source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de + 23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre + oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. + + Vérifié le 2026-10-04 dans le lab (#50), sur une VM Debian 12 lancée par l'agent : sans barre + oblique, ``Datasource DataSourceNone`` ; avec, ``DataSourceNoCloudNet + [seed=…http://169.254.169.254…]`` et le nom d'hôte de la VM. Avec la barre oblique, la + configuration fonctionne quelle que soit la version de cloud-init. .. note:: diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index e0095d2..ab57b32 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -8,15 +8,106 @@ reflector, qui redistribue. Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster. +Adressage +--------- + +Le route reflector est **sur le même segment L2 que les hyperviseurs**, avec trois adresses : + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Adresse + - Interface + - Rôle + * - une adresse de ``192.168.14.0/24`` + - interface principale + - celle de n'importe quelle machine du segment ; les réponses aux hyperviseurs en partent + * - ``169.254.0.3/28`` + - interface principale, **adresse secondaire** + - le lien avec les routeurs de cluster (``169.254.0.1`` et ``.2``), sur le même L2 + * - ``10.255.255.1/32`` + - ``lo1``, interface ``dummy`` + - ``router-id``, ``cluster-id`` et source des sessions EVPN ; seule route annoncée aux routeurs + +Les hyperviseurs ne connaissent que la loopback : ils ouvrent leur session vers ``10.255.255.1`` +par leur passerelle (``192.168.14.1``), les routeurs l'ayant apprise du route reflector en eBGP. +Aucune adresse d'hyperviseur n'est déclarée côté route reflector : il accepte toute session venant +du segment (``bgp listen range``). + +L'adresse du lien est une **adresse secondaire** de l'interface principale, pas une interface à +part : même L2, même MAC sur le fil, une interface de moins qu'avec un ``ipvlan``. Elle doit +survivre aux renouvellements DHCP de l'adresse principale : la déclarer dans la configuration +réseau du système, pas la poser à la main. + .. note:: - **À rédiger.** À documenter : + **Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est : - * la création de la VM : ressources, subnet et mode utilisés, et s'il s'agit d'une VM créée - par l'agent comme les autres ou d'un cas particulier — l'image, elle, est l'image golden de - :doc:`image-qcow2` ; - * son adressage, et comment les hyperviseurs le connaissent ; - * la configuration du démon de routage qu'elle héberge ; + .. code-block:: bash + + nmcli connection modify +ipv4.addresses 169.254.0.3/28 + nmcli connection add type dummy ifname lo1 con-name lo1 \ + ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled + + Le lab, en Debian, pose ces adresses par cloud-init et un script rejoué au démarrage ; ces + commandes restent à valider sur l'image golden. + +Configuration de FRR +-------------------- + +``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du +lab** (``conf/lab/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom +d'hôte diffère. Le lab qualifie donc exactement ce fichier. + +.. literalinclude:: ../../conf/lab/frr/rr1.conf + :language: text + +Ce qu'elle établit : + +* **Routeurs de cluster** (groupe ``CLUSTER``) : eBGP vers l'AS 65100, avec BFD, en IPv4 + unicast. Le route reflector n'annonce que sa loopback (``RR-LOOPBACK-OUT``) et **n'accepte + rien** (``NO-IN``) : il ne reçoit aucune route des routeurs. +* **Hyperviseurs** (groupe ``fabric``) : voisins dynamiques, toute session venant de + ``192.168.14.0/24`` étant acceptée, jusqu'à 200. ``local-as 64600 no-prepend replace-as`` fait + que, du point de vue des hyperviseurs, la session est en iBGP dans l'AS 64600 — celui de leur + configuration — alors que le route reflector est en AS 65000 face aux routeurs. +* **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients + (``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres. + +Vérifié dans le lab +~~~~~~~~~~~~~~~~~~~ + +Le 2026-10-04, FRR 10.7.1, route reflector en Debian 12 sur le segment des hyperviseurs, avec +l'adresse du lien en secondaire et la loopback sur ``lo1`` : + +.. list-table:: + :header-rows: 1 + :widths: 55 45 + + * - Vérification + - Résultat + * - session avec le routeur, IPv4 unicast + - Established ; le routeur reçoit **un seul** préfixe, la loopback, et l'installe via + ``169.254.0.3`` + * - BFD avec le routeur + - up des deux côtés + * - sessions des deux hyperviseurs vers ``10.255.255.1`` + - Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée + * - routes EVPN échangées + - aucune : rien à annoncer tant que two n'a pas créé de VXLAN — voir + :doc:`premier-hyperviseur` + +Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs +de cluster (:doc:`routeurs`). + +.. note:: + + **À rédiger.** Reste à documenter : + + * la création de la VM : ressources, subnet et mode utilisés (``bridge``, pour pouvoir la + lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les + autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; * la procédure de reconstruction, et l'état du cluster pendant que le route reflector est absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 8f56789..2504bc0 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,26 @@ 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`_). + * ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de + two que ``deploy.sh`` installe (``release: 0.2.0rc002``). Sans lui, ``deploy.sh`` prendrait la + dernière release, et le lab ne serait plus reproductible. + + ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. + Ce que l'outil en déduit, de façon déterministe — même fichier, même plan : .. list-table:: @@ -257,11 +277,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 release + sw1 - underlay 169.254.0.1/28 sw1.conf - + rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - + hv1 - - hv1.conf 0.2.0rc002 + hv2 - - hv2.conf 0.2.0rc002 + + segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1 + node interface address mac udp switch port mac udp + rr1 underlay 192.168.14.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001 + hv1 underlay 192.168.14.11/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003 + hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005 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 +301,122 @@ 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. + +**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**, +embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans +``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` : + +.. code-block:: text + + deploy.sh --noup_script -i -u -t + +``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab +teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut — +celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que +``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh`` +télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur +réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final. + +**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script +``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un +FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc +``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle +que lui, et la première étape en échec met cloud-init en erreur. + +**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif +sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que +cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de +secours, et la VM redémarrée ne rejoue pas ``runcmd``. + +.. warning:: + + **Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en + tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est + exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le + lab : ``lab down`` puis ``lab up``. + +Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` : + +.. code-block:: text + + $ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml' + sw1: started + rr1: started + hv1: started + hv2: started + sw1: ready + rr1: ready + hv1: ready + hv2: ready + +.. list-table:: + :header-rows: 1 + :widths: 55 45 + + * - Vérification + - Résultat + * - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle + - 6 min 15 + * - session switch ↔ route reflector, IPv4 unicast, BFD + - Established, BFD up ; le switch reçoit la seule loopback du route reflector + * - sessions EVPN des deux hyperviseurs vers la loopback du route reflector + - Established, voisins dynamiques, stables + * - réseau d'un hyperviseur après ``deploy.sh`` + - adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond + * - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1 + - ``login:`` en 20 s, en KVM imbriqué + * - DHCP et routes (option 121) servis par two à la VM + - conformes, route ``/32`` vers ``169.254.169.254`` comprise + * - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2` + - ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de + ``seedfrom`` (voir cette page) + * - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan`` + - **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN — + `#51 `_ ; avec l'adresse posée, ping et MTU 1500 + passent + +.. note:: + + La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite + pour l'essai du 2026-10-04, elle se contente d'établir la session avec le route reflector et de + n'accepter que sa loopback. Elle sera remplacée par la configuration réelle des routeurs. Rendu des VM ------------ @@ -596,6 +737,10 @@ Sécurité * **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un serveur auquel on s'est authentifié. +* **Code exécuté sans épinglage par** ``deploy.sh`` : la bibliothèque ``shflags`` est récupérée + par ``curl`` sur la branche ``main`` d'un autre dépôt (``H6N/tools``) et exécutée par ``eval``, + sans vérification d'intégrité — dans le lab comme en production. Les artefacts de la release + sont, eux, vérifiés contre ``SHA256SUMS``. * **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian (``SHA512SUMS.sign``) n'est pas encore vérifiée. diff --git a/docs/schemas/architecture-cluster-dark.svg b/docs/schemas/architecture-cluster-dark.svg new file mode 100644 index 0000000..483d0f0 --- /dev/null +++ b/docs/schemas/architecture-cluster-dark.svg @@ -0,0 +1,155 @@ + + + + + + + + + + ROUTEURS DE CLUSTER · AS 65100 + + routeur 1 + 169.254.0.1/28 + + routeur 2 + 169.254.0.2/28 + passerelle 192.168.14.1/24 + + + VM ROUTE REFLECTOR · AS 65000 + lo1 · 10.255.255.1/32 + router-id, cluster-id, source des sessions EVPN + 192.168.14.2/24 · 169.254.0.3/28 + principale, et secondaire pour le lien avec les routeurs + hébergée par le cluster lui-même + + + eBGP · BFD (×2) + annonce lo1 seule + n'accepte rien + + + + + + + + segment underlay · L2 + 192.168.14.0/24 · 169.254.0.0/28 + + + HYPERVISEUR 1 + + + br-000000 + uplink · 192.168.14.11 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + HYPERVISEUR 2 + + + br-000000 + uplink · 192.168.14.12 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + + + + + + iBGP l2vpn evpn · AS 64600 + iBGP + + + VXLAN + UDP 4789 + + + lien L2 / interface + + plan de données : tunnels VXLAN + + plan de contrôle : sessions BGP + + créé par l'agent + + Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. + Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). + MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. + ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. + diff --git a/docs/schemas/architecture-cluster.svg b/docs/schemas/architecture-cluster.svg new file mode 100644 index 0000000..25028d6 --- /dev/null +++ b/docs/schemas/architecture-cluster.svg @@ -0,0 +1,155 @@ + + + + + + + + + + ROUTEURS DE CLUSTER · AS 65100 + + routeur 1 + 169.254.0.1/28 + + routeur 2 + 169.254.0.2/28 + passerelle 192.168.14.1/24 + + + VM ROUTE REFLECTOR · AS 65000 + lo1 · 10.255.255.1/32 + router-id, cluster-id, source des sessions EVPN + 192.168.14.2/24 · 169.254.0.3/28 + principale, et secondaire pour le lien avec les routeurs + hébergée par le cluster lui-même + + + eBGP · BFD (×2) + annonce lo1 seule + n'accepte rien + + + + + + + + segment underlay · L2 + 192.168.14.0/24 · 169.254.0.0/28 + + + HYPERVISEUR 1 + + + br-000000 + uplink · 192.168.14.11 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + HYPERVISEUR 2 + + + br-000000 + uplink · 192.168.14.12 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + + + + + + iBGP l2vpn evpn · AS 64600 + iBGP + + + VXLAN + UDP 4789 + + + lien L2 / interface + + plan de données : tunnels VXLAN + + plan de contrôle : sessions BGP + + créé par l'agent + + Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. + Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). + MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. + ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. + diff --git a/docs/schemas/topologie-cluster-dark.svg b/docs/schemas/topologie-cluster-dark.svg deleted file mode 100644 index 48a3916..0000000 --- a/docs/schemas/topologie-cluster-dark.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - ROUTEURS DE CLUSTER - - routeur A - - routeur B - - - VM route reflector - plan de contrôle - - - HYPERVISEUR 1 - - br-000000 - - FRR - - agent - - - - - HYPERVISEUR 2 - - br-000000 - - FRR - - agent - - - - - - uplink - uplink - - - - diff --git a/docs/schemas/topologie-cluster.svg b/docs/schemas/topologie-cluster.svg deleted file mode 100644 index 858d18a..0000000 --- a/docs/schemas/topologie-cluster.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - ROUTEURS DE CLUSTER - - routeur A - - routeur B - - - VM route reflector - plan de contrôle - - - HYPERVISEUR 1 - - br-000000 - - FRR - - agent - - - - - HYPERVISEUR 2 - - br-000000 - - FRR - - agent - - - - - - uplink - uplink - - - - diff --git a/internal/lab/machine/machine.go b/internal/lab/machine/machine.go index 1630ed7..29e1312 100644 --- a/internal/lab/machine/machine.go +++ b/internal/lab/machine/machine.go @@ -125,6 +125,10 @@ func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Dur errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) continue } + if err := l.checkServices(ctx, n); err != nil { + errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) + continue + } fmt.Fprintf(l.Out, "%s: ready\n", n.Name) } return errors.Join(errs...) @@ -163,6 +167,26 @@ func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error { } } +func services(n topology.NodePlan) []string { + var units []string + if n.Role == topology.RoleHypervisor { + units = append(units, "agent.service") + } + if n.FRR != "" { + units = append(units, "frr.service") + } + return units +} + +func (l Lab) checkServices(ctx context.Context, n topology.NodePlan) error { + for _, unit := range services(n) { + if err := l.Runner.Run(ctx, "ssh", l.sshArgs(n, true, []string{"systemctl", "is-active", "--quiet", unit})...); err != nil { + return fmt.Errorf("%s is not active: %w", unit, err) + } + } + return nil +} + func exitCode(err error) int { var coded interface{ ExitCode() int } if errors.As(err, &coded) { diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go index 22635d6..d3bc49a 100644 --- a/internal/lab/machine/machine_test.go +++ b/internal/lab/machine/machine_test.go @@ -34,9 +34,10 @@ func (e exitErr) ExitCode() int { return int(e) } type fakeRunner struct { mu sync.Mutex calls [][]string - fail string - ssh map[string][]error - always map[string]error + fail string + ssh map[string][]error + always map[string]error + inactive string } func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { @@ -55,6 +56,9 @@ func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644) case "ssh": port := args[indexOf(args, "-p")+1] + if args[len(args)-1] == f.inactive { + return exitErr(3) + } answers := f.ssh[port] if len(answers) == 0 { return f.always[port] @@ -124,7 +128,7 @@ nodes: } const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } ` @@ -409,8 +413,8 @@ func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) { } ssh := f.runner.commands("ssh") - if len(ssh) != 3 { - t.Fatalf("%d ssh calls, want 3", len(ssh)) + if len(ssh) != 4 { + t.Fatalf("%d ssh calls, want 4", len(ssh)) } want := []string{"ssh", "-i", filepath.Join(f.lab.RunDir, "lab_ed25519"), @@ -440,8 +444,8 @@ func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) { if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { t.Fatalf("Up: %v", err) } - if n := len(f.runner.commands("ssh")); n != 5 { - t.Errorf("%d ssh calls, want 5", n) + if n := len(f.runner.commands("ssh")); n != 6 { + t.Errorf("%d ssh calls, want 6", n) } } @@ -541,3 +545,44 @@ func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) { } } } + +func TestUp_ChecksTheServicesOfEachRole(t *testing.T) { + f := newFixture(t, switchLast) + conf := filepath.Join(t.TempDir(), "sw1.conf") + if err := os.WriteFile(conf, []byte("hostname sw1\n"), 0o600); err != nil { + t.Fatal(err) + } + f.lab.Plan.Nodes[2].FRR = conf + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + + var checks []string + for _, c := range f.runner.commands("ssh") { + if c[len(c)-4] == "systemctl" { + checks = append(checks, c[indexOf(c, "-p")+1]+" "+strings.Join(c[len(c)-4:], " ")) + } + } + want := []string{ + "2201 systemctl is-active --quiet agent.service", + "2202 systemctl is-active --quiet frr.service", + } + if !reflect.DeepEqual(checks, want) { + t.Errorf("checks = %q, want %q", checks, want) + } +} + +func TestUp_ReportsAnInactiveServiceAndWaitsForTheOthers(t *testing.T) { + f := newFixture(t, switchLast) + f.runner.inactive = "agent.service" + + err := f.lab.Up(context.Background(), f.fetcher, time.Second) + + if err == nil || err.Error() != "node hv1: agent.service is not active: exit status 3" { + t.Errorf("error = %v", err) + } + if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") || strings.Contains(f.out.String(), "hv1: ready") { + t.Errorf("output = %q", f.out.String()) + } +} 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..668a0f8 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -60,8 +60,8 @@ segments: nodes: sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 } rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] } - hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay] } + hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` topo, err := topology.Parse([]byte(doc)) if err != nil { @@ -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 0000000..c38f82b Binary files /dev/null and b/internal/lab/render/frrouting.gpg differ 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/render_test.go b/internal/lab/render/render_test.go index f270d13..78bb186 100644 --- a/internal/lab/render/render_test.go +++ b/internal/lab/render/render_test.go @@ -22,8 +22,8 @@ segments: nodes: sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } - hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` const twoSegments = `name: two-seg @@ -36,7 +36,7 @@ segments: blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 } nodes: sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } ` func plan(t *testing.T, doc string) *topology.Plan { @@ -317,7 +317,7 @@ func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) { func TestUserData_Hypervisor(t *testing.T) { cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) - if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 || len(cfg.Packages) != 0 { + if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.Packages) != 0 || !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { t.Errorf("hv1 user-data = %+v", cfg) } if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) { @@ -376,9 +376,12 @@ nft -f /etc/lab-switch.nft t.Errorf("unit misses %q", want) } } - if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) { + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { t.Errorf("runcmd = %v", cfg.Runcmd) } + if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-switch.service\n" { + t.Errorf("lab-provision:\n%s", got) + } } func TestUserData_SwitchWithTwoSegments(t *testing.T) { diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go new file mode 100644 index 0000000..001677b --- /dev/null +++ b/internal/lab/render/roles_test.go @@ -0,0 +1,264 @@ +package render + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "os" + "reflect" + "strings" + "testing" +) + +const withRoles = `name: evpn-2hv +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +segments: + underlay: { switch: sw1, cidr: 192.168.14.0/24 } +nodes: + sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf } + rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: rr1.conf } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf, release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } +` + +var frrConfigs = map[string]string{ + "sw1": "hostname sw1\nrouter bgp 65100\n", + "rr1": "hostname rr1\nrouter bgp 65000\n", + "hv1": "hostname hv1\nrouter bgp 64600\n", +} + +func renderRoles(t *testing.T) map[string]Node { + t.Helper() + nodes, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) + if err != nil { + t.Fatalf("Render: %v", err) + } + out := map[string]Node{} + for _, n := range nodes { + out[n.Name] = n + } + return out +} + +func runcmd(t *testing.T, n Node) []string { + t.Helper() + var out []string + for _, c := range user(t, n).Runcmd { + out = append(out, strings.Join(c, " ")) + } + return out +} + +const frrScriptHead = `#!/bin/sh +set -eu +export DEBIAN_FRONTEND=noninteractive +. /etc/os-release +echo "deb [signed-by=/usr/share/keyrings/frrouting.gpg] https://deb.frrouting.org/frr ${VERSION_CODENAME} frr-stable" > /etc/apt/sources.list.d/frr.list +n=0 +until apt-get update -qq --error-on=any; do + n=$((n + 1)) + [ "$n" -lt 30 ] || exit 1 + sleep 10 +done +apt-get install -y -qq --no-install-recommends frr frr-pythontools +sed -i 's/^bgpd=no/bgpd=yes/' /etc/frr/daemons +` + +func TestRoles_SecondaryAddressFollowsThePrimaryOnTheNode(t *testing.T) { + doc := network(t, nodeNamed(t, renderRoles(t), "rr1")) + got := iface(t, doc, "underlay").Addresses + if !reflect.DeepEqual(got, []string{"192.168.14.2/24", "169.254.0.3/28"}) { + t.Errorf("rr1 underlay addresses = %v", got) + } +} + +func TestRoles_SwitchCarriesItsSecondaryOnTheBridge(t *testing.T) { + script := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "sw1")), "/usr/local/sbin/lab-switch").Content + want := "ip addr replace 192.168.14.1/24 dev br-underlay\nip addr replace 169.254.0.1/28 dev br-underlay\nip link set dev br-underlay up\n" + if !strings.Contains(script, want) { + t.Errorf("lab-switch:\n%s\ndoes not contain:\n%s", script, want) + } +} + +func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) { + rr1 := nodeNamed(t, renderRoles(t), "rr1") + cfg := user(t, rr1) + + script := fileAt(t, cfg, "/usr/local/sbin/lab-node") + want := "#!/bin/sh\nset -eu\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.255.1/32 dev lo1\nip link set dev lo1 up\n" + if script.Content != want || script.Permissions != "0755" { + t.Errorf("lab-node (%s):\n%s", script.Permissions, script.Content) + } + unit := fileAt(t, cfg, "/etc/systemd/system/lab-node.service").Content + if !strings.Contains(unit, "ExecStart=/usr/local/sbin/lab-node\n") || !strings.Contains(unit, "WantedBy=multi-user.target\n") { + t.Errorf("lab-node.service:\n%s", unit) + } + want = "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-node.service\n/usr/local/sbin/lab-frr\n" + if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != want { + t.Errorf("lab-provision:\n%s\nwant:\n%s", got, want) + } +} + +func TestRoles_FRRConfigIsWrittenVerbatimAndPrivately(t *testing.T) { + for _, name := range []string{"sw1", "rr1", "hv1"} { + f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), name)), "/etc/lab/frr.conf") + if f.Content != frrConfigs[name] || f.Permissions != "0640" { + t.Errorf("%s frr.conf (%s) = %q", name, f.Permissions, f.Content) + } + } +} + +func TestRoles_FRRDaemonsDependOnTheRole(t *testing.T) { + tail := "install -o frr -g frr -m 0640 /etc/lab/frr.conf /etc/frr/frr.conf\nsystemctl restart frr\n" + bfd := "sed -i 's/^bfdd=no/bfdd=yes/' /etc/frr/daemons\n" + nodes := renderRoles(t) + for name, want := range map[string]string{ + "sw1": frrScriptHead + bfd + tail, + "rr1": frrScriptHead + bfd + tail, + "hv1": frrScriptHead + tail, + } { + f := fileAt(t, user(t, nodeNamed(t, nodes, name)), "/usr/local/sbin/lab-frr") + if f.Content != want || f.Permissions != "0755" { + t.Errorf("%s lab-frr (%s):\n%s\nwant:\n%s", name, f.Permissions, f.Content, want) + } + } +} + +func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) { + f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "hv1")), "/usr/share/keyrings/frrouting.gpg") + if f.Encoding != "b64" || f.Permissions != "0644" { + t.Errorf("key file: encoding %q, permissions %q", f.Encoding, f.Permissions) + } + key, err := base64.StdEncoding.DecodeString(f.Content) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(key) + if got := hex.EncodeToString(sum[:]); got != "bf10935b9296e2ce7c5d9855fa29ef30c35810b0fc4b1f53005494a04a33554d" { + t.Errorf("key sha256 = %s", got) + } +} + +func TestRoles_ProvisioningIsOneScriptThatStopsAtTheFirstFailure(t *testing.T) { + nodes := renderRoles(t) + for name, steps := range map[string]string{ + "sw1": "systemctl daemon-reload\nsystemctl enable --now lab-switch.service\n/usr/local/sbin/lab-frr\n", + "hv1": "/usr/local/sbin/lab-deploy\n/usr/local/sbin/lab-frr\n", + "hv2": "/usr/local/sbin/lab-deploy\n", + } { + cfg := user(t, nodeNamed(t, nodes, name)) + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { + t.Errorf("%s runcmd = %q", name, cfg.Runcmd) + } + f := fileAt(t, cfg, "/usr/local/sbin/lab-provision") + if f.Content != "#!/bin/sh\nset -eu\n"+steps || f.Permissions != "0755" { + t.Errorf("%s lab-provision (%s):\n%s", name, f.Permissions, f.Content) + } + } +} + +func TestRoles_HypervisorDeploysTheReleaseThroughTheRepositoryScripts(t *testing.T) { + cfg := user(t, nodeNamed(t, renderRoles(t), "hv1")) + + f := fileAt(t, cfg, "/usr/local/sbin/lab-deploy") + want := `#!/bin/sh +set -eu +n=0 +until curl -fsS -o /dev/null https://git.g3e.fr/; do + n=$((n + 1)) + [ "$n" -lt 30 ] || exit 1 + sleep 10 +done +cd /opt/two/scripts +bash ./deploy.sh --noup_script -i -u underlay -t 0.2.0rc002 +` + if f.Content != want || f.Permissions != "0755" { + t.Errorf("lab-deploy (%s):\n%s\nwant:\n%s", f.Permissions, f.Content, want) + } + + for path, source := range map[string]string{ + "/opt/two/scripts/deploy.sh": "../../../scripts/deploy.sh", + "/opt/two/scripts/bootstrap_kvm.sh": "../../../scripts/bootstrap_kvm.sh", + } { + f := fileAt(t, cfg, path) + got, err := base64.StdEncoding.DecodeString(f.Content) + if err != nil { + t.Fatal(err) + } + repo, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if f.Encoding != "b64" || f.Permissions != "0755" || !bytes.Equal(got, repo) { + t.Errorf("%s: encoding %q, permissions %q, identical to %s: %v", path, f.Encoding, f.Permissions, source, bytes.Equal(got, repo)) + } + } +} + +func TestRoles_UplinkIsTheInterfaceCarryingTheDefaultRoute(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoSegments), "hv") + var withDefault []string + for name, e := range network(t, hv).Ethernets { + for _, r := range e.Routes { + if r.To == "0.0.0.0/0" { + withDefault = append(withDefault, name) + } + } + } + if !reflect.DeepEqual(withDefault, []string{"red"}) { + t.Fatalf("default route on %v, want [red]", withDefault) + } + if got := fileAt(t, user(t, hv), "/usr/local/sbin/lab-deploy").Content; !strings.HasSuffix(got, "bash ./deploy.sh --noup_script -i -u red -t 0.2.0rc002\n") { + t.Errorf("lab-deploy:\n%s", got) + } +} + +func TestRoles_OnlyHypervisorsDeployTwo(t *testing.T) { + nodes := renderRoles(t) + for _, name := range []string{"sw1", "rr1"} { + for _, f := range user(t, nodeNamed(t, nodes, name)).WriteFiles { + if strings.HasPrefix(f.Path, "/opt/two/") || f.Path == "/usr/local/sbin/lab-deploy" { + t.Errorf("%s receives %s", name, f.Path) + } + } + } +} + +func TestRoles_NodeWithoutRoleFieldsGetsNoFRR(t *testing.T) { + for _, f := range user(t, nodeNamed(t, renderRoles(t), "hv2")).WriteFiles { + if strings.Contains(f.Path, "frr") { + t.Errorf("hv2 receives %s", f.Path) + } + } +} + +func TestRoles_RefusesAnUnreadFRRConfig(t *testing.T) { + configs := map[string]string{"sw1": "x", "rr1": "y"} + _, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: configs}) + if err == nil || err.Error() != "node hv1: frr configuration hv1.conf was not read" { + t.Errorf("error = %v", err) + } +} + +func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) { + doc := strings.Replace(withRoles, "secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf", "secondary: { underlay: [169.254.0.1/28] }, loopback: 10.255.254.1/32, frr: sw1.conf", 1) + nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) + if err != nil { + t.Fatalf("Render: %v", err) + } + var sw1 Node + for _, n := range nodes { + if n.Name == "sw1" { + sw1 = n + } + } + script := fileAt(t, user(t, sw1), "/usr/local/sbin/lab-switch").Content + want := "ip link set dev br-underlay up\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.254.1/32 dev lo1\nip link set dev lo1 up\nnft -f /etc/lab-switch.nft\n" + if !strings.HasSuffix(script, want) { + t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want) + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go index c959d3c..804ed45 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -1,20 +1,41 @@ package render import ( + _ "embed" + "encoding/base64" "fmt" "strings" "go.yaml.in/yaml/v3" "git.g3e.fr/syonad/two/internal/lab/topology" + "git.g3e.fr/syonad/two/scripts" ) const ( SwitchScript = "/usr/local/sbin/lab-switch" SwitchNFT = "/etc/lab-switch.nft" SwitchUnit = "/etc/systemd/system/lab-switch.service" + + NodeScript = "/usr/local/sbin/lab-node" + NodeUnit = "/etc/systemd/system/lab-node.service" + + FRRKey = "/usr/share/keyrings/frrouting.gpg" + FRRConfig = "/etc/lab/frr.conf" + FRRScript = "/usr/local/sbin/lab-frr" + FRRSuite = "frr-stable" + FRRRepo = "https://deb.frrouting.org/frr" + FRRPackages = "frr frr-pythontools" + + ProvisionScript = "/usr/local/sbin/lab-provision" + DeployScript = "/usr/local/sbin/lab-deploy" + TwoScriptsDir = "/opt/two/scripts" + TwoGitServer = "https://git.g3e.fr/" ) +//go:embed frrouting.gpg +var frrKey []byte + type metaDoc struct { InstanceID string `yaml:"instance-id"` LocalHostname string `yaml:"local-hostname"` @@ -23,6 +44,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 +90,51 @@ 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 { + var steps []string + 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"}, + steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-switch.service") + case n.Loopback.IsValid(): + cfg.WriteFiles = []writeFile{ + {Path: NodeScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + loopbackLines(n)}, + {Path: NodeUnit, Permissions: "0644", Content: unit("Lab node: loopback", NodeScript)}, } + steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service") + } + if n.Role == topology.RoleHypervisor { + cfg.WriteFiles = append(cfg.WriteFiles, + writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)}, + writeFile{Path: TwoScriptsDir + "/bootstrap_kvm.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.BootstrapKVM)}, + writeFile{Path: DeployScript, Permissions: "0755", Content: deployScript(p, n)}, + ) + steps = append(steps, DeployScript) + } + if n.FRR != "" { + cfg.WriteFiles = append(cfg.WriteFiles, + writeFile{Path: FRRKey, Permissions: "0644", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(frrKey)}, + writeFile{Path: FRRConfig, Permissions: "0640", Content: o.FRR[n.Name]}, + writeFile{Path: FRRScript, Permissions: "0755", Content: frrScript(n)}, + ) + steps = append(steps, FRRScript) + } + if len(steps) > 0 { + cfg.WriteFiles = append(cfg.WriteFiles, + writeFile{Path: ProvisionScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + strings.Join(steps, "\n") + "\n"}, + ) + cfg.Runcmd = [][]string{{ProvisionScript}} } body, err := yaml.Marshal(cfg) if err != nil { @@ -94,6 +143,47 @@ func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, err return append([]byte("#cloud-config\n"), body...), nil } +func retry(command string) string { + return "n=0\nuntil " + command + "; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n" +} + +func deployScript(p *topology.Plan, n topology.NodePlan) string { + uplink := nodeCables(p, n.Name)[0].NodeInterface + return "#!/bin/sh\nset -eu\n" + + retry("curl -fsS -o /dev/null "+TwoGitServer) + + "cd " + TwoScriptsDir + "\n" + + fmt.Sprintf("bash ./deploy.sh --noup_script -i -u %s -t %s\n", uplink, n.Release) +} + +func frrDaemons(n topology.NodePlan) []string { + if n.Role == topology.RoleHypervisor { + return []string{"bgpd"} + } + return []string{"bgpd", "bfdd"} +} + +func frrScript(n topology.NodePlan) string { + var b strings.Builder + b.WriteString("#!/bin/sh\nset -eu\nexport DEBIAN_FRONTEND=noninteractive\n. /etc/os-release\n") + fmt.Fprintf(&b, "echo \"deb [signed-by=%s] %s ${VERSION_CODENAME} %s\" > /etc/apt/sources.list.d/frr.list\n", FRRKey, FRRRepo, FRRSuite) + b.WriteString(retry("apt-get update -qq --error-on=any")) + fmt.Fprintf(&b, "apt-get install -y -qq --no-install-recommends %s\n", FRRPackages) + for _, d := range frrDaemons(n) { + fmt.Fprintf(&b, "sed -i 's/^%s=no/%s=yes/' /etc/frr/daemons\n", d, d) + } + fmt.Fprintf(&b, "install -o frr -g frr -m 0640 %s /etc/frr/frr.conf\n", FRRConfig) + b.WriteString("systemctl restart frr\n") + return b.String() +} + +func loopbackLines(n topology.NodePlan) string { + if !n.Loopback.IsValid() { + return "" + } + return fmt.Sprintf("ip link add %s type dummy 2>/dev/null || true\nip addr replace %s dev %s\nip link set dev %s up\n", + topology.LoopbackInterface, n.Loopback, topology.LoopbackInterface, topology.LoopbackInterface) +} + func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) { doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}} admin := ethernet{ @@ -121,6 +211,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 +232,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 +247,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 +273,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 +286,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..567933a 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 != "" || n.Release != "" { + extras = append(extras, n) + } + } + if len(extras) > 0 { + fmt.Fprintf(tw, "\nroles\n") + fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\n") + for _, n := range extras { + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release)) + } + } + for _, s := range p.Segments { fmt.Fprintf(tw, "\nsegment %s: %s, mtu %d, switch %s, bridge %s, gateway %s\n", s.Name, s.Network, s.MTU, s.Switch, s.Bridge, s.Gateway) @@ -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..83487ba 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -34,12 +34,16 @@ 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 + Release string } type Cable struct { @@ -65,14 +69,28 @@ 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, + Release: n.Release, + } + for segment, raws := range n.Secondary { + for _, raw := range raws { + if node.Secondary == nil { + node.Secondary = map[string][]netip.Prefix{} + } + node.Secondary[segment] = append(node.Secondary[segment], netip.MustParsePrefix(raw)) + } + } + if n.Loopback != "" { + node.Loopback = netip.MustParsePrefix(n.Loopback) + } + p.Nodes = append(p.Nodes, node) } var errs []error diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 1621621..4f23fef 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -55,8 +55,8 @@ segments: nodes: sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } - hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` func TestCompute_TwoHypervisorsPlan(t *testing.T) { @@ -165,7 +165,7 @@ segments: blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 } nodes: sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] } `) got := make([]string, 0, len(p.Cables)) @@ -193,7 +193,7 @@ nodes: sw: { role: switch, image: deb, cpus: 1, memory: 512 } n1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] } n2: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] } - n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a] } + n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a], release: 0.2.0rc002 } `) macs := map[string]bool{} ports := map[int]bool{} @@ -314,6 +314,11 @@ nodes hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202 hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 +roles + name loopback secondary frr release + hv1 - - - 0.2.0rc002 + hv2 - - - 0.2.0rc002 + segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1 node interface address mac udp switch port mac udp rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go new file mode 100644 index 0000000..97ee53c --- /dev/null +++ b/internal/lab/topology/roles_test.go @@ -0,0 +1,216 @@ +package topology + +import ( + "bytes" + "net/netip" + "os" + "path/filepath" + "reflect" + "testing" +) + +const withRoles = header + ` +segments: + underlay: { switch: sw1, cidr: 192.168.14.0/24 } +nodes: + sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf } + rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: frr/rr1.conf } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf, release: 0.2.0rc002 } +` + +func TestCompute_CarriesTheRoleFields(t *testing.T) { + p := compute(t, withRoles) + + rr1 := nodeOf(t, p, "rr1") + if !reflect.DeepEqual(rr1.Secondary, map[string][]netip.Prefix{"underlay": {netip.MustParsePrefix("169.254.0.3/28")}}) { + t.Errorf("rr1 secondary = %v", rr1.Secondary) + } + if rr1.Loopback != netip.MustParsePrefix("10.255.255.1/32") { + t.Errorf("rr1 loopback = %v", rr1.Loopback) + } + if rr1.FRR != "frr/rr1.conf" { + t.Errorf("rr1 frr = %q", rr1.FRR) + } + hv1 := nodeOf(t, p, "hv1") + if hv1.Secondary != nil || hv1.Loopback.IsValid() { + t.Errorf("hv1 = %+v, want no secondary and no loopback", hv1) + } +} + +func TestLoad_ResolvesFRRPathsAgainstTheTopologyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "lab.yml") + if err := os.WriteFile(path, []byte(withRoles), 0o600); err != nil { + t.Fatal(err) + } + + topo, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + want := map[string]string{ + "sw1": filepath.Join(dir, "frr", "sw1.conf"), + "rr1": filepath.Join(dir, "frr", "rr1.conf"), + "hv1": "/etc/lab/hv1.conf", + } + for _, n := range topo.Nodes { + if n.FRR != want[n.Name] { + t.Errorf("%s frr = %q, want %q", n.Name, n.FRR, want[n.Name]) + } + } +} + +func TestValidate_RoleFieldRejections(t *testing.T) { + cases := map[string]struct { + node string + want string + }{ + "secondary on a segment not attached": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { blue: [169.254.0.3/28] } }`, + "node rr1: secondary address given for segment blue it is not attached to", + }, + "secondary without prefix length": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [169.254.0.3] } }`, + `node rr1: secondary address "169.254.0.3" on red`, + }, + "secondary in IPv6": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: ["fd00::3/64"] } }`, + "node rr1: secondary address fd00::3/64 on red is not IPv4", + }, + "secondary inside the segment": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [10.1.0.9/24] } }`, + "node rr1: secondary address 10.1.0.9/24 is inside segment red (10.1.0.0/24), use addresses instead", + }, + "loopback without prefix length": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: 10.255.255.1 }`, + `node rr1: loopback "10.255.255.1"`, + }, + "loopback in IPv6": { + `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: "fd00::1/128" }`, + "node rr1: loopback fd00::1/128 is not IPv4", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } + blue: { switch: sw, cidr: 10.2.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } + ` + c.node + ` +` + requireContains(t, validationError(t, doc), c.want) + }) + } +} + +func TestValidate_SwitchSecondaryOnlyOnItsOwnSegments(t *testing.T) { + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } + blue: { switch: other, cidr: 10.2.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512, secondary: { red: [169.254.0.1/28], blue: [169.254.1.1/28] } } + other: { role: switch, image: deb, cpus: 1, memory: 512 } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } +` + msg := validationError(t, doc) + requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to") + if bytes.Contains([]byte(msg), []byte("segment red")) { + t.Errorf("the switch's own segment was refused:\n%s", msg) + } +} + +func TestValidate_LoopbackInterfaceNameIsReserved(t *testing.T) { + doc := header + ` +segments: + lo1: { switch: sw, cidr: 10.1.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1], release: 0.2.0rc002 } +` + requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface") +} + +func TestWrite_ShowsTheRoles(t *testing.T) { + var buf bytes.Buffer + if err := compute(t, withRoles).Write(&buf); err != nil { + t.Fatalf("Write: %v", err) + } + want := ` +roles + name loopback secondary frr release + sw1 - underlay 169.254.0.1/28 sw1.conf - + rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - + hv1 - - hv1.conf 0.2.0rc002 +` + if !bytes.Contains(buf.Bytes(), []byte(want)) { + t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want) + } +} + +func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) { + var buf bytes.Buffer + doc := header + ` +segments: + underlay: { switch: sw1, cidr: 10.250.0.0/24 } +nodes: + sw1: { role: switch, image: deb, cpus: 1, memory: 512 } + rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [underlay] } +` + if err := compute(t, doc).Write(&buf); err != nil { + t.Fatalf("Write: %v", err) + } + if bytes.Contains(buf.Bytes(), []byte("roles")) { + t.Errorf("plan shows a roles section:\n%s", buf.String()) + } +} + +func TestValidate_ReleaseRejections(t *testing.T) { + cases := map[string]struct { + node string + want string + }{ + "hypervisor without release": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red] }`, + "node hv: a hypervisor needs the release of two to deploy (release: )", + }, + "release with shell characters": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: "0.2.0; reboot" }`, + `node hv: release "0.2.0; reboot" must match`, + }, + "release on a route reflector": { + `hv: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }`, + "node hv: release is only for hypervisors", + }, + "release on a switch": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 } + sw2: { role: switch, image: deb, cpus: 1, memory: 512, release: 0.2.0rc002 }`, + "node sw2: release is only for hypervisors", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + ` + c.node + ` +` + requireContains(t, validationError(t, doc), c.want) + }) + } +} + +func TestCompute_CarriesTheRelease(t *testing.T) { + if got := nodeOf(t, compute(t, withRoles), "hv1").Release; got != "0.2.0rc002" { + t.Errorf("hv1 release = %q", got) + } + if got := nodeOf(t, compute(t, withRoles), "rr1").Release; got != "" { + t.Errorf("rr1 release = %q", got) + } +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go index bd184d7..72c532a 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,10 @@ type Node struct { Memory int Segments []string Addresses map[string]string + Secondary map[string][]string + Loopback string + FRR string + Release string } type fileImage struct { @@ -59,12 +64,16 @@ 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"` + Release string `yaml:"release"` } type file struct { @@ -83,6 +92,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 +140,10 @@ func Parse(data []byte) (*Topology, error) { Memory: n.Memory, Segments: n.Segments, Addresses: n.Addresses, + Secondary: n.Secondary, + Loopback: n.Loopback, + FRR: n.FRR, + Release: n.Release, }) } return t, nil diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 6a7d53e..2128c68 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -17,11 +17,13 @@ const ( MinPrefix = 8 ReservedInterface = "mgmt0" + LoopbackInterface = "lo1" ) var ( namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`) + releasePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) ) func (t *Topology) Validate() error { @@ -68,6 +70,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 +121,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 +171,61 @@ func (t *Topology) Validate() error { return nil } +func validateExtras(n Node, segments map[string]Segment, add func(string, ...any)) { + carried := map[string]bool{} + if n.Role == RoleSwitch { + for name, s := range segments { + if s.Switch == n.Name { + carried[name] = true + } + } + } else { + for _, name := range n.Segments { + carried[name] = true + } + } + names := make([]string, 0, len(n.Secondary)) + for name := range n.Secondary { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if !carried[name] { + add("node %s: secondary address given for segment %s it is not attached to", n.Name, name) + continue + } + network, _ := netip.ParsePrefix(segments[name].CIDR) + for _, raw := range n.Secondary[name] { + prefix, err := netip.ParsePrefix(raw) + switch { + case err != nil: + add("node %s: secondary address %q on %s: %v", n.Name, raw, name, err) + case !prefix.Addr().Is4(): + add("node %s: secondary address %s on %s is not IPv4", n.Name, raw, name) + case network.IsValid() && network.Contains(prefix.Addr()): + add("node %s: secondary address %s is inside segment %s (%s), use addresses instead", n.Name, raw, name, network) + } + } + } + switch { + case n.Role == RoleHypervisor && n.Release == "": + add("node %s: a hypervisor needs the release of two to deploy (release: )", n.Name) + case n.Role == RoleHypervisor && !releasePattern.MatchString(n.Release): + add("node %s: release %q must match %s", n.Name, n.Release, releasePattern) + case n.Role != RoleHypervisor && n.Release != "": + add("node %s: release is only for hypervisors", n.Name) + } + if n.Loopback != "" { + prefix, err := netip.ParsePrefix(n.Loopback) + switch { + case err != nil: + add("node %s: loopback %q: %v", n.Name, n.Loopback, err) + case !prefix.Addr().Is4(): + add("node %s: loopback %s is not IPv4", n.Name, n.Loopback) + } + } +} + func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/scripts/embed.go b/scripts/embed.go new file mode 100644 index 0000000..481e8e5 --- /dev/null +++ b/scripts/embed.go @@ -0,0 +1,9 @@ +package scripts + +import _ "embed" + +//go:embed deploy.sh +var Deploy []byte + +//go:embed bootstrap_kvm.sh +var BootstrapKVM []byte diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh index 412fd6a..d06c1d7 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 --no-same-owner -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..9953579 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 --no-same-owner -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 }