f-50: lab: topologie et calcul du plan #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
28ce00cfa3
commit
a706e24b1c
11 changed files with 1310 additions and 4 deletions
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