From 99ecb59aadb1f1248a14abdf27b7296f935be8ef Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 01:17:12 +0200 Subject: [PATCH] f-50: lab: arguments QEMU et seeds cloud-init des VM #50 Signed-off-by: GnomeZworc --- cmd/lab/main.go | 117 +++++- cmd/lab/main_test.go | 51 ++- docs/developpement/lab.rst | 113 +++++- internal/lab/render/qemu.go | 54 +++ internal/lab/render/render.go | 132 +++++++ internal/lab/render/render_test.go | 477 +++++++++++++++++++++++++ internal/lab/render/seed.go | 192 ++++++++++ internal/lab/topology/topology_test.go | 1 + internal/lab/topology/validate.go | 5 + 9 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 internal/lab/render/qemu.go create mode 100644 internal/lab/render/render.go create mode 100644 internal/lab/render/render_test.go create mode 100644 internal/lab/render/seed.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go index dc0199d..936e09a 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -1,45 +1,73 @@ package main import ( + "flag" "fmt" "io" "os" + "path/filepath" + "strings" + "git.g3e.fr/syonad/two/internal/lab/render" "git.g3e.fr/syonad/two/internal/lab/topology" ) -const usage = `usage: lab +const usage = `usage: lab [options] [dir] - plan validate the topology and print the deterministic plan: addresses, cables, ports + plan + validate the topology and print the deterministic plan: addresses, cables, ports + + render -key + write, for each node, //qemu.args (one argument per line) and the + cloud-init seed files meta-data, user-data and network-config ` +type keyFiles []string + +func (k *keyFiles) String() string { return strings.Join(*k, ",") } +func (k *keyFiles) Set(v string) error { *k = append(*k, v); return nil } + func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } func run(args []string, stdout, stderr io.Writer) int { - if len(args) != 2 { + if len(args) == 0 { fmt.Fprint(stderr, usage) return 2 } switch args[0] { case "plan": + if len(args) != 2 { + fmt.Fprint(stderr, usage) + return 2 + } return plan(args[1], stdout, stderr) + case "render": + return renderCmd(args[1:], stdout, stderr) default: fmt.Fprint(stderr, usage) return 2 } } -func plan(path string, stdout, stderr io.Writer) int { +func load(path string, stderr io.Writer) (*topology.Plan, bool) { t, err := topology.Load(path) if err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 + return nil, false } p, err := topology.Compute(t) if err != nil { fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err) + return nil, false + } + return p, true +} + +func plan(path string, stdout, stderr io.Writer) int { + p, ok := load(path, stderr) + if !ok { return 1 } if err := p.Write(stdout); err != nil { @@ -48,3 +76,82 @@ func plan(path string, stdout, stderr io.Writer) int { } return 0 } + +func renderCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("render", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, usage) } + var keys keyFiles + fs.Var(&keys, "key", "public key file allowed to log in, repeatable") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 2 || len(keys) == 0 { + fmt.Fprint(stderr, usage) + return 2 + } + + p, ok := load(fs.Arg(0), stderr) + if !ok { + return 1 + } + dir, err := filepath.Abs(fs.Arg(1)) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + authorized, err := readKeys(keys) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized}) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + for _, n := range nodes { + if err := writeNode(n); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "%s\n", n.Dir) + } + return 0 +} + +func readKeys(files []string) ([]string, error) { + var keys []string + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + keys = append(keys, line) + } + } + return keys, nil +} + +func writeNode(n render.Node) error { + if err := os.MkdirAll(n.Dir, 0o700); err != nil { + return err + } + files := map[string][]byte{ + "qemu.args": []byte(strings.Join(n.QEMU, "\n") + "\n"), + "meta-data": n.MetaData, + "user-data": n.UserData, + "network-config": n.NetworkConfig, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { + return err + } + } + return nil +} diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index 3c87f8a..a1cc588 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -15,7 +15,7 @@ func runLab(args ...string) (int, string, string) { } func TestRun_UsageOnMissingArguments(t *testing.T) { - for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}} { + for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}, {"render", "a.yml", "dir"}, {"render", "-key", "k.pub", "a.yml"}, {"render", "-bogus"}} { code, _, stderr := runLab(args...) if code != 2 || !strings.Contains(stderr, "usage: lab") { t.Errorf("args %v: code %d, stderr %q", args, code, stderr) @@ -70,3 +70,52 @@ func TestRun_MissingFile(t *testing.T) { t.Errorf("code %d, stderr %q", code, stderr) } } + +func TestRun_RenderWritesEveryNodeFiles(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "lab.pub") + if err := os.WriteFile(key, []byte("# lab key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "run") + code, stdout, 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 := range []string{"sw1", "rr1", "hv1", "hv2"} { + if !strings.Contains(stdout, filepath.Join(out, node)) { + t.Errorf("stdout does not list %s:\n%s", node, stdout) + } + for _, f := range []string{"qemu.args", "meta-data", "user-data", "network-config"} { + info, err := os.Stat(filepath.Join(out, node, f)) + if err != nil { + t.Errorf("%s/%s: %v", node, f, err) + continue + } + if info.Mode().Perm() != 0o600 { + t.Errorf("%s/%s mode %o, want 600", node, f, info.Mode().Perm()) + } + } + } + args, err := os.ReadFile(filepath.Join(out, "hv1", "qemu.args")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "\n-netdev\nuser,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22\n") { + t.Errorf("qemu.args is not one argument per line:\n%s", args) + } + userData, err := os.ReadFile(filepath.Join(out, "hv1", "user-data")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(userData), "two-lab-automation") || strings.Contains(string(userData), "# lab key") { + t.Errorf("keys not read as an authorized_keys file:\n%s", userData) + } +} + +func TestRun_RenderRefusesMissingKeyFile(t *testing.T) { + code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), t.TempDir()) + if code != 1 || !strings.Contains(stderr, "absent.pub") { + t.Errorf("code %d, stderr %q", code, stderr) + } +} diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 7c55361..7c0371d 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,10 +13,10 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** et **E1** livrées — le cycle de vie du serveur qui portera le lab - (``scripts/lab-host.sh``), puis la description de la topologie et le calcul de son plan - (``lab plan``). Le lancement des VM viendra avec les étapes suivantes, et cette page avec - elles. + État actuel : étapes **E0** à **E2** livrées — le cycle de vie du serveur qui portera le lab + (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan + (``lab plan``), puis la génération des arguments QEMU et des fichiers cloud-init de chaque VM + (``lab render``). Le lancement des VM viendra avec l'étape suivante, et cette page avec elle. Le serveur de lab ----------------- @@ -254,6 +254,111 @@ champs inconnus et les clés en double sont refusés aussi : Les plages d'adresses de l'exemple sont des valeurs de travail : le plan d'adressage du lab reste à définir (#50). +Rendu des VM +------------ + +``lab render`` produit, pour chaque nœud, ce qu'il faut pour démarrer sa VM — sans rien lancer : + +.. code-block:: text + + $ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub conf/lab/evpn-2hv.yml + +``//`` reçoit : + +``qemu.args`` + Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à + interpréter par un shell. + +``meta-data``, ``user-data``, ``network-config`` + Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``. + +Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``) +sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier +``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``. + +Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau : + +.. code-block:: text + + -netdev + user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22 + -device + virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile= + -netdev + dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003 + -device + virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile= + +Les choix qui s'y lisent : + +* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en + dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ; +* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:::00:00``, SSH redirigé + sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un + nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe. + ``ipv6=off`` partout (voir plus bas) ; +* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent + (port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au + guest ; +* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli + sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM + restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de + ce repli ; la cause n'a pas été isolée, l'option est une précaution. + +Ce que fait cloud-init : + +* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p``), + ``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par + clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ; +* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS + (``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le + switch ; +* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge + ``br-`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la + passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le + service est rejoué à chaque démarrage. + +Vérifié sur de vraies VM +~~~~~~~~~~~~~~~~~~~~~~~~ + +Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de +l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et +les fichiers produits par ``lab render``. + +.. list-table:: + :header-rows: 1 + :widths: 60 40 + + * - Vérification + - Résultat + * - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24`` + - conforme + * - service ``lab-switch`` actif, y compris après redémarrage + - conforme + * - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation) + - passe + * - ``ping -M do -s 8973`` (MTU 9001) + - refusé : ``message too long, mtu=9000`` + * - Internet depuis ``rr1`` en IPv4 + - passe, par ``10.250.0.1`` + * - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient + - bloqué + * - ``rr1`` vers Internet par ``mgmt0`` + - bloqué + +Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un +préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque +``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS +renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4. + +.. note:: + + Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de + QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de + l'hôte, avec un témoin qui y parvient, le prouve. + +Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué. + Facturation ----------- diff --git a/internal/lab/render/qemu.go b/internal/lab/render/qemu.go new file mode 100644 index 0000000..9a870d2 --- /dev/null +++ b/internal/lab/render/qemu.go @@ -0,0 +1,54 @@ +package render + +import ( + "fmt" + "path/filepath" + "strconv" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const loopback = "127.0.0.1" + +func qemuArgs(p *topology.Plan, n topology.NodePlan, index int, dir string) []string { + args := []string{ + "-name", n.Name, + "-machine", "q35", + "-accel", "kvm", + "-cpu", "host", + "-smp", strconv.Itoa(n.CPUs), + "-m", strconv.Itoa(n.Memory), + "-nodefaults", + "-display", "none", + "-serial", "file:" + filepath.Join(dir, ConsoleFile), + "-qmp", "unix:" + filepath.Join(dir, QMPFile) + ",server=on,wait=off", + "-pidfile", filepath.Join(dir, PIDFile), + "-drive", "file=" + filepath.Join(dir, DiskFile) + ",if=virtio,format=qcow2", + "-drive", "file=" + filepath.Join(dir, SeedFile) + ",media=cdrom,readonly=on", + } + + restrict := "on" + if n.Role == topology.RoleSwitch { + restrict = "off" + } + args = append(args, + "-netdev", fmt.Sprintf("user,id=%s,restrict=%s,ipv6=off,hostfwd=tcp:%s:%d-:22", AdminInterface, restrict, loopback, n.SSHPort), + "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,romfile=", AdminInterface, adminMAC(index)), + ) + + for _, c := range nodeCables(p, n.Name) { + args = append(args, cable(c.NodeInterface, c.NodePort, c.SwitchPort, c.NodeMAC.String(), c.MTU)...) + } + for _, c := range switchCables(p, n.Name) { + args = append(args, cable(c.SwitchInterface, c.SwitchPort, c.NodePort, c.SwitchMAC.String(), c.MTU)...) + } + return args +} + +func cable(id string, local, remote int, mac string, mtu int) []string { + return []string{ + "-netdev", fmt.Sprintf("dgram,id=%s,local.type=inet,local.host=%s,local.port=%d,remote.type=inet,remote.host=%s,remote.port=%d", + id, loopback, local, loopback, remote), + "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,host_mtu=%d,romfile=", id, mac, mtu), + } +} diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go new file mode 100644 index 0000000..49b3089 --- /dev/null +++ b/internal/lab/render/render.go @@ -0,0 +1,132 @@ +package render + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + QEMUBinary = "qemu-system-x86_64" + + AdminInterface = topology.ReservedInterface + AdminAddress = "10.0.2.15/24" + AdminGateway = "10.0.2.2" + AdminDNS = "10.0.2.3" + + DiskFile = "disk.qcow2" + SeedFile = "seed.iso" + ConsoleFile = "console.log" + QMPFile = "qmp.sock" + PIDFile = "qemu.pid" +) + +var Nameservers = []string{"1.1.1.1", "8.8.8.8"} + +type Options struct { + RunDir string + AuthorizedKeys []string +} + +type Node struct { + Name string + Dir string + QEMU []string + MetaData []byte + UserData []byte + NetworkConfig []byte +} + +func Render(p *topology.Plan, o Options) ([]Node, error) { + if err := o.validate(); err != nil { + return nil, err + } + var nodes []Node + for index, n := range p.Nodes { + dir := filepath.Join(o.RunDir, n.Name) + meta, err := metaData(p, n) + if err != nil { + return nil, err + } + user, err := userData(p, n, o.AuthorizedKeys) + if err != nil { + return nil, err + } + network, err := networkConfig(p, n, index) + if err != nil { + return nil, err + } + nodes = append(nodes, Node{ + Name: n.Name, + Dir: dir, + QEMU: qemuArgs(p, n, index, dir), + MetaData: meta, + UserData: user, + NetworkConfig: network, + }) + } + return nodes, nil +} + +func (o Options) validate() error { + var errs []error + if !filepath.IsAbs(o.RunDir) { + errs = append(errs, fmt.Errorf("run dir %q must be an absolute path", o.RunDir)) + } + if len(o.AuthorizedKeys) == 0 { + errs = append(errs, errors.New("at least one authorized ssh key is required")) + } + for i, k := range o.AuthorizedKeys { + if strings.ContainsAny(k, "\r\n") { + errs = append(errs, fmt.Errorf("authorized key %d spans several lines", i+1)) + continue + } + fields := strings.Fields(k) + if len(fields) < 2 || !validKeyType(fields[0]) { + errs = append(errs, fmt.Errorf("authorized key %d is not an ssh public key", i+1)) + } + } + return errors.Join(errs...) +} + +func validKeyType(t string) bool { + return strings.HasPrefix(t, "ssh-") || strings.HasPrefix(t, "ecdsa-sha2-") || strings.HasPrefix(t, "sk-") +} + +func adminMAC(index int) net.HardwareAddr { + return net.HardwareAddr{0x02, 0x4d, byte(index >> 8), byte(index), 0x00, 0x00} +} + +func nodeCables(p *topology.Plan, name string) []topology.Cable { + var out []topology.Cable + for _, c := range p.Cables { + if c.Node == name { + out = append(out, c) + } + } + return out +} + +func switchCables(p *topology.Plan, name string) []topology.Cable { + var out []topology.Cable + for _, c := range p.Cables { + if c.Switch == name { + out = append(out, c) + } + } + return out +} + +func switchSegments(p *topology.Plan, name string) []topology.SegmentPlan { + var out []topology.SegmentPlan + for _, s := range p.Segments { + if s.Switch == name { + out = append(out, s) + } + } + return out +} diff --git a/internal/lab/render/render_test.go b/internal/lab/render/render_test.go new file mode 100644 index 0000000..f270d13 --- /dev/null +++ b/internal/lab/render/render_test.go @@ -0,0 +1,477 @@ +package render + +import ( + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation" + +const twoHypervisors = `name: evpn-2hv +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +segments: + underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } +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] } +` + +const twoSegments = `name: two-seg +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } + 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] } +` + +func plan(t *testing.T, doc string) *topology.Plan { + t.Helper() + topo, err := topology.Parse([]byte(doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + p, err := topology.Compute(topo) + if err != nil { + t.Fatalf("Compute: %v", err) + } + return p +} + +func renderAll(t *testing.T, doc string) map[string]Node { + t.Helper() + nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}}) + if err != nil { + t.Fatalf("Render: %v", err) + } + out := map[string]Node{} + for _, n := range nodes { + out[n.Name] = n + } + return out +} + +func nodeNamed(t *testing.T, nodes map[string]Node, name string) Node { + t.Helper() + n, ok := nodes[name] + if !ok { + t.Fatalf("no rendered node %s", name) + } + return n +} + +func value(t *testing.T, args []string, flag string) []string { + t.Helper() + var out []string + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + out = append(out, args[i+1]) + } + } + if len(out) == 0 { + t.Fatalf("no %s in %v", flag, args) + } + return out +} + +func hasValue(args []string, flag, want string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == want { + return true + } + } + return false +} + +func network(t *testing.T, n Node) networkDoc { + t.Helper() + var doc networkDoc + if err := yaml.Unmarshal(n.NetworkConfig, &doc); err != nil { + t.Fatalf("network-config of %s is not yaml: %v", n.Name, err) + } + return doc +} + +func iface(t *testing.T, doc networkDoc, name string) ethernet { + t.Helper() + e, ok := doc.Ethernets[name] + if !ok { + t.Fatalf("no interface %s in %v", name, doc.Ethernets) + } + return e +} + +func user(t *testing.T, n Node) cloudConfig { + t.Helper() + if !strings.HasPrefix(string(n.UserData), "#cloud-config\n") { + t.Fatalf("user-data of %s does not start with #cloud-config", n.Name) + } + var cfg cloudConfig + if err := yaml.Unmarshal(n.UserData, &cfg); err != nil { + t.Fatalf("user-data of %s is not yaml: %v", n.Name, err) + } + return cfg +} + +func fileAt(t *testing.T, cfg cloudConfig, path string) writeFile { + t.Helper() + for _, f := range cfg.WriteFiles { + if f.Path == path { + return f + } + } + t.Fatalf("no write_files entry %s", path) + return writeFile{} +} + +func TestQEMU_HypervisorArguments(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") + for flag, want := range map[string]string{ + "-name": "hv1", + "-machine": "q35", + "-accel": "kvm", + "-cpu": "host", + "-smp": "4", + "-m": "16384", + "-display": "none", + "-serial": "file:/srv/lab/hv1/console.log", + "-qmp": "unix:/srv/lab/hv1/qmp.sock,server=on,wait=off", + "-pidfile": "/srv/lab/hv1/qemu.pid", + } { + if !hasValue(hv.QEMU, flag, want) { + t.Errorf("%s %s missing from %v", flag, want, hv.QEMU) + } + } + for _, want := range []string{ + "file=/srv/lab/hv1/disk.qcow2,if=virtio,format=qcow2", + "file=/srv/lab/hv1/seed.iso,media=cdrom,readonly=on", + } { + if !hasValue(hv.QEMU, "-drive", want) { + t.Errorf("-drive %s missing", want) + } + } + if hv.Dir != "/srv/lab/hv1" { + t.Errorf("Dir = %s", hv.Dir) + } + nodefaults := 0 + for _, a := range hv.QEMU { + if a == "-nodefaults" { + nodefaults++ + } + } + if nodefaults != 1 { + t.Errorf("-nodefaults appears %d times", nodefaults) + } +} + +func TestQEMU_HypervisorNetwork(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") + wantNetdevs := []string{ + "user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22", + "dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003", + } + wantDevices := []string{ + "virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=", + "virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=", + } + if got := value(t, hv.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { + t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) + } + if got := value(t, hv.QEMU, "-device"); !reflect.DeepEqual(got, wantDevices) { + t.Errorf("devices = %v\nwant %v", got, wantDevices) + } +} + +func TestQEMU_SwitchNetwork(t *testing.T) { + sw := nodeNamed(t, renderAll(t, twoHypervisors), "sw1") + wantNetdevs := []string{ + "user,id=mgmt0,restrict=off,ipv6=off,hostfwd=tcp:127.0.0.1:2200-:22", + "dgram,id=p0,local.type=inet,local.host=127.0.0.1,local.port=20001,remote.type=inet,remote.host=127.0.0.1,remote.port=20000", + "dgram,id=p1,local.type=inet,local.host=127.0.0.1,local.port=20003,remote.type=inet,remote.host=127.0.0.1,remote.port=20002", + "dgram,id=p2,local.type=inet,local.host=127.0.0.1,local.port=20005,remote.type=inet,remote.host=127.0.0.1,remote.port=20004", + } + if got := value(t, sw.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { + t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) + } + if !hasValue(sw.QEMU, "-device", "virtio-net-pci,netdev=p2,mac=02:4c:00:03:00:01,host_mtu=9000,romfile=") { + t.Errorf("p2 device missing: %v", sw.QEMU) + } +} + +func TestQEMU_OnlyTheSwitchReachesTheOutsideThroughAdministration(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + admin := value(t, n.QEMU, "-netdev")[0] + wantRestrict := "restrict=on" + if name == "sw1" { + wantRestrict = "restrict=off" + } + if !strings.Contains(admin, ","+wantRestrict+",") { + t.Errorf("%s admin netdev %q, want %s", name, admin, wantRestrict) + } + if !strings.Contains(admin, "hostfwd=tcp:127.0.0.1:") { + t.Errorf("%s ssh forward not bound to loopback: %q", name, admin) + } + } +} + +func TestQEMU_EveryCableEndsMatch(t *testing.T) { + ends := map[string]int{} + for _, n := range renderAll(t, twoHypervisors) { + for _, nd := range value(t, n.QEMU, "-netdev") { + if !strings.HasPrefix(nd, "dgram,") { + continue + } + var local, remote string + for _, kv := range strings.Split(nd, ",") { + if v, ok := strings.CutPrefix(kv, "local.port="); ok { + local = v + } + if v, ok := strings.CutPrefix(kv, "remote.port="); ok { + remote = v + } + } + ends[local+">"+remote]++ + } + } + for pair, count := range ends { + local, remote, _ := strings.Cut(pair, ">") + if count != 1 || ends[remote+">"+local] != 1 { + t.Errorf("cable end %s has no single matching end", pair) + } + } + if len(ends) != 6 { + t.Errorf("%d cable ends, want 6", len(ends)) + } +} + +func TestNetworkConfig_Hypervisor(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) + if doc.Version != 2 || len(doc.Ethernets) != 2 { + t.Fatalf("network-config = %+v", doc) + } + admin := iface(t, doc, "mgmt0") + if admin.Match.MACAddress != "02:4d:00:02:00:00" || admin.SetName != "mgmt0" || + !reflect.DeepEqual(admin.Addresses, []string{"10.0.2.15/24"}) || len(admin.Routes) != 0 || admin.Nameservers != nil { + t.Errorf("mgmt0 = %+v", admin) + } + under := iface(t, doc, "underlay") + if under.Match.MACAddress != "02:4c:00:02:00:00" || under.SetName != "underlay" || under.MTU != 9000 || + !reflect.DeepEqual(under.Addresses, []string{"10.250.0.3/24"}) { + t.Errorf("underlay = %+v", under) + } + if !reflect.DeepEqual(under.Routes, []route{{To: "0.0.0.0/0", Via: "10.250.0.1"}}) { + t.Errorf("underlay routes = %+v", under.Routes) + } + if under.Nameservers == nil || !reflect.DeepEqual(under.Nameservers.Addresses, []string{"1.1.1.1", "8.8.8.8"}) { + t.Errorf("underlay nameservers = %+v", under.Nameservers) + } +} + +func TestNetworkConfig_DHCPIsExplicitlyOffEverywhere(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + if c := strings.Count(string(n.NetworkConfig), "dhcp4: false"); c != len(network(t, n).Ethernets) { + t.Errorf("%s: %d explicit dhcp4: false for %d interfaces", name, c, len(network(t, n).Ethernets)) + } + } +} + +func TestNetworkConfig_Switch(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) + admin := iface(t, doc, "mgmt0") + if !reflect.DeepEqual(admin.Routes, []route{{To: "0.0.0.0/0", Via: "10.0.2.2"}}) || + admin.Nameservers == nil || !reflect.DeepEqual(admin.Nameservers.Addresses, []string{"10.0.2.3"}) { + t.Errorf("switch mgmt0 = %+v", admin) + } + for port, mac := range map[string]string{"p0": "02:4c:00:01:00:01", "p1": "02:4c:00:02:00:01", "p2": "02:4c:00:03:00:01"} { + e := iface(t, doc, port) + if e.Match.MACAddress != mac || e.SetName != port || e.MTU != 9000 || len(e.Addresses) != 0 || len(e.Routes) != 0 { + t.Errorf("%s = %+v", port, e) + } + } +} + +func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoSegments), "hv")) + red, blue := iface(t, doc, "red"), iface(t, doc, "blue") + if !reflect.DeepEqual(red.Routes, []route{{To: "0.0.0.0/0", Via: "10.1.0.1"}}) || red.Nameservers == nil { + t.Errorf("red = %+v", red) + } + if len(blue.Routes) != 0 || blue.Nameservers != nil || blue.MTU != 1500 { + t.Errorf("blue = %+v", blue) + } +} + +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 { + t.Errorf("hv1 user-data = %+v", cfg) + } + if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) { + t.Errorf("keys = %v", cfg.SSHAuthorizedKeys) + } +} + +func TestUserData_PasswordLoginIsExplicitlyOff(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + if !strings.Contains(string(n.UserData), "\nssh_pwauth: false\n") || !strings.Contains(string(n.UserData), "\ndisable_root: true\n") { + t.Errorf("%s user-data:\n%s", name, n.UserData) + } + } +} + +func TestUserData_SwitchBuildsBridgeGatewayAndNAT(t *testing.T) { + cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) + if !reflect.DeepEqual(cfg.Packages, []string{"nftables"}) { + t.Errorf("packages = %v", cfg.Packages) + } + script := fileAt(t, cfg, "/usr/local/sbin/lab-switch") + if script.Permissions != "0755" { + t.Errorf("script permissions = %s", script.Permissions) + } + wantScript := `#!/bin/sh +set -eu +sysctl -qw net.ipv4.ip_forward=1 +ip link add br-underlay type bridge stp_state 0 2>/dev/null || true +ip link set dev p0 master br-underlay +ip link set dev p0 up +ip link set dev p1 master br-underlay +ip link set dev p1 up +ip link set dev p2 master br-underlay +ip link set dev p2 up +ip link set dev br-underlay mtu 9000 +ip addr replace 10.250.0.1/24 dev br-underlay +ip link set dev br-underlay up +nft -f /etc/lab-switch.nft +` + if script.Content != wantScript { + t.Errorf("script:\n%s\nwant:\n%s", script.Content, wantScript) + } + nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content + for _, want := range []string{ + "add table ip lab_nat\ndelete table ip lab_nat\n", + "type nat hook postrouting priority srcnat;", + `ip saddr { 10.250.0.0/24 } oifname "mgmt0" masquerade`, + } { + if !strings.Contains(nft, want) { + t.Errorf("nft rules miss %q:\n%s", want, nft) + } + } + unit := fileAt(t, cfg, "/etc/systemd/system/lab-switch.service").Content + for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/sbin/lab-switch", "After=network-online.target", "WantedBy=multi-user.target"} { + if !strings.Contains(unit, want) { + t.Errorf("unit misses %q", want) + } + } + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) { + t.Errorf("runcmd = %v", cfg.Runcmd) + } +} + +func TestUserData_SwitchWithTwoSegments(t *testing.T) { + cfg := user(t, nodeNamed(t, renderAll(t, twoSegments), "sw")) + script := fileAt(t, cfg, "/usr/local/sbin/lab-switch").Content + for _, want := range []string{ + "ip link set dev p0 master br-red\n", + "ip link set dev p1 master br-blue\n", + "ip link set dev br-blue mtu 1500\n", + "ip addr replace 10.2.0.1/24 dev br-blue\n", + } { + if !strings.Contains(script, want) { + t.Errorf("script misses %q:\n%s", want, script) + } + } + if strings.Contains(script, "p1 master br-red") || strings.Contains(script, "p0 master br-blue") { + t.Errorf("port bridged on the wrong segment:\n%s", script) + } + if nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content; !strings.Contains(nft, "ip saddr { 10.1.0.0/24, 10.2.0.0/24 }") { + t.Errorf("nft:\n%s", nft) + } +} + +func TestMetaData(t *testing.T) { + var doc metaDoc + if err := yaml.Unmarshal(nodeNamed(t, renderAll(t, twoHypervisors), "hv2").MetaData, &doc); err != nil { + t.Fatal(err) + } + if doc.InstanceID != "evpn-2hv-hv2" || doc.LocalHostname != "hv2" { + t.Errorf("meta-data = %+v", doc) + } +} + +func TestRender_AdminMacsAreUniqueAndApartFromCableMacs(t *testing.T) { + seen := map[string]string{} + for name, n := range renderAll(t, twoHypervisors) { + for _, d := range value(t, n.QEMU, "-device") { + for _, kv := range strings.Split(d, ",") { + if mac, ok := strings.CutPrefix(kv, "mac="); ok { + if other, dup := seen[mac]; dup { + t.Errorf("mac %s used by %s and %s", mac, other, name) + } + seen[mac] = name + } + } + } + } + if len(seen) != 10 { + t.Errorf("%d distinct macs, want 10", len(seen)) + } +} + +func TestRender_IsStable(t *testing.T) { + first := renderAll(t, twoHypervisors) + for i := 0; i < 20; i++ { + if again := renderAll(t, twoHypervisors); !reflect.DeepEqual(first, again) { + t.Fatalf("render differs on run %d", i) + } + } +} + +func TestRender_RejectsBadOptions(t *testing.T) { + p := plan(t, twoHypervisors) + cases := []struct { + name string + opts Options + want string + }{ + {"relative run dir", Options{RunDir: "lab", AuthorizedKeys: []string{labKey}}, `run dir "lab" must be an absolute path`}, + {"no key", Options{RunDir: "/srv/lab"}, "at least one authorized ssh key"}, + {"multi-line key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey + "\nssh-rsa AAAA x"}}, "authorized key 1 spans several lines"}, + {"not a key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"hello world"}}, "authorized key 1 is not an ssh public key"}, + {"type only", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"ssh-ed25519"}}, "authorized key 1 is not an ssh public key"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Render(p, c.opts) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + }) + } +} + +func TestRender_AcceptsUsualKeyTypes(t *testing.T) { + p := plan(t, twoHypervisors) + for _, k := range []string{ + "ssh-rsa AAAAB3NzaC1yc2E user", + "ecdsa-sha2-nistp256 AAAAE2VjZHNh user", + "sk-ssh-ed25519@openssh.com AAAAGnNr user", + } { + if _, err := Render(p, Options{RunDir: "/srv/lab", AuthorizedKeys: []string{k}}); err != nil { + t.Errorf("%s rejected: %v", strings.Fields(k)[0], err) + } + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go new file mode 100644 index 0000000..c959d3c --- /dev/null +++ b/internal/lab/render/seed.go @@ -0,0 +1,192 @@ +package render + +import ( + "fmt" + "strings" + + "go.yaml.in/yaml/v3" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + SwitchScript = "/usr/local/sbin/lab-switch" + SwitchNFT = "/etc/lab-switch.nft" + SwitchUnit = "/etc/systemd/system/lab-switch.service" +) + +type metaDoc struct { + InstanceID string `yaml:"instance-id"` + LocalHostname string `yaml:"local-hostname"` +} + +type writeFile struct { + Path string `yaml:"path"` + Permissions string `yaml:"permissions"` + Content string `yaml:"content"` +} + +type cloudConfig struct { + Hostname string `yaml:"hostname"` + SSHPwauth bool `yaml:"ssh_pwauth"` + DisableRoot bool `yaml:"disable_root"` + SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys"` + Packages []string `yaml:"packages,omitempty"` + WriteFiles []writeFile `yaml:"write_files,omitempty"` + Runcmd [][]string `yaml:"runcmd,omitempty"` +} + +type match struct { + MACAddress string `yaml:"macaddress"` +} + +type route struct { + To string `yaml:"to"` + Via string `yaml:"via"` +} + +type nameservers struct { + Addresses []string `yaml:"addresses"` +} + +type ethernet struct { + Match match `yaml:"match"` + SetName string `yaml:"set-name"` + DHCP4 bool `yaml:"dhcp4"` + MTU int `yaml:"mtu,omitempty"` + Addresses []string `yaml:"addresses,omitempty"` + Routes []route `yaml:"routes,omitempty"` + Nameservers *nameservers `yaml:"nameservers,omitempty"` +} + +type networkDoc struct { + Version int `yaml:"version"` + Ethernets map[string]ethernet `yaml:"ethernets"` +} + +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) { + cfg := cloudConfig{ + Hostname: n.Name, + SSHPwauth: false, + DisableRoot: true, + SSHAuthorizedKeys: keys, + } + if n.Role == topology.RoleSwitch { + cfg.Packages = []string{"nftables"} + cfg.WriteFiles = []writeFile{ + {Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)}, + {Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)}, + {Path: SwitchUnit, Permissions: "0644", Content: switchUnit()}, + } + cfg.Runcmd = [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "lab-switch.service"}, + } + } + body, err := yaml.Marshal(cfg) + if err != nil { + return nil, err + } + return append([]byte("#cloud-config\n"), body...), nil +} + +func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) { + doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}} + admin := ethernet{ + Match: match{MACAddress: adminMAC(index).String()}, + SetName: AdminInterface, + Addresses: []string{AdminAddress}, + } + if n.Role == topology.RoleSwitch { + admin.Routes = []route{{To: "0.0.0.0/0", Via: AdminGateway}} + admin.Nameservers = &nameservers{Addresses: []string{AdminDNS}} + for _, c := range switchCables(p, n.Name) { + doc.Ethernets[c.SwitchInterface] = ethernet{ + Match: match{MACAddress: c.SwitchMAC.String()}, + SetName: c.SwitchInterface, + MTU: c.MTU, + } + } + } + doc.Ethernets[AdminInterface] = admin + + for i, c := range nodeCables(p, n.Name) { + e := ethernet{ + Match: match{MACAddress: c.NodeMAC.String()}, + SetName: c.NodeInterface, + MTU: c.MTU, + Addresses: []string{c.NodeAddress.String()}, + } + if i == 0 { + e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}} + e.Nameservers = &nameservers{Addresses: Nameservers} + } + doc.Ethernets[c.NodeInterface] = e + } + return yaml.Marshal(doc) +} + +func gatewayOf(p *topology.Plan, segment string) string { + for _, s := range p.Segments { + if s.Name == segment { + return s.Gateway.String() + } + } + return "" +} + +func switchScript(p *topology.Plan, name string) string { + var b strings.Builder + b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n") + for _, s := range switchSegments(p, name) { + fmt.Fprintf(&b, "ip link add %s type bridge stp_state 0 2>/dev/null || true\n", s.Bridge) + for _, c := range switchCables(p, name) { + if c.Segment != s.Name { + continue + } + fmt.Fprintf(&b, "ip link set dev %s master %s\n", c.SwitchInterface, s.Bridge) + fmt.Fprintf(&b, "ip link set dev %s up\n", c.SwitchInterface) + } + fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU) + fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge) + fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge) + } + fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT) + return b.String() +} + +func switchNFT(p *topology.Plan, name string) string { + var networks []string + for _, s := range switchSegments(p, name) { + networks = append(networks, s.Network.String()) + } + return fmt.Sprintf(`add table ip lab_nat +delete table ip lab_nat +table ip lab_nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + ip saddr { %s } oifname "%s" masquerade + } +} +`, strings.Join(networks, ", "), AdminInterface) +} + +func switchUnit() string { + return fmt.Sprintf(`[Unit] +Description=Lab switch: bridges, gateways and NAT +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=%s + +[Install] +WantedBy=multi-user.target +`, SwitchScript) +} diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go index e59b189..d050a0f 100644 --- a/internal/lab/topology/topology_test.go +++ b/internal/lab/topology/topology_test.go @@ -133,6 +133,7 @@ func TestValidate_Rejections(t *testing.T) { {"segment switch unknown", strings.Replace(valid, "switch: sw, cidr", "switch: ghost, cidr", 1), "switch ghost is not a declared node"}, {"segment switch not a switch", strings.Replace(valid, "switch: sw, cidr", "switch: rr, cidr", 1), "rr is a rr, not a switch"}, {"segment name too long", strings.ReplaceAll(valid, "under", "underlayunder"), "segment underlayunder: name must match"}, + {"segment named like the admin interface", strings.ReplaceAll(valid, "under", "mgmt0"), "segment mgmt0: name is reserved"}, {"segment name with dash", strings.ReplaceAll(valid, "under", "un-der"), "segment un-der: name must match"}, {"mtu too high", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 9001", 1), "mtu 9001 out of range"}, {"mtu too low", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 1279", 1), "mtu 1279 out of range"}, diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 8a5abf9..6a7d53e 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -15,6 +15,8 @@ const ( MinMemory = 256 MaxPrefix = 30 MinPrefix = 8 + + ReservedInterface = "mgmt0" ) var ( @@ -63,6 +65,9 @@ func (t *Topology) Validate() error { if !segmentPattern.MatchString(s.Name) { add("segment %s: name must match %s", s.Name, segmentPattern) } + if s.Name == ReservedInterface { + add("segment %s: name is reserved for the administration interface", s.Name) + } sw, ok := nodes[s.Switch] switch { case s.Switch == "":