f-50: lab: topologie et calcul du plan #50

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-10-04 00:49:21 +02:00
commit 4d0fc7e18d
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
11 changed files with 1310 additions and 4 deletions

50
cmd/lab/main.go Normal file
View file

@ -0,0 +1,50 @@
package main
import (
"fmt"
"io"
"os"
"git.g3e.fr/syonad/two/internal/lab/topology"
)
const usage = `usage: lab <command> <topology.yml>
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
}

72
cmd/lab/main_test.go Normal file
View file

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

18
conf/lab/evpn-2hv.yml Normal file
View file

@ -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] }

View file

@ -13,9 +13,10 @@ comment s'en servir.
.. note:: .. note::
État actuel : seule l'étape **E0** est livrée — le cycle de vie du serveur qui portera le lab, État actuel : étapes **E0** et **E1** livrées — 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 (``scripts/lab-host.sh``), puis la description de la topologie et le calcul de son plan
avec les étapes suivantes, et cette page avec elles. (``lab plan``). Le lancement des VM viendra avec les étapes suivantes, et cette page avec
elles.
Le serveur de lab 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 ``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression
à la charge de l'utilisateur. à 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-<segment>``
* - 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:<nœud>:<nœud>:<segment>:<côté>`` — 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<i>``, 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 Facturation
----------- -----------
@ -240,6 +332,7 @@ Tests
.. code-block:: bash .. code-block:: bash
bash scripts/lab-host_test.sh 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 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, qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets,

2
go.mod
View file

@ -13,6 +13,7 @@ require (
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5 github.com/vishvananda/netns v0.0.5
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.45.0 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/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace 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/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/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect

View file

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

View file

@ -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
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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.<name>.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
}