diff --git a/cmd/lab/main.go b/cmd/lab/main.go new file mode 100644 index 0000000..dc0199d --- /dev/null +++ b/cmd/lab/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "os" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const usage = `usage: lab + + plan validate the topology and print the deterministic plan: addresses, cables, ports +` + +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 { + fmt.Fprint(stderr, usage) + return 2 + } + switch args[0] { + case "plan": + return plan(args[1], stdout, stderr) + default: + fmt.Fprint(stderr, usage) + return 2 + } +} + +func plan(path string, stdout, stderr io.Writer) int { + t, err := topology.Load(path) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + p, err := topology.Compute(t) + if err != nil { + fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err) + return 1 + } + if err := p.Write(stdout); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go new file mode 100644 index 0000000..3c87f8a --- /dev/null +++ b/cmd/lab/main_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func runLab(args ...string) (int, string, string) { + var stdout, stderr bytes.Buffer + code := run(args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestRun_UsageOnMissingArguments(t *testing.T) { + for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}} { + code, _, stderr := runLab(args...) + if code != 2 || !strings.Contains(stderr, "usage: lab") { + t.Errorf("args %v: code %d, stderr %q", args, code, stderr) + } + } +} + +func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) { + code, stdout, stderr := runLab("plan", filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml")) + if code != 0 { + t.Fatalf("code %d, stderr %s", code, stderr) + } + 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", + } { + if !strings.Contains(stdout, want) { + t.Errorf("output does not contain %q:\n%s", want, stdout) + } + } +} + +func TestRun_InvalidTopologyExitsWithErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.yml") + doc := `name: bad +images: + deb: { url: https://example.invalid/a, sums: https://example.invalid/b } +segments: + under: { switch: sw, cidr: 10.0.0.0/31 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + rr: { role: router, image: deb, cpus: 1, memory: 512, segments: [under] } +` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + code, stdout, stderr := runLab("plan", path) + if code != 1 || stdout != "" { + t.Fatalf("code %d, stdout %q", code, stdout) + } + for _, want := range []string{path, `role "router"`, "prefix length out of range"} { + if !strings.Contains(stderr, want) { + t.Errorf("stderr does not mention %q:\n%s", want, stderr) + } + } +} + +func TestRun_MissingFile(t *testing.T) { + code, _, stderr := runLab("plan", filepath.Join(t.TempDir(), "absent.yml")) + if code != 1 || !strings.Contains(stderr, "absent.yml") { + t.Errorf("code %d, stderr %q", code, stderr) + } +} diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml new file mode 100644 index 0000000..3d389f6 --- /dev/null +++ b/conf/lab/evpn-2hv.yml @@ -0,0 +1,18 @@ +name: evpn-2hv + +images: + debian12: + url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2 + sums: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS + +segments: + underlay: + switch: sw1 + cidr: 10.250.0.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] } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 3da14eb..7c55361 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,9 +13,10 @@ comment s'en servir. .. note:: - État actuel : seule l'étape **E0** est livrée — le cycle de vie du serveur qui portera le lab, - avec ``scripts/lab-host.sh``. La description de la topologie et le lancement des VM viendront - avec les étapes suivantes, et cette page avec elles. + É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. Le serveur de lab ----------------- @@ -162,6 +163,97 @@ environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. ``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression à la charge de l'utilisateur. +Topologie +--------- + +Un lab est décrit par un fichier YAML : des **nœuds** (les VM) et des **segments** (des réseaux L2 +portés par un switch). Exemple livré, ``conf/lab/evpn-2hv.yml`` : + +.. literalinclude:: ../../conf/lab/evpn-2hv.yml + :language: yaml + +Chaque nœud non-switch est relié au switch de chacun de ses segments par un câble virtuel QEMU ; +le switch met ces câbles dans un bridge et porte la passerelle du segment. + +Ce que le fichier déclare : + +``images`` + ``url`` de l'image qcow2 et ``sums`` du fichier de sommes à vérifier, tous deux en ``https://``. + +``segments`` + ``switch`` (un nœud de rôle ``switch``), ``cidr`` IPv4 entre ``/8`` et ``/30``, ``mtu`` + facultatif — 9000 par défaut, entre 1280 et 9000. Nom : 12 caractères au plus, minuscules et + chiffres, parce qu'il devient le nom d'interface dans les VM et, préfixé de ``br-``, celui du + bridge (15 caractères au plus sous Linux). + +``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 + déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``. + +Ce que l'outil en déduit, de façon déterministe — même fichier, même plan : + +.. list-table:: + :widths: 30 70 + + * - Passerelle d'un segment + - la première adresse du CIDR, portée par le switch sur ``br-`` + * - Adresse d'un nœud + - les suivantes, **dans l'ordre de déclaration des nœuds** ; une adresse fixée par + ``addresses`` est réservée d'abord et sautée par l'attribution automatique + * - Câbles + - un par couple (segment, nœud), segments puis nœuds dans l'ordre de déclaration ; le + câble *i* utilise les ports UDP ``20000 + 2i`` (côté nœud) et ``20001 + 2i`` (côté switch) + * - MAC + - ``02:4c::::`` — préfixe localement administré, rang du nœud + sur deux octets, rang du segment, ``00`` côté nœud et ``01`` côté switch + * - Interfaces + - côté nœud, le nom du segment ; côté switch, ``p``, du rang du câble + * - SSH d'administration + - ``127.0.0.1:<2200 + rang du nœud>`` sur l'hôte du lab + +.. warning:: + + Réordonner les nœuds ou les segments dans le fichier **change les adresses, les MAC et les + ports**. C'est assumé pour un lab ; ``lab plan`` montre le résultat avant tout lancement. + +Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le permet (22 768). + +``lab plan`` valide le fichier et affiche le plan, sans rien lancer : + +.. code-block:: text + + $ go run ./cmd/lab plan conf/lab/evpn-2hv.yml + lab evpn-2hv: nodes 4, segments 1, cables 3 + + nodes + name role image cpus memory ssh + sw1 switch debian12 2 1024 MiB 127.0.0.1:2200 + rr1 rr debian12 1 1024 MiB 127.0.0.1:2201 + 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 + +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 : + +.. code-block:: text + + $ lab plan cassee.yml + lab: cassee.yml: + segment underlay: rr1 is a rr, not a switch + 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). + Facturation ----------- @@ -240,6 +332,7 @@ Tests .. code-block:: bash bash scripts/lab-host_test.sh + go test ./internal/lab/... ./cmd/lab/ Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets, diff --git a/go.mod b/go.mod index 0e59eb9..9bcc86b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netns v0.0.5 + go.yaml.in/yaml/v3 v3.0.4 golang.org/x/sys v0.45.0 ) @@ -46,7 +47,6 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go new file mode 100644 index 0000000..e501bff --- /dev/null +++ b/internal/lab/topology/format.go @@ -0,0 +1,34 @@ +package topology + +import ( + "fmt" + "io" + "text/tabwriter" +) + +func (p *Plan) Write(w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + + fmt.Fprintf(tw, "lab %s: nodes %d, segments %d, cables %d\n", p.Name, len(p.Nodes), len(p.Segments), len(p.Cables)) + + fmt.Fprintf(tw, "\nnodes\n") + fmt.Fprintf(tw, " name\trole\timage\tcpus\tmemory\tssh\n") + for _, n := range p.Nodes { + 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) + } + + 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) + fmt.Fprintf(tw, " node\tinterface\taddress\tmac\tudp\t\tswitch port\tmac\tudp\n") + for _, c := range p.Cables { + if c.Segment != s.Name { + continue + } + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%d\t<->\t%s %s\t%s\t%d\n", + c.Node, c.NodeInterface, c.NodeAddress, c.NodeMAC, c.NodePort, + c.Switch, c.SwitchInterface, c.SwitchMAC, c.SwitchPort) + } + } + return tw.Flush() +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go new file mode 100644 index 0000000..5d91389 --- /dev/null +++ b/internal/lab/topology/plan.go @@ -0,0 +1,195 @@ +package topology + +import ( + "errors" + "fmt" + "net" + "net/netip" + "strconv" +) + +const ( + SSHBasePort = 2200 + CableBasePort = 20000 + + maxNodes = 1000 + maxSegments = 256 +) + +type Plan struct { + Name string + Segments []SegmentPlan + Nodes []NodePlan + Cables []Cable +} + +type SegmentPlan struct { + Name string + Switch string + Bridge string + Network netip.Prefix + Gateway netip.Addr + MTU int +} + +type NodePlan struct { + Name string + Role string + Image string + CPUs int + Memory int + SSHPort int +} + +type Cable struct { + Segment string + MTU int + + Node string + NodeInterface string + NodeMAC net.HardwareAddr + NodeAddress netip.Prefix + NodePort int + + Switch string + SwitchInterface string + SwitchMAC net.HardwareAddr + SwitchPort int +} + +func Compute(t *Topology) (*Plan, error) { + if err := t.Validate(); err != nil { + return nil, err + } + + p := &Plan{Name: t.Name} + for i, n := range t.Nodes { + p.Nodes = append(p.Nodes, NodePlan{ + Name: n.Name, + Role: n.Role, + Image: n.Image, + CPUs: n.CPUs, + Memory: n.Memory, + SSHPort: SSHBasePort + i, + }) + } + + var errs []error + for segIndex, s := range t.Segments { + network := netip.MustParsePrefix(s.CIDR) + gateway := network.Addr().Next() + p.Segments = append(p.Segments, SegmentPlan{ + Name: s.Name, + Switch: s.Switch, + Bridge: "br-" + s.Name, + Network: network, + Gateway: gateway, + MTU: s.MTU, + }) + + addresses, err := allocate(t, s, network, gateway) + if err != nil { + errs = append(errs, err) + continue + } + + for nodeIndex, n := range t.Nodes { + address, ok := addresses[n.Name] + if !ok { + continue + } + cable := len(p.Cables) + p.Cables = append(p.Cables, Cable{ + Segment: s.Name, + MTU: s.MTU, + Node: n.Name, + NodeInterface: s.Name, + NodeMAC: mac(nodeIndex, segIndex, 0), + NodeAddress: netip.PrefixFrom(address, network.Bits()), + NodePort: CableBasePort + 2*cable, + Switch: s.Switch, + SwitchInterface: "p" + strconv.Itoa(cable), + SwitchMAC: mac(nodeIndex, segIndex, 1), + SwitchPort: CableBasePort + 2*cable + 1, + }) + } + } + + if last := CableBasePort + 2*len(p.Cables) - 1; last > 65535 { + errs = append(errs, fmt.Errorf("cables: %d cables need udp ports up to %d, beyond 65535", len(p.Cables), last)) + } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + return p, nil +} + +func allocate(t *Topology, s Segment, network netip.Prefix, gateway netip.Addr) (map[string]netip.Addr, error) { + broadcast := lastAddr(network) + used := map[netip.Addr]string{gateway: s.Switch + " (gateway)"} + result := map[string]netip.Addr{} + var errs []error + + var auto []string + for _, n := range t.Nodes { + if !contains(n.Segments, s.Name) { + continue + } + raw, ok := n.Addresses[s.Name] + if !ok { + auto = append(auto, n.Name) + continue + } + addr := netip.MustParseAddr(raw) + switch { + case !network.Contains(addr): + errs = append(errs, fmt.Errorf("node %s: address %s is outside segment %s (%s)", n.Name, addr, s.Name, network)) + case addr == network.Addr() || addr == broadcast: + errs = append(errs, fmt.Errorf("node %s: address %s is the network or broadcast address of segment %s", n.Name, addr, s.Name)) + case used[addr] != "": + errs = append(errs, fmt.Errorf("node %s: address %s on segment %s is already taken by %s", n.Name, addr, s.Name, used[addr])) + default: + used[addr] = n.Name + result[n.Name] = addr + } + } + + next := gateway.Next() + for _, name := range auto { + for next != broadcast && used[next] != "" { + next = next.Next() + } + if next == broadcast { + errs = append(errs, fmt.Errorf("segment %s: no address left in %s for node %s", s.Name, network, name)) + break + } + used[next] = name + result[name] = next + next = next.Next() + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + return result, nil +} + +func lastAddr(network netip.Prefix) netip.Addr { + a := network.Addr().As4() + host := uint32(1)<<(32-network.Bits()) - 1 + v := (uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])) | host + return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) +} + +func mac(node, segment, side int) net.HardwareAddr { + return net.HardwareAddr{0x02, 0x4c, byte(node >> 8), byte(node), byte(segment), byte(side)} +} + +func contains(list []string, value string) bool { + for _, v := range list { + if v == value { + return true + } + } + return false +} diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go new file mode 100644 index 0000000..9c65b6d --- /dev/null +++ b/internal/lab/topology/plan_test.go @@ -0,0 +1,351 @@ +package topology + +import ( + "bytes" + "fmt" + "net/netip" + "reflect" + "strings" + "testing" +) + +func compute(t *testing.T, doc string) *Plan { + t.Helper() + p, err := Compute(parse(t, doc)) + if err != nil { + t.Fatalf("Compute: %v", err) + } + return p +} + +func computeError(t *testing.T, doc string) string { + t.Helper() + _, err := Compute(parse(t, doc)) + if err == nil { + t.Fatalf("Compute accepted:\n%s", doc) + } + return err.Error() +} + +func cableOf(t *testing.T, p *Plan, node, segment string) Cable { + t.Helper() + for _, c := range p.Cables { + if c.Node == node && c.Segment == segment { + return c + } + } + t.Fatalf("no cable for %s on %s", node, segment) + return Cable{} +} + +func nodeOf(t *testing.T, p *Plan, name string) NodePlan { + t.Helper() + for _, n := range p.Nodes { + if n.Name == name { + return n + } + } + t.Fatalf("no node %s", name) + return NodePlan{} +} + +const twoHypervisors = header + ` +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] } +` + +func TestCompute_TwoHypervisorsPlan(t *testing.T) { + p := compute(t, twoHypervisors) + + if len(p.Segments) != 1 { + t.Fatalf("%d segments, want 1", len(p.Segments)) + } + s := p.Segments[0] + if s.Bridge != "br-underlay" || s.Gateway != netip.MustParseAddr("10.250.0.1") || s.MTU != 9000 || s.Switch != "sw1" { + t.Errorf("segment = %+v", s) + } + + want := []struct { + node, address, nodeMAC, switchMAC, switchIface string + nodePort, switchPort int + }{ + {"rr1", "10.250.0.2/24", "02:4c:00:01:00:00", "02:4c:00:01:00:01", "p0", 20000, 20001}, + {"hv1", "10.250.0.3/24", "02:4c:00:02:00:00", "02:4c:00:02:00:01", "p1", 20002, 20003}, + {"hv2", "10.250.0.4/24", "02:4c:00:03:00:00", "02:4c:00:03:00:01", "p2", 20004, 20005}, + } + if len(p.Cables) != len(want) { + t.Fatalf("%d cables, want %d", len(p.Cables), len(want)) + } + for _, w := range want { + c := cableOf(t, p, w.node, "underlay") + if c.NodeAddress.String() != w.address { + t.Errorf("%s address = %s, want %s", w.node, c.NodeAddress, w.address) + } + if c.NodeMAC.String() != w.nodeMAC || c.SwitchMAC.String() != w.switchMAC { + t.Errorf("%s macs = %s / %s, want %s / %s", w.node, c.NodeMAC, c.SwitchMAC, w.nodeMAC, w.switchMAC) + } + if c.NodePort != w.nodePort || c.SwitchPort != w.switchPort { + t.Errorf("%s ports = %d / %d, want %d / %d", w.node, c.NodePort, c.SwitchPort, w.nodePort, w.switchPort) + } + if c.NodeInterface != "underlay" || c.SwitchInterface != w.switchIface || c.Switch != "sw1" || c.MTU != 9000 { + t.Errorf("%s cable = %+v", w.node, c) + } + } + + for name, port := range map[string]int{"sw1": 2200, "rr1": 2201, "hv1": 2202, "hv2": 2203} { + if got := nodeOf(t, p, name).SSHPort; got != port { + t.Errorf("%s ssh port = %d, want %d", name, got, port) + } + } + if hv := nodeOf(t, p, "hv1"); hv.Role != "hypervisor" || hv.CPUs != 4 || hv.Memory != 16384 || hv.Image != "deb" { + t.Errorf("hv1 = %+v", hv) + } +} + +func TestCompute_IsStableAcrossRuns(t *testing.T) { + first := compute(t, twoHypervisors) + for i := 0; i < 20; i++ { + if again := compute(t, twoHypervisors); !reflect.DeepEqual(first, again) { + t.Fatalf("plan differs on run %d", i) + } + } +} + +func TestCompute_AddressesFollowDeclarationOrderNotNames(t *testing.T) { + p := compute(t, header+` +segments: + under: { switch: sw, cidr: 10.0.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } + alfa: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } +`) + if got := cableOf(t, p, "zeta", "under").NodeAddress.String(); got != "10.0.0.2/24" { + t.Errorf("zeta = %s, want 10.0.0.2/24", got) + } + if got := cableOf(t, p, "alfa", "under").NodeAddress.String(); got != "10.0.0.3/24" { + t.Errorf("alfa = %s, want 10.0.0.3/24", got) + } +} + +func TestCompute_ExplicitAddressIsKeptAndSkippedByAllocation(t *testing.T) { + p := compute(t, header+` +segments: + under: { switch: sw, cidr: 10.0.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } + b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.3} } + c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } +`) + for node, want := range map[string]string{"a": "10.0.0.2/24", "b": "10.0.0.3/24", "c": "10.0.0.4/24"} { + if got := cableOf(t, p, node, "under").NodeAddress.String(); got != want { + t.Errorf("%s = %s, want %s", node, got, want) + } + } +} + +func TestCompute_TwoSegmentsCableOrderMacsAndPorts(t *testing.T) { + p := compute(t, header+` +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] } + rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] } +`) + got := make([]string, 0, len(p.Cables)) + for _, c := range p.Cables { + got = append(got, c.Segment+"/"+c.Node+"/"+c.SwitchInterface) + } + if strings.Join(got, ",") != "red/hv/p0,red/rr/p1,blue/hv/p2" { + t.Errorf("cable order = %v", got) + } + blue := cableOf(t, p, "hv", "blue") + if blue.NodeMAC.String() != "02:4c:00:01:01:00" || blue.NodePort != 20004 || blue.MTU != 1500 || blue.NodeAddress.String() != "10.2.0.2/24" { + t.Errorf("hv on blue = %+v", blue) + } + if red := cableOf(t, p, "hv", "red"); red.NodeMAC.String() != "02:4c:00:01:00:00" { + t.Errorf("hv on red mac = %s", red.NodeMAC) + } +} + +func TestCompute_EveryMacAndPortIsUnique(t *testing.T) { + p := compute(t, header+` +segments: + a: { switch: sw, cidr: 10.1.0.0/24 } + b: { switch: sw, cidr: 10.2.0.0/24 } +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] } +`) + macs := map[string]bool{} + ports := map[int]bool{} + for _, n := range p.Nodes { + ports[n.SSHPort] = true + } + for _, c := range p.Cables { + for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} { + if macs[m] { + t.Errorf("mac %s used twice", m) + } + macs[m] = true + } + for _, port := range []int{c.NodePort, c.SwitchPort} { + if ports[port] { + t.Errorf("port %d used twice", port) + } + ports[port] = true + } + } + if len(macs) != 12 { + t.Errorf("%d distinct macs, want 12", len(macs)) + } +} + +func TestCompute_MacsAreLocallyAdministeredUnicast(t *testing.T) { + for _, c := range compute(t, twoHypervisors).Cables { + for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} { + if !strings.HasPrefix(m, "02:") { + t.Errorf("mac %s is not locally administered unicast", m) + } + } + } +} + +func TestCompute_AddressRejections(t *testing.T) { + base := func(addresses string) string { + return header + ` +segments: + under: { switch: sw, cidr: 10.0.0.0/29 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under]` + addresses + ` } + b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.5} } +` + } + cases := []struct{ name, addresses, want string }{ + {"outside", ", addresses: {under: 10.0.1.2}", "node a: address 10.0.1.2 is outside segment under"}, + {"network", ", addresses: {under: 10.0.0.0}", "node a: address 10.0.0.0 is the network or broadcast"}, + {"broadcast", ", addresses: {under: 10.0.0.7}", "node a: address 10.0.0.7 is the network or broadcast"}, + {"gateway", ", addresses: {under: 10.0.0.1}", "node a: address 10.0.0.1 on segment under is already taken by sw (gateway)"}, + {"duplicate", ", addresses: {under: 10.0.0.5}", "node b: address 10.0.0.5 on segment under is already taken by a"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + requireContains(t, computeError(t, base(c.addresses)), c.want) + }) + } +} + +func TestCompute_ReportsExhaustedSegment(t *testing.T) { + got := computeError(t, header+` +segments: + tiny: { switch: sw, cidr: 10.0.0.0/30 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } + b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } +`) + requireContains(t, got, "segment tiny: no address left in 10.0.0.0/30 for node b") +} + +func TestCompute_FillsSegmentExactly(t *testing.T) { + p := compute(t, header+` +segments: + tiny: { switch: sw, cidr: 10.0.0.0/29 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } + b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } + c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } + d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } + e: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } +`) + if got := cableOf(t, p, "e", "tiny").NodeAddress.String(); got != "10.0.0.6/29" { + t.Errorf("last node = %s, want 10.0.0.6/29", got) + } +} + +func TestCompute_RefusesInvalidTopology(t *testing.T) { + requireContains(t, computeError(t, strings.Replace(valid, "role: rr", "role: router", 1)), `role "router"`) +} + +func TestLastAddr(t *testing.T) { + for cidr, want := range map[string]string{ + "10.0.0.0/24": "10.0.0.255", + "10.0.0.0/30": "10.0.0.3", + "10.250.0.0/16": "10.250.255.255", + "192.168.4.8/29": "192.168.4.15", + } { + if got := lastAddr(netip.MustParsePrefix(cidr)).String(); got != want { + t.Errorf("lastAddr(%s) = %s, want %s", cidr, got, want) + } + } +} + +func TestWrite_TwoHypervisorsPlan(t *testing.T) { + var buf bytes.Buffer + if err := compute(t, twoHypervisors).Write(&buf); err != nil { + t.Fatalf("Write: %v", err) + } + want := `lab lab-test: nodes 4, segments 1, cables 3 + +nodes + name role image cpus memory ssh + sw1 switch deb 2 1024 MiB 127.0.0.1:2200 + rr1 rr deb 1 1024 MiB 127.0.0.1:2201 + hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202 + hv2 hypervisor deb 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 +` + if buf.String() != want { + t.Errorf("output:\n%s\nwant:\n%s", buf.String(), want) + } +} + +func generated(segments, nodes int) string { + var b strings.Builder + b.WriteString(header + "segments:\n") + names := make([]string, segments) + for i := range names { + names[i] = fmt.Sprintf("s%d", i) + fmt.Fprintf(&b, " %s: { switch: sw, cidr: 10.%d.0.0/16 }\n", names[i], i%250) + } + b.WriteString("nodes:\n sw: { role: switch, image: deb, cpus: 1, memory: 512 }\n") + for i := 1; i < nodes; i++ { + fmt.Fprintf(&b, " n%d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [%s] }\n", i, strings.Join(names, ", ")) + } + return b.String() +} + +func TestValidate_RejectsTooManyNodes(t *testing.T) { + requireContains(t, validationError(t, generated(1, 1001)), "nodes: 1001 declared, at most 1000") +} + +func TestValidate_AcceptsExactlyMaxNodes(t *testing.T) { + if err := parse(t, generated(1, 1000)).Validate(); err != nil { + t.Fatalf("1000 nodes rejected: %v", err) + } +} + +func TestValidate_RejectsTooManySegments(t *testing.T) { + requireContains(t, validationError(t, generated(257, 2)), "segments: 257 declared, at most 256") +} + +func TestCompute_RejectsCablesBeyondUDPPortRange(t *testing.T) { + requireContains(t, computeError(t, generated(23, 1000)), "cables: 22977 cables need udp ports up to 65953, beyond 65535") +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go new file mode 100644 index 0000000..bd184d7 --- /dev/null +++ b/internal/lab/topology/topology.go @@ -0,0 +1,151 @@ +package topology + +import ( + "bytes" + "errors" + "fmt" + "os" + + "go.yaml.in/yaml/v3" +) + +const ( + RoleSwitch = "switch" + RoleRR = "rr" + RoleHypervisor = "hypervisor" + + DefaultMTU = 9000 +) + +type Topology struct { + Name string + Images []Image + Segments []Segment + Nodes []Node +} + +type Image struct { + Name string + URL string + Sums string +} + +type Segment struct { + Name string + Switch string + CIDR string + MTU int +} + +type Node struct { + Name string + Role string + Image string + CPUs int + Memory int + Segments []string + Addresses map[string]string +} + +type fileImage struct { + URL string `yaml:"url"` + Sums string `yaml:"sums"` +} + +type fileSegment struct { + Switch string `yaml:"switch"` + CIDR string `yaml:"cidr"` + MTU int `yaml:"mtu"` +} + +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"` +} + +type file struct { + Name string `yaml:"name"` + Images map[string]fileImage `yaml:"images"` + Segments map[string]fileSegment `yaml:"segments"` + Nodes map[string]fileNode `yaml:"nodes"` +} + +func Load(path string) (*Topology, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + t, err := Parse(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return t, nil +} + +func Parse(data []byte) (*Topology, error) { + var f file + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + order, err := keyOrder(&root) + if err != nil { + return nil, err + } + + t := &Topology{Name: f.Name} + for _, name := range order["images"] { + i := f.Images[name] + t.Images = append(t.Images, Image{Name: name, URL: i.URL, Sums: i.Sums}) + } + for _, name := range order["segments"] { + s := f.Segments[name] + mtu := s.MTU + if mtu == 0 { + mtu = DefaultMTU + } + t.Segments = append(t.Segments, Segment{Name: name, Switch: s.Switch, CIDR: s.CIDR, MTU: mtu}) + } + for _, name := range order["nodes"] { + n := f.Nodes[name] + t.Nodes = append(t.Nodes, Node{ + Name: name, + Role: n.Role, + Image: n.Image, + CPUs: n.CPUs, + Memory: n.Memory, + Segments: n.Segments, + Addresses: n.Addresses, + }) + } + return t, nil +} + +func keyOrder(root *yaml.Node) (map[string][]string, error) { + order := map[string][]string{} + if root.Kind != yaml.DocumentNode || len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("yaml: the document must be a mapping") + } + top := root.Content[0] + for i := 0; i+1 < len(top.Content); i += 2 { + section := top.Content[i].Value + value := top.Content[i+1] + if value.Kind != yaml.MappingNode { + continue + } + for j := 0; j+1 < len(value.Content); j += 2 { + order[section] = append(order[section], value.Content[j].Value) + } + } + return order, nil +} diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go new file mode 100644 index 0000000..e59b189 --- /dev/null +++ b/internal/lab/topology/topology_test.go @@ -0,0 +1,172 @@ +package topology + +import ( + "strings" + "testing" +) + +const header = `name: lab-test +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +` + +func parse(t *testing.T, doc string) *Topology { + t.Helper() + topo, err := Parse([]byte(doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return topo +} + +func validationError(t *testing.T, doc string) string { + t.Helper() + err := parse(t, doc).Validate() + if err == nil { + t.Fatalf("Validate accepted an invalid topology:\n%s", doc) + } + return err.Error() +} + +func requireContains(t *testing.T, got, want string) { + t.Helper() + if !strings.Contains(got, want) { + t.Fatalf("error does not mention %q:\n%s", want, got) + } +} + +func TestParse_KeepsDeclarationOrderOfNodesAndSegments(t *testing.T) { + topo := parse(t, header+` +segments: + zulu: { switch: sw, cidr: 10.0.1.0/24 } + alpha: { switch: sw, cidr: 10.0.2.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu, alpha] } + beta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu] } +`) + var nodes, segments []string + for _, n := range topo.Nodes { + nodes = append(nodes, n.Name) + } + for _, s := range topo.Segments { + segments = append(segments, s.Name) + } + if got := strings.Join(nodes, ","); got != "sw,zeta,beta" { + t.Errorf("node order = %s, want sw,zeta,beta", got) + } + if got := strings.Join(segments, ","); got != "zulu,alpha" { + t.Errorf("segment order = %s, want zulu,alpha", got) + } +} + +func TestParse_DefaultsMTUTo9000(t *testing.T) { + topo := parse(t, header+` +segments: + under: { switch: sw, cidr: 10.0.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } +`) + if topo.Segments[0].MTU != 9000 { + t.Errorf("MTU = %d, want 9000", topo.Segments[0].MTU) + } +} + +func TestParse_RejectsUnknownField(t *testing.T) { + _, err := Parse([]byte(header + ` +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512, ram: 4 } +`)) + if err == nil || !strings.Contains(err.Error(), "ram") { + t.Fatalf("unknown field not rejected: %v", err) + } +} + +func TestParse_RejectsDuplicateNode(t *testing.T) { + _, err := Parse([]byte(header + ` +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + sw: { role: rr, image: deb, cpus: 1, memory: 512 } +`)) + if err == nil || !strings.Contains(err.Error(), "already defined") { + t.Fatalf("duplicate node not rejected: %v", err) + } +} + +func TestParse_RejectsNonMappingDocument(t *testing.T) { + if _, err := Parse([]byte("- a\n- b\n")); err == nil { + t.Fatal("a list document was accepted") + } +} + +func TestLoad_ReportsPathOnError(t *testing.T) { + if _, err := Load("/nonexistent/lab.yml"); err == nil { + t.Fatal("missing file accepted") + } +} + +const valid = header + ` +segments: + under: { switch: sw, cidr: 10.0.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } +` + +func TestValidate_AcceptsMinimalTopology(t *testing.T) { + if err := parse(t, valid).Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +func TestValidate_Rejections(t *testing.T) { + cases := []struct { + name, doc, want string + }{ + {"bad lab name", strings.Replace(valid, "name: lab-test", "name: Lab_Test", 1), `name "Lab_Test"`}, + {"image url not https", strings.Replace(valid, "url: https://", "url: http://", 1), "image deb: url"}, + {"image sums not https", strings.Replace(valid, "sums: https://", "sums: ftp://", 1), "image deb: sums"}, + {"no node", header + "\nsegments: {}\nnodes: {}\n", "at least one node"}, + {"segment switch missing", strings.Replace(valid, "switch: sw, cidr", "cidr", 1), "segment under: switch is required"}, + {"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 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"}, + {"cidr unparsable", strings.Replace(valid, "10.0.0.0/24", "10.0.0/24", 1), `cidr "10.0.0/24"`}, + {"cidr ipv6", strings.Replace(valid, "10.0.0.0/24", "fd00::/64", 1), "is not IPv4"}, + {"cidr host bits", strings.Replace(valid, "10.0.0.0/24", "10.0.0.5/24", 1), "network is 10.0.0.0/24"}, + {"cidr too small", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/31", 1), "prefix length out of range"}, + {"cidr too large", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/7", 1), "prefix length out of range"}, + {"bad node name", strings.Replace(valid, " rr: {", " RR: {", 1), "node RR: name must match"}, + {"bad role", strings.Replace(valid, "role: rr", "role: router", 1), `role "router"`}, + {"unknown image", strings.Replace(valid, "role: rr, image: deb", "role: rr, image: ubuntu", 1), `image "ubuntu" is not declared`}, + {"no cpu", strings.Replace(valid, "role: rr, image: deb, cpus: 1", "role: rr, image: deb, cpus: 0", 1), "node rr: cpus must be at least 1"}, + {"memory too low", strings.Replace(valid, "cpus: 1, memory: 512, segments", "cpus: 1, memory: 255, segments", 1), "node rr: memory must be at least 256"}, + {"switch with segments", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, segments: [under] }", 1), "node sw: a switch carries its segments"}, + {"switch with addresses", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, addresses: {under: 10.0.0.9} }", 1), "node sw: a switch carries its segments"}, + {"switch without segment", valid + " sw2: { role: switch, image: deb, cpus: 1, memory: 512 }\n", "node sw2: switch carries no segment"}, + {"node without segment", valid + " rr2: { role: rr, image: deb, cpus: 1, memory: 512 }\n", "node rr2: must be attached to at least one segment"}, + {"node on unknown segment", strings.Replace(valid, "segments: [under]", "segments: [over]", 1), "node rr: segment over is not declared"}, + {"node on segment twice", strings.Replace(valid, "segments: [under]", "segments: [under, under]", 1), "node rr: segment under listed twice"}, + {"segment without node", strings.Replace(valid, " under: { switch: sw, cidr: 10.0.0.0/24 }", " under: { switch: sw, cidr: 10.0.0.0/24 }\n empty: { switch: sw, cidr: 10.0.9.0/24 }", 1), "segment empty: no node is attached"}, + {"address on foreign segment", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {over: 10.0.0.9}", 1), "address given for segment over"}, + {"address unparsable", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {under: 10.0.0}", 1), `address "10.0.0" on under`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + requireContains(t, validationError(t, c.doc), c.want) + }) + } +} + +func TestValidate_ReportsEveryErrorAtOnce(t *testing.T) { + doc := strings.Replace(valid, "role: rr", "role: router", 1) + doc = strings.Replace(doc, "10.0.0.0/24", "10.0.0.0/31", 1) + got := validationError(t, doc) + requireContains(t, got, `role "router"`) + requireContains(t, got, "prefix length out of range") +} diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go new file mode 100644 index 0000000..8a5abf9 --- /dev/null +++ b/internal/lab/topology/validate.go @@ -0,0 +1,170 @@ +package topology + +import ( + "errors" + "fmt" + "net/netip" + "regexp" + "sort" + "strings" +) + +const ( + MinMTU = 1280 + MaxMTU = 9000 + MinMemory = 256 + MaxPrefix = 30 + MinPrefix = 8 +) + +var ( + namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) + segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`) +) + +func (t *Topology) Validate() error { + var errs []error + add := func(format string, args ...any) { + errs = append(errs, fmt.Errorf(format, args...)) + } + + if !namePattern.MatchString(t.Name) { + add("name %q: must match %s", t.Name, namePattern) + } + + images := map[string]bool{} + for _, i := range t.Images { + images[i.Name] = true + if !strings.HasPrefix(i.URL, "https://") { + add("image %s: url must be an https:// URL", i.Name) + } + if !strings.HasPrefix(i.Sums, "https://") { + add("image %s: sums must be an https:// URL", i.Name) + } + } + + nodes := map[string]Node{} + for _, n := range t.Nodes { + nodes[n.Name] = n + } + if len(t.Nodes) == 0 { + add("nodes: at least one node is required") + } + if len(t.Segments) > maxSegments { + add("segments: %d declared, at most %d", len(t.Segments), maxSegments) + } + if len(t.Nodes) > maxNodes { + add("nodes: %d declared, at most %d", len(t.Nodes), maxNodes) + } + + segments := map[string]Segment{} + for _, s := range t.Segments { + segments[s.Name] = s + if !segmentPattern.MatchString(s.Name) { + add("segment %s: name must match %s", s.Name, segmentPattern) + } + sw, ok := nodes[s.Switch] + switch { + case s.Switch == "": + add("segment %s: switch is required", s.Name) + case !ok: + add("segment %s: switch %s is not a declared node", s.Name, s.Switch) + case sw.Role != RoleSwitch: + add("segment %s: %s is a %s, not a switch", s.Name, s.Switch, sw.Role) + } + if s.MTU < MinMTU || s.MTU > MaxMTU { + add("segment %s: mtu %d out of range [%d, %d]", s.Name, s.MTU, MinMTU, MaxMTU) + } + prefix, err := netip.ParsePrefix(s.CIDR) + switch { + case err != nil: + add("segment %s: cidr %q: %v", s.Name, s.CIDR, err) + case !prefix.Addr().Is4(): + add("segment %s: cidr %s is not IPv4", s.Name, s.CIDR) + case prefix.Masked() != prefix: + add("segment %s: cidr %s has host bits set, network is %s", s.Name, s.CIDR, prefix.Masked()) + case prefix.Bits() < MinPrefix || prefix.Bits() > MaxPrefix: + add("segment %s: cidr %s prefix length out of range [/%d, /%d]", s.Name, s.CIDR, MinPrefix, MaxPrefix) + } + } + + attached := map[string]int{} + hosting := map[string]int{} + for _, s := range t.Segments { + hosting[s.Switch]++ + } + for _, n := range t.Nodes { + if !namePattern.MatchString(n.Name) { + add("node %s: name must match %s", n.Name, namePattern) + } + switch n.Role { + case RoleSwitch, RoleRR, RoleHypervisor: + default: + add("node %s: role %q must be one of %s, %s, %s", n.Name, n.Role, RoleSwitch, RoleRR, RoleHypervisor) + } + if !images[n.Image] { + add("node %s: image %q is not declared", n.Name, n.Image) + } + if n.CPUs < 1 { + add("node %s: cpus must be at least 1", n.Name) + } + if n.Memory < MinMemory { + add("node %s: memory must be at least %d MiB", n.Name, MinMemory) + } + 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) + } + if hosting[n.Name] == 0 { + add("node %s: switch carries no segment", n.Name) + } + continue + } + if len(n.Segments) == 0 { + add("node %s: must be attached to at least one segment", n.Name) + } + seen := map[string]bool{} + for _, name := range n.Segments { + if seen[name] { + add("node %s: segment %s listed twice", n.Name, name) + continue + } + seen[name] = true + if _, ok := segments[name]; !ok { + add("node %s: segment %s is not declared", n.Name, name) + continue + } + attached[name]++ + } + for _, name := range sortedKeys(n.Addresses) { + address := n.Addresses[name] + if !seen[name] { + add("node %s: address given for segment %s it is not attached to", n.Name, name) + continue + } + if _, err := netip.ParseAddr(address); err != nil { + add("node %s: address %q on %s: %v", n.Name, address, name, err) + } + } + } + + for _, s := range t.Segments { + if attached[s.Name] == 0 { + add("segment %s: no node is attached", s.Name) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +}