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

@ -72,7 +72,11 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
if err != nil {
return nil, err
}
nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}})
frr, err := ReadFRR(p)
if err != nil {
return nil, err
}
nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr})
if err != nil {
return nil, err
}
@ -84,6 +88,21 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
return nodes, nil
}
func ReadFRR(p *topology.Plan) (map[string]string, error) {
configs := map[string]string{}
for _, n := range p.Nodes {
if n.FRR == "" {
continue
}
data, err := os.ReadFile(n.FRR)
if err != nil {
return nil, fmt.Errorf("node %s: %w", n.Name, err)
}
configs[n.Name] = string(data)
}
return configs, nil
}
func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) {
private := filepath.Join(dir, KeyFile)
if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) {

View file

@ -315,3 +315,73 @@ func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) {
t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err)
}
}
func TestReadFRR_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) {
dir := t.TempDir()
conf := filepath.Join(dir, "rr1.conf")
if err := os.WriteFile(conf, []byte("hostname rr1\n"), 0o600); err != nil {
t.Fatal(err)
}
m := newMirror(t)
p := labPlan(t, m)
p.Nodes[1].FRR = conf
got, err := ReadFRR(p)
if err != nil || !reflect.DeepEqual(got, map[string]string{"rr1": "hostname rr1\n"}) {
t.Errorf("ReadFRR = %q, %v", got, err)
}
}
func TestReadFRR_NamesTheNodeOfAMissingFile(t *testing.T) {
m := newMirror(t)
p := labPlan(t, m)
p.Nodes[2].FRR = filepath.Join(t.TempDir(), "absent.conf")
if _, err := ReadFRR(p); err == nil || !strings.Contains(err.Error(), "node hv1: ") || !strings.Contains(err.Error(), "absent.conf") {
t.Errorf("error = %v", err)
}
}
func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) {
m := newMirror(t)
publish(m, []byte("qcow2 image"))
root := t.TempDir()
conf := filepath.Join(root, "hv1.conf")
if err := os.WriteFile(conf, []byte("hostname hv1\n"), 0o600); err != nil {
t.Fatal(err)
}
p := labPlan(t, m)
p.Nodes[2].FRR = conf
nodes, err := Prepare(context.Background(), p, Options{
RunDir: filepath.Join(root, "run"),
Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
Runner: &fakeRunner{},
})
if err != nil {
t.Fatalf("Prepare: %v", err)
}
data, err := os.ReadFile(filepath.Join(nodes[2].Dir, "user-data"))
if err != nil {
t.Fatal(err)
}
var cfg struct {
Files []struct {
Path string `yaml:"path"`
Content string `yaml:"content"`
} `yaml:"write_files"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
for _, f := range cfg.Files {
if f.Path == "/etc/lab/frr.conf" {
if f.Content != "hostname hv1\n" {
t.Errorf("frr.conf = %q", f.Content)
}
return
}
}
t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data)
}

Binary file not shown.

View file

@ -30,6 +30,7 @@ var Nameservers = []string{"1.1.1.1", "8.8.8.8"}
type Options struct {
RunDir string
AuthorizedKeys []string
FRR map[string]string
}
type Node struct {
@ -45,6 +46,11 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
if err := o.validate(); err != nil {
return nil, err
}
for _, n := range p.Nodes {
if _, ok := o.FRR[n.Name]; n.FRR != "" && !ok {
return nil, fmt.Errorf("node %s: frr configuration %s was not read", n.Name, n.FRR)
}
}
var nodes []Node
for index, n := range p.Nodes {
dir := filepath.Join(o.RunDir, n.Name)
@ -52,7 +58,7 @@ func Render(p *topology.Plan, o Options) ([]Node, error) {
if err != nil {
return nil, err
}
user, err := userData(p, n, o.AuthorizedKeys)
user, err := userData(p, n, o)
if err != nil {
return nil, err
}

View file

@ -0,0 +1,188 @@
package render
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"reflect"
"strings"
"testing"
)
const withRoles = `name: evpn-2hv
images:
deb:
url: https://example.invalid/deb.qcow2
sums: https://example.invalid/SHA512SUMS
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: 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: rr1.conf }
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf }
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
`
var frrConfigs = map[string]string{
"sw1": "hostname sw1\nrouter bgp 65100\n",
"rr1": "hostname rr1\nrouter bgp 65000\n",
"hv1": "hostname hv1\nrouter bgp 64600\n",
}
func renderRoles(t *testing.T) map[string]Node {
t.Helper()
nodes, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
if err != nil {
t.Fatalf("Render: %v", err)
}
out := map[string]Node{}
for _, n := range nodes {
out[n.Name] = n
}
return out
}
func runcmd(t *testing.T, n Node) []string {
t.Helper()
var out []string
for _, c := range user(t, n).Runcmd {
out = append(out, strings.Join(c, " "))
}
return out
}
const frrScriptHead = `#!/bin/sh
set -eu
export DEBIAN_FRONTEND=noninteractive
. /etc/os-release
echo "deb [signed-by=/usr/share/keyrings/frrouting.gpg] https://deb.frrouting.org/frr ${VERSION_CODENAME} frr-stable" > /etc/apt/sources.list.d/frr.list
n=0
until apt-get update -qq --error-on=any; do
n=$((n + 1))
[ "$n" -lt 30 ] || exit 1
sleep 10
done
apt-get install -y -qq --no-install-recommends frr frr-pythontools
sed -i 's/^bgpd=no/bgpd=yes/' /etc/frr/daemons
`
func TestRoles_SecondaryAddressFollowsThePrimaryOnTheNode(t *testing.T) {
doc := network(t, nodeNamed(t, renderRoles(t), "rr1"))
got := iface(t, doc, "underlay").Addresses
if !reflect.DeepEqual(got, []string{"192.168.14.2/24", "169.254.0.3/28"}) {
t.Errorf("rr1 underlay addresses = %v", got)
}
}
func TestRoles_SwitchCarriesItsSecondaryOnTheBridge(t *testing.T) {
script := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "sw1")), "/usr/local/sbin/lab-switch").Content
want := "ip addr replace 192.168.14.1/24 dev br-underlay\nip addr replace 169.254.0.1/28 dev br-underlay\nip link set dev br-underlay up\n"
if !strings.Contains(script, want) {
t.Errorf("lab-switch:\n%s\ndoes not contain:\n%s", script, want)
}
}
func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) {
rr1 := nodeNamed(t, renderRoles(t), "rr1")
cfg := user(t, rr1)
script := fileAt(t, cfg, "/usr/local/sbin/lab-node")
want := "#!/bin/sh\nset -eu\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.255.1/32 dev lo1\nip link set dev lo1 up\n"
if script.Content != want || script.Permissions != "0755" {
t.Errorf("lab-node (%s):\n%s", script.Permissions, script.Content)
}
unit := fileAt(t, cfg, "/etc/systemd/system/lab-node.service").Content
if !strings.Contains(unit, "ExecStart=/usr/local/sbin/lab-node\n") || !strings.Contains(unit, "WantedBy=multi-user.target\n") {
t.Errorf("lab-node.service:\n%s", unit)
}
wantCmds := []string{"systemctl daemon-reload", "systemctl enable --now lab-node.service", "/usr/local/sbin/lab-frr"}
if got := runcmd(t, rr1); !reflect.DeepEqual(got, wantCmds) {
t.Errorf("runcmd = %q, want %q", got, wantCmds)
}
}
func TestRoles_FRRConfigIsWrittenVerbatimAndPrivately(t *testing.T) {
for _, name := range []string{"sw1", "rr1", "hv1"} {
f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), name)), "/etc/lab/frr.conf")
if f.Content != frrConfigs[name] || f.Permissions != "0640" {
t.Errorf("%s frr.conf (%s) = %q", name, f.Permissions, f.Content)
}
}
}
func TestRoles_FRRDaemonsDependOnTheRole(t *testing.T) {
tail := "install -o frr -g frr -m 0640 /etc/lab/frr.conf /etc/frr/frr.conf\nsystemctl restart frr\n"
bfd := "sed -i 's/^bfdd=no/bfdd=yes/' /etc/frr/daemons\n"
nodes := renderRoles(t)
for name, want := range map[string]string{
"sw1": frrScriptHead + bfd + tail,
"rr1": frrScriptHead + bfd + tail,
"hv1": frrScriptHead + tail,
} {
f := fileAt(t, user(t, nodeNamed(t, nodes, name)), "/usr/local/sbin/lab-frr")
if f.Content != want || f.Permissions != "0755" {
t.Errorf("%s lab-frr (%s):\n%s\nwant:\n%s", name, f.Permissions, f.Content, want)
}
}
}
func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) {
f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "hv1")), "/usr/share/keyrings/frrouting.gpg")
if f.Encoding != "b64" || f.Permissions != "0644" {
t.Errorf("key file: encoding %q, permissions %q", f.Encoding, f.Permissions)
}
key, err := base64.StdEncoding.DecodeString(f.Content)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(key)
if got := hex.EncodeToString(sum[:]); got != "bf10935b9296e2ce7c5d9855fa29ef30c35810b0fc4b1f53005494a04a33554d" {
t.Errorf("key sha256 = %s", got)
}
}
func TestRoles_FRRInstallRunsLast(t *testing.T) {
nodes := renderRoles(t)
for name, want := range map[string][]string{
"sw1": {"systemctl daemon-reload", "systemctl enable --now lab-switch.service", "/usr/local/sbin/lab-frr"},
"hv1": {"/usr/local/sbin/lab-frr"},
} {
if got := runcmd(t, nodeNamed(t, nodes, name)); !reflect.DeepEqual(got, want) {
t.Errorf("%s runcmd = %q, want %q", name, got, want)
}
}
}
func TestRoles_NodeWithoutRoleFieldsGetsNothingExtra(t *testing.T) {
cfg := user(t, nodeNamed(t, renderRoles(t), "hv2"))
if len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 {
t.Errorf("hv2 write_files %d, runcmd %q", len(cfg.WriteFiles), cfg.Runcmd)
}
}
func TestRoles_RefusesAnUnreadFRRConfig(t *testing.T) {
configs := map[string]string{"sw1": "x", "rr1": "y"}
_, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: configs})
if err == nil || err.Error() != "node hv1: frr configuration hv1.conf was not read" {
t.Errorf("error = %v", err)
}
}
func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) {
doc := strings.Replace(withRoles, "secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf", "secondary: { underlay: [169.254.0.1/28] }, loopback: 10.255.254.1/32, frr: sw1.conf", 1)
nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
if err != nil {
t.Fatalf("Render: %v", err)
}
var sw1 Node
for _, n := range nodes {
if n.Name == "sw1" {
sw1 = n
}
}
script := fileAt(t, user(t, sw1), "/usr/local/sbin/lab-switch").Content
want := "ip link set dev br-underlay up\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.254.1/32 dev lo1\nip link set dev lo1 up\nnft -f /etc/lab-switch.nft\n"
if !strings.HasSuffix(script, want) {
t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want)
}
}

View file

@ -1,6 +1,8 @@
package render
import (
_ "embed"
"encoding/base64"
"fmt"
"strings"
@ -13,8 +15,21 @@ const (
SwitchScript = "/usr/local/sbin/lab-switch"
SwitchNFT = "/etc/lab-switch.nft"
SwitchUnit = "/etc/systemd/system/lab-switch.service"
NodeScript = "/usr/local/sbin/lab-node"
NodeUnit = "/etc/systemd/system/lab-node.service"
FRRKey = "/usr/share/keyrings/frrouting.gpg"
FRRConfig = "/etc/lab/frr.conf"
FRRScript = "/usr/local/sbin/lab-frr"
FRRSuite = "frr-stable"
FRRRepo = "https://deb.frrouting.org/frr"
FRRPackages = "frr frr-pythontools"
)
//go:embed frrouting.gpg
var frrKey []byte
type metaDoc struct {
InstanceID string `yaml:"instance-id"`
LocalHostname string `yaml:"local-hostname"`
@ -23,6 +38,7 @@ type metaDoc struct {
type writeFile struct {
Path string `yaml:"path"`
Permissions string `yaml:"permissions"`
Encoding string `yaml:"encoding,omitempty"`
Content string `yaml:"content"`
}
@ -68,24 +84,42 @@ func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) {
return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
}
func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, error) {
func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) {
cfg := cloudConfig{
Hostname: n.Name,
SSHPwauth: false,
DisableRoot: true,
SSHAuthorizedKeys: keys,
SSHAuthorizedKeys: o.AuthorizedKeys,
}
if n.Role == topology.RoleSwitch {
switch {
case n.Role == topology.RoleSwitch:
cfg.Packages = []string{"nftables"}
cfg.WriteFiles = []writeFile{
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)},
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n)},
{Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
{Path: SwitchUnit, Permissions: "0644", Content: switchUnit()},
{Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)},
}
cfg.Runcmd = [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "lab-switch.service"},
}
case n.Loopback.IsValid():
cfg.WriteFiles = []writeFile{
{Path: NodeScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + loopbackLines(n)},
{Path: NodeUnit, Permissions: "0644", Content: unit("Lab node: loopback", NodeScript)},
}
cfg.Runcmd = [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "lab-node.service"},
}
}
if n.FRR != "" {
cfg.WriteFiles = append(cfg.WriteFiles,
writeFile{Path: FRRKey, Permissions: "0644", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(frrKey)},
writeFile{Path: FRRConfig, Permissions: "0640", Content: o.FRR[n.Name]},
writeFile{Path: FRRScript, Permissions: "0755", Content: frrScript(n)},
)
cfg.Runcmd = append(cfg.Runcmd, []string{FRRScript})
}
body, err := yaml.Marshal(cfg)
if err != nil {
@ -94,6 +128,35 @@ func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, err
return append([]byte("#cloud-config\n"), body...), nil
}
func frrDaemons(n topology.NodePlan) []string {
if n.Role == topology.RoleHypervisor {
return []string{"bgpd"}
}
return []string{"bgpd", "bfdd"}
}
func frrScript(n topology.NodePlan) string {
var b strings.Builder
b.WriteString("#!/bin/sh\nset -eu\nexport DEBIAN_FRONTEND=noninteractive\n. /etc/os-release\n")
fmt.Fprintf(&b, "echo \"deb [signed-by=%s] %s ${VERSION_CODENAME} %s\" > /etc/apt/sources.list.d/frr.list\n", FRRKey, FRRRepo, FRRSuite)
b.WriteString("n=0\nuntil apt-get update -qq --error-on=any; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n")
fmt.Fprintf(&b, "apt-get install -y -qq --no-install-recommends %s\n", FRRPackages)
for _, d := range frrDaemons(n) {
fmt.Fprintf(&b, "sed -i 's/^%s=no/%s=yes/' /etc/frr/daemons\n", d, d)
}
fmt.Fprintf(&b, "install -o frr -g frr -m 0640 %s /etc/frr/frr.conf\n", FRRConfig)
b.WriteString("systemctl restart frr\n")
return b.String()
}
func loopbackLines(n topology.NodePlan) string {
if !n.Loopback.IsValid() {
return ""
}
return fmt.Sprintf("ip link add %s type dummy 2>/dev/null || true\nip addr replace %s dev %s\nip link set dev %s up\n",
topology.LoopbackInterface, n.Loopback, topology.LoopbackInterface, topology.LoopbackInterface)
}
func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) {
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
admin := ethernet{
@ -121,6 +184,9 @@ func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, er
MTU: c.MTU,
Addresses: []string{c.NodeAddress.String()},
}
for _, prefix := range n.Secondary[c.Segment] {
e.Addresses = append(e.Addresses, prefix.String())
}
if i == 0 {
e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
e.Nameservers = &nameservers{Addresses: Nameservers}
@ -139,7 +205,8 @@ func gatewayOf(p *topology.Plan, segment string) string {
return ""
}
func switchScript(p *topology.Plan, name string) string {
func switchScript(p *topology.Plan, n topology.NodePlan) string {
name := n.Name
var b strings.Builder
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
for _, s := range switchSegments(p, name) {
@ -153,8 +220,12 @@ func switchScript(p *topology.Plan, name string) string {
}
fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU)
fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge)
for _, prefix := range n.Secondary[s.Name] {
fmt.Fprintf(&b, "ip addr replace %s dev %s\n", prefix, s.Bridge)
}
fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge)
}
b.WriteString(loopbackLines(n))
fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
return b.String()
}
@ -175,9 +246,9 @@ table ip lab_nat {
`, strings.Join(networks, ", "), AdminInterface)
}
func switchUnit() string {
func unit(description, script string) string {
return fmt.Sprintf(`[Unit]
Description=Lab switch: bridges, gateways and NAT
Description=%s
Wants=network-online.target
After=network-online.target
@ -188,5 +259,5 @@ ExecStart=%s
[Install]
WantedBy=multi-user.target
`, SwitchScript)
`, description, script)
}

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 {