f-50: lab: rôles des nœuds, adresses du lien, loopback et installation de FRR #50

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

View file

@ -3,6 +3,9 @@ package topology
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
)
@ -17,6 +20,20 @@ func (p *Plan) Write(w io.Writer) error {
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)
}
var extras []NodePlan
for _, n := range p.Nodes {
if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" {
extras = append(extras, n)
}
}
if len(extras) > 0 {
fmt.Fprintf(tw, "\nroles\n")
fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\n")
for _, n := range extras {
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)))
}
}
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)
@ -32,3 +49,32 @@ func (p *Plan) Write(w io.Writer) error {
}
return tw.Flush()
}
func loopback(n NodePlan) string {
if !n.Loopback.IsValid() {
return ""
}
return LoopbackInterface + " " + n.Loopback.String()
}
func secondary(n NodePlan) string {
segments := make([]string, 0, len(n.Secondary))
for s := range n.Secondary {
segments = append(segments, s)
}
sort.Strings(segments)
var parts []string
for _, s := range segments {
for _, prefix := range n.Secondary[s] {
parts = append(parts, s+" "+prefix.String())
}
}
return strings.Join(parts, ", ")
}
func orDash(s string) string {
if s == "" || s == "." {
return "-"
}
return s
}

View file

@ -34,12 +34,15 @@ type SegmentPlan struct {
}
type NodePlan struct {
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
Name string
Role string
Image string
CPUs int
Memory int
SSHPort int
Secondary map[string][]netip.Prefix
Loopback netip.Prefix
FRR string
}
type Cable struct {
@ -65,14 +68,27 @@ func Compute(t *Topology) (*Plan, error) {
p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
for i, n := range t.Nodes {
p.Nodes = append(p.Nodes, NodePlan{
node := NodePlan{
Name: n.Name,
Role: n.Role,
Image: n.Image,
CPUs: n.CPUs,
Memory: n.Memory,
SSHPort: SSHBasePort + i,
})
FRR: n.FRR,
}
for segment, raws := range n.Secondary {
for _, raw := range raws {
if node.Secondary == nil {
node.Secondary = map[string][]netip.Prefix{}
}
node.Secondary[segment] = append(node.Secondary[segment], netip.MustParsePrefix(raw))
}
}
if n.Loopback != "" {
node.Loopback = netip.MustParsePrefix(n.Loopback)
}
p.Nodes = append(p.Nodes, node)
}
var errs []error

View file

@ -0,0 +1,163 @@
package topology
import (
"bytes"
"net/netip"
"os"
"path/filepath"
"reflect"
"testing"
)
const withRoles = header + `
segments:
underlay: { switch: sw1, cidr: 192.168.14.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: frr/rr1.conf }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf }
`
func TestCompute_CarriesTheRoleFields(t *testing.T) {
p := compute(t, withRoles)
rr1 := nodeOf(t, p, "rr1")
if !reflect.DeepEqual(rr1.Secondary, map[string][]netip.Prefix{"underlay": {netip.MustParsePrefix("169.254.0.3/28")}}) {
t.Errorf("rr1 secondary = %v", rr1.Secondary)
}
if rr1.Loopback != netip.MustParsePrefix("10.255.255.1/32") {
t.Errorf("rr1 loopback = %v", rr1.Loopback)
}
if rr1.FRR != "frr/rr1.conf" {
t.Errorf("rr1 frr = %q", rr1.FRR)
}
hv1 := nodeOf(t, p, "hv1")
if hv1.Secondary != nil || hv1.Loopback.IsValid() {
t.Errorf("hv1 = %+v, want no secondary and no loopback", hv1)
}
}
func TestLoad_ResolvesFRRPathsAgainstTheTopologyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lab.yml")
if err := os.WriteFile(path, []byte(withRoles), 0o600); err != nil {
t.Fatal(err)
}
topo, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
want := map[string]string{
"sw1": filepath.Join(dir, "frr", "sw1.conf"),
"rr1": filepath.Join(dir, "frr", "rr1.conf"),
"hv1": "/etc/lab/hv1.conf",
}
for _, n := range topo.Nodes {
if n.FRR != want[n.Name] {
t.Errorf("%s frr = %q, want %q", n.Name, n.FRR, want[n.Name])
}
}
}
func TestValidate_RoleFieldRejections(t *testing.T) {
cases := map[string]struct {
node string
want string
}{
"secondary on a segment not attached": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { blue: [169.254.0.3/28] } }`,
"node rr1: secondary address given for segment blue it is not attached to",
},
"secondary without prefix length": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [169.254.0.3] } }`,
`node rr1: secondary address "169.254.0.3" on red`,
},
"secondary in IPv6": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: ["fd00::3/64"] } }`,
"node rr1: secondary address fd00::3/64 on red is not IPv4",
},
"secondary inside the segment": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [10.1.0.9/24] } }`,
"node rr1: secondary address 10.1.0.9/24 is inside segment red (10.1.0.0/24), use addresses instead",
},
"loopback without prefix length": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: 10.255.255.1 }`,
`node rr1: loopback "10.255.255.1"`,
},
"loopback in IPv6": {
`rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: "fd00::1/128" }`,
"node rr1: loopback fd00::1/128 is not IPv4",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
blue: { switch: sw, cidr: 10.2.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue] }
` + c.node + `
`
requireContains(t, validationError(t, doc), c.want)
})
}
}
func TestValidate_SwitchSecondaryOnlyOnItsOwnSegments(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
blue: { switch: other, cidr: 10.2.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512, secondary: { red: [169.254.0.1/28], blue: [169.254.1.1/28] } }
other: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue] }
`
msg := validationError(t, doc)
requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to")
if bytes.Contains([]byte(msg), []byte("segment red")) {
t.Errorf("the switch's own segment was refused:\n%s", msg)
}
}
func TestValidate_LoopbackInterfaceNameIsReserved(t *testing.T) {
doc := header + `
segments:
lo1: { switch: sw, cidr: 10.1.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1] }
`
requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface")
}
func TestWrite_ShowsTheRoles(t *testing.T) {
var buf bytes.Buffer
if err := compute(t, withRoles).Write(&buf); err != nil {
t.Fatalf("Write: %v", err)
}
want := `
roles
name loopback secondary frr
sw1 - underlay 169.254.0.1/28 sw1.conf
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf
hv1 - - hv1.conf
`
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want)
}
}
func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) {
var buf bytes.Buffer
if err := compute(t, twoHypervisors).Write(&buf); err != nil {
t.Fatalf("Write: %v", err)
}
if bytes.Contains(buf.Bytes(), []byte("roles")) {
t.Errorf("plan shows a roles section:\n%s", buf.String())
}
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"go.yaml.in/yaml/v3"
)
@ -45,6 +46,9 @@ type Node struct {
Memory int
Segments []string
Addresses map[string]string
Secondary map[string][]string
Loopback string
FRR string
}
type fileImage struct {
@ -59,12 +63,15 @@ type fileSegment struct {
}
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"`
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"`
Secondary map[string][]string `yaml:"secondary"`
Loopback string `yaml:"loopback"`
FRR string `yaml:"frr"`
}
type file struct {
@ -83,6 +90,11 @@ func Load(path string) (*Topology, error) {
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for i, n := range t.Nodes {
if n.FRR != "" && !filepath.IsAbs(n.FRR) {
t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), n.FRR)
}
}
return t, nil
}
@ -126,6 +138,9 @@ func Parse(data []byte) (*Topology, error) {
Memory: n.Memory,
Segments: n.Segments,
Addresses: n.Addresses,
Secondary: n.Secondary,
Loopback: n.Loopback,
FRR: n.FRR,
})
}
return t, nil

View file

@ -17,6 +17,7 @@ const (
MinPrefix = 8
ReservedInterface = "mgmt0"
LoopbackInterface = "lo1"
)
var (
@ -68,6 +69,9 @@ func (t *Topology) Validate() error {
if s.Name == ReservedInterface {
add("segment %s: name is reserved for the administration interface", s.Name)
}
if s.Name == LoopbackInterface {
add("segment %s: name is reserved for the loopback interface", s.Name)
}
sw, ok := nodes[s.Switch]
switch {
case s.Switch == "":
@ -116,6 +120,7 @@ func (t *Topology) Validate() error {
if n.Memory < MinMemory {
add("node %s: memory must be at least %d MiB", n.Name, MinMemory)
}
validateExtras(n, segments, add)
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)
@ -165,6 +170,53 @@ func (t *Topology) Validate() error {
return nil
}
func validateExtras(n Node, segments map[string]Segment, add func(string, ...any)) {
carried := map[string]bool{}
if n.Role == RoleSwitch {
for name, s := range segments {
if s.Switch == n.Name {
carried[name] = true
}
}
} else {
for _, name := range n.Segments {
carried[name] = true
}
}
names := make([]string, 0, len(n.Secondary))
for name := range n.Secondary {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if !carried[name] {
add("node %s: secondary address given for segment %s it is not attached to", n.Name, name)
continue
}
network, _ := netip.ParsePrefix(segments[name].CIDR)
for _, raw := range n.Secondary[name] {
prefix, err := netip.ParsePrefix(raw)
switch {
case err != nil:
add("node %s: secondary address %q on %s: %v", n.Name, raw, name, err)
case !prefix.Addr().Is4():
add("node %s: secondary address %s on %s is not IPv4", n.Name, raw, name)
case network.IsValid() && network.Contains(prefix.Addr()):
add("node %s: secondary address %s is inside segment %s (%s), use addresses instead", n.Name, raw, name, network)
}
}
}
if n.Loopback != "" {
prefix, err := netip.ParsePrefix(n.Loopback)
switch {
case err != nil:
add("node %s: loopback %q: %v", n.Name, n.Loopback, err)
case !prefix.Addr().Is4():
add("node %s: loopback %s is not IPv4", n.Name, n.Loopback)
}
}
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {