two/internal/lab/topology/plan_test.go
GnomeZworc 4b176c22d7
f-50: lab: image vérifiée, disques et seeds des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:46 +02:00

359 lines
12 KiB
Go

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_CarriesTheDeclaredImages(t *testing.T) {
p := compute(t, twoHypervisors)
want := []Image{{Name: "deb", URL: "https://example.invalid/deb.qcow2", Sums: "https://example.invalid/SHA512SUMS"}}
if !reflect.DeepEqual(p.Images, want) {
t.Errorf("images = %+v, want %+v", p.Images, want)
}
}
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")
}