f-50: lab: topologie et calcul du plan #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
81eb0ebe3b
commit
4d0fc7e18d
11 changed files with 1310 additions and 4 deletions
50
cmd/lab/main.go
Normal file
50
cmd/lab/main.go
Normal 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
72
cmd/lab/main_test.go
Normal 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
18
conf/lab/evpn-2hv.yml
Normal 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] }
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||||
|
|
|
||||||
34
internal/lab/topology/format.go
Normal file
34
internal/lab/topology/format.go
Normal 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()
|
||||||
|
}
|
||||||
195
internal/lab/topology/plan.go
Normal file
195
internal/lab/topology/plan.go
Normal 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
|
||||||
|
}
|
||||||
351
internal/lab/topology/plan_test.go
Normal file
351
internal/lab/topology/plan_test.go
Normal 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")
|
||||||
|
}
|
||||||
151
internal/lab/topology/topology.go
Normal file
151
internal/lab/topology/topology.go
Normal 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
|
||||||
|
}
|
||||||
172
internal/lab/topology/topology_test.go
Normal file
172
internal/lab/topology/topology_test.go
Normal 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")
|
||||||
|
}
|
||||||
170
internal/lab/topology/validate.go
Normal file
170
internal/lab/topology/validate.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue