From 8a04f6f7f677f36a3f9fd3261d5a1b0f3193c93d Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 8 Sep 2026 21:49:33 +0200 Subject: [PATCH 01/25] f-46: dhcpbackend: drive dnsmasq or the built-in server through one interface #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/subnet et internal/vm ne parlent plus à dnsmasq en direct mais à une interface Backend, choisie au démarrage par dhcp.backend. Le défaut restant dnsmasq, un agent.yml de 0.1.0 se comporte à l'identique ; ValidBackend refuse toute autre valeur que dnsmasq ou two. Les entrées ip→mac sont écrites hors du backend : elles font autorité pour les deux, internal/vm/data.go les lisant par GetMACForIP quel que soit le serveur. D'où l'extraction de dhcp.Entries, que GenerateConfig mêlait à l'écriture du fichier dnsmasq. Reservation porte Index et DefaultRoute : dnsmasq en dérive ses tags et n'en pose que sur les interfaces sans route par défaut, le backend two transmet le drapeau tel quel. La notion de tag ne remonte plus jusqu'à internal/vm. Two.ConfigureSubnet sonde get-state avant de pousser la configuration : le job systemd d'une unit Type=simple est done dès le fork, avant que la socket existe. TeardownSubnet arrête l'unit avant de supprimer le .state. Le backend two est testé contre un vrai serveur sur une vraie socket. Onze mutations, toutes détectées. Signed-off-by: GnomeZworc --- internal/dhcp/generate.go | 17 +- internal/dhcp/reservation.go | 8 + internal/dhcpbackend/backend.go | 53 ++++ internal/dhcpbackend/backend_test.go | 99 ++++++++ internal/dhcpbackend/dnsmasq.go | 127 ++++++++++ internal/dhcpbackend/dnsmasq_test.go | 113 +++++++++ internal/dhcpbackend/two.go | 139 ++++++++++ internal/dhcpbackend/two_test.go | 254 +++++++++++++++++++ internal/dispatcher/agent/subnet_commands.go | 6 +- internal/subnet/create.go | 52 ++-- internal/subnet/delete.go | 42 ++- internal/vm/create.go | 65 ++--- internal/vm/delete.go | 53 +--- internal/vm/dhcp.go | 10 + internal/vm/dhcp_test.go | 122 +++++++++ pkg/db/statefile/statefile.go | 8 +- 16 files changed, 1021 insertions(+), 147 deletions(-) create mode 100644 internal/dhcpbackend/backend.go create mode 100644 internal/dhcpbackend/backend_test.go create mode 100644 internal/dhcpbackend/dnsmasq.go create mode 100644 internal/dhcpbackend/dnsmasq_test.go create mode 100644 internal/dhcpbackend/two.go create mode 100644 internal/dhcpbackend/two_test.go create mode 100644 internal/vm/dhcp.go create mode 100644 internal/vm/dhcp_test.go diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index dc125a5..0e5833e 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -29,12 +29,7 @@ func GenerateConfig(c Config) (string, map[string]string, error) { fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name)) fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name)) - entries := make(map[string]string) - i := 0 - for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { - entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) - i++ - } + entries := Entries(c.Network) for _, dir := range []string{c.ConfDir, HostsDir(c.ConfDir, c.Name), OptsDir(c.ConfDir, c.Name)} { if err := os.MkdirAll(dir, 0755); err != nil { @@ -46,6 +41,16 @@ func GenerateConfig(c Config) (string, map[string]string, error) { return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } +func Entries(network *net.IPNet) map[string]string { + entries := make(map[string]string) + i := 0 + for ip := cloneIP(network.IP); network.Contains(ip); incrementIP(ip) { + entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) + i++ + } + return entries +} + func classlessRoutes(c Config) []string { nextHop := c.InterfaceIP.String() diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go index 0cd875c..ab915f5 100644 --- a/internal/dhcp/reservation.go +++ b/internal/dhcp/reservation.go @@ -117,3 +117,11 @@ func RemoveSubnetDirs(confDir, name string) error { } return nil } + +func RemoveConfig(confDir, name string) error { + path := filepath.Join(confDir, name+".conf") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil +} diff --git a/internal/dhcpbackend/backend.go b/internal/dhcpbackend/backend.go new file mode 100644 index 0000000..3c9c909 --- /dev/null +++ b/internal/dhcpbackend/backend.go @@ -0,0 +1,53 @@ +package dhcpbackend + +import ( + "fmt" + "net" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" +) + +type Subnet struct { + Name string + VPC string + Bridge string + Network *net.IPNet + InterfaceIP net.IP + VPCRoute *net.IPNet + DefaultGateway net.IP +} + +func (s Subnet) Instance() string { + return s.VPC + "_" + s.Bridge +} + +type Reservation struct { + Index int + MAC string + IP string + DefaultRoute bool +} + +type Backend interface { + Unit(s Subnet) string + ConfigureSubnet(s Subnet) error + TeardownSubnet(s Subnet) error + SetVM(s Subnet, vmName string, res []Reservation) error + DelVM(s Subnet, vmName string, res []Reservation) error +} + +func New(cfg *configuration.Config) (Backend, error) { + if cfg == nil { + return nil, fmt.Errorf("configuration is required to pick a dhcp backend") + } + if err := configuration.ValidBackend(cfg.DHCP.Backend); err != nil { + return nil, err + } + + switch cfg.DHCP.Backend { + case configuration.BackendTwo: + return Two{}, nil + default: + return Dnsmasq{}, nil + } +} diff --git a/internal/dhcpbackend/backend_test.go b/internal/dhcpbackend/backend_test.go new file mode 100644 index 0000000..bf63b2f --- /dev/null +++ b/internal/dhcpbackend/backend_test.go @@ -0,0 +1,99 @@ +package dhcpbackend + +import ( + "net" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" +) + +func testSubnet(t *testing.T) Subnet { + t.Helper() + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + _, vpcRoute, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + return Subnet{ + Name: "sn-000001", + VPC: "vp-admin", + Bridge: "br-000001", + Network: network, + InterfaceIP: net.ParseIP("10.0.5.1"), + VPCRoute: vpcRoute, + DefaultGateway: net.ParseIP("10.0.5.254"), + } +} + +func configFor(backend string) *configuration.Config { + cfg := &configuration.Config{} + cfg.DHCP.Backend = backend + return cfg +} + +func TestInstance_JoinsVPCAndBridge(t *testing.T) { + if got := testSubnet(t).Instance(); got != "vp-admin_br-000001" { + t.Errorf("Instance = %s, want vp-admin_br-000001", got) + } +} + +func TestNew_DnsmasqIsTheDefault(t *testing.T) { + backend, err := New(configFor(configuration.BackendDnsmasq)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := backend.(Dnsmasq); !ok { + t.Errorf("backend = %T, want Dnsmasq", backend) + } +} + +func TestNew_ReturnsTheTwoBackendWhenAsked(t *testing.T) { + backend, err := New(configFor(configuration.BackendTwo)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := backend.(Two); !ok { + t.Errorf("backend = %T, want Two", backend) + } +} + +func TestNew_RejectsAnUnknownBackend(t *testing.T) { + if _, err := New(configFor("dhcpd")); err == nil { + t.Fatal("an unknown backend must be reported rather than silently defaulted") + } +} + +func TestNew_RejectsAnEmptyBackend(t *testing.T) { + if _, err := New(configFor("")); err == nil { + t.Fatal("an empty backend must be reported: the config default is what fills it") + } +} + +func TestNew_RejectsANilConfig(t *testing.T) { + if _, err := New(nil); err == nil { + t.Fatal("a nil config must be reported") + } +} + +func TestUnit_NamesADistinctUnitPerBackend(t *testing.T) { + s := testSubnet(t) + + if got := (Dnsmasq{}).Unit(s); got != "dnsmasq@vp-admin_br-000001.service" { + t.Errorf("dnsmasq unit = %s", got) + } + if got := (Two{}).Unit(s); got != "dhcp@vp-admin_br-000001.service" { + t.Errorf("two unit = %s", got) + } +} + +func TestTag_IsPerInterfaceNotPerVM(t *testing.T) { + if tag("vm-web", 0) == tag("vm-web", 1) { + t.Error("two interfaces of the same vm must get distinct tags") + } + if got := tag("vm-web", 1); got != "vm-web-1" { + t.Errorf("tag = %s, want vm-web-1", got) + } +} diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go new file mode 100644 index 0000000..c34c4d0 --- /dev/null +++ b/internal/dhcpbackend/dnsmasq.go @@ -0,0 +1,127 @@ +package dhcpbackend + +import ( + "fmt" + + "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/pkg/systemd" +) + +type Dnsmasq struct { + ConfDir string +} + +func (b Dnsmasq) confDir() string { + if b.ConfDir == "" { + return dhcp.DefaultConfDir + } + return b.ConfDir +} + +func tag(vmName string, index int) string { + return fmt.Sprintf("%s-%d", vmName, index) +} + +func (Dnsmasq) Unit(s Subnet) string { + return dhcp.UnitName(s.Instance()) +} + +func (b Dnsmasq) config(s Subnet) dhcp.Config { + return dhcp.Config{ + Network: s.Network, + Name: s.Instance(), + ConfDir: b.confDir(), + InterfaceIP: s.InterfaceIP, + VPCRoute: s.VPCRoute, + DefaultGateway: s.DefaultGateway, + } +} + +func (b Dnsmasq) ConfigureSubnet(s Subnet) error { + if _, _, err := dhcp.GenerateConfig(b.config(s)); err != nil { + return fmt.Errorf("generate dhcp config: %w", err) + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + if err := svc.Start(b.Unit(s)); err != nil { + return fmt.Errorf("start dnsmasq: %w", err) + } + return nil +} + +func (b Dnsmasq) TeardownSubnet(s Subnet) error { + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" { + if err := svc.Stop(unit); err != nil { + return fmt.Errorf("stop dnsmasq: %w", err) + } + } + + if err := dhcp.RemoveConfig(b.confDir(), s.Instance()); err != nil { + return err + } + return dhcp.RemoveSubnetDirs(b.confDir(), s.Instance()) +} + +func (b Dnsmasq) SetVM(s Subnet, vmName string, res []Reservation) error { + instance := s.Instance() + + reservations := make([]dhcp.Reservation, 0, len(res)) + var tags []string + for _, r := range res { + reservations = append(reservations, dhcp.Reservation{ + MAC: r.MAC, IP: r.IP, Tag: tag(vmName, r.Index), + }) + if !r.DefaultRoute { + tags = append(tags, tag(vmName, r.Index)) + } + } + + if err := dhcp.WriteReservations(b.confDir(), instance, vmName, reservations); err != nil { + return fmt.Errorf("write dhcp reservations on %s: %w", instance, err) + } + + options := dhcp.Config{InterfaceIP: s.InterfaceIP, VPCRoute: s.VPCRoute} + if err := dhcp.WriteVMOptions(b.confDir(), instance, vmName, tags, options); err != nil { + return fmt.Errorf("write dhcp options on %s: %w", instance, err) + } + return nil +} + +func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error { + if err := dhcp.RemoveReservations(b.confDir(), s.Instance(), vmName); err != nil { + return err + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + status, err := svc.Status(unit) + if err != nil || status.ActiveState != "active" { + return nil + } + if err := svc.Restart(unit); err != nil { + return fmt.Errorf("restart %s: %w", unit, err) + } + if status, err := svc.Status(unit); err != nil { + return fmt.Errorf("status %s after restart: %w", unit, err) + } else if status.ActiveState != "active" { + return fmt.Errorf("%s is %s after restart", unit, status.ActiveState) + } + return nil +} diff --git a/internal/dhcpbackend/dnsmasq_test.go b/internal/dhcpbackend/dnsmasq_test.go new file mode 100644 index 0000000..1bb2d7c --- /dev/null +++ b/internal/dhcpbackend/dnsmasq_test.go @@ -0,0 +1,113 @@ +package dhcpbackend + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func dnsmasqBackend(t *testing.T) Dnsmasq { + t.Helper() + return Dnsmasq{ConfDir: t.TempDir()} +} + +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(raw) +} + +func TestDnsmasq_ConfDirDefaultsToTheSystemOne(t *testing.T) { + if got := (Dnsmasq{}).confDir(); got != "/etc/dnsmasq.d" { + t.Errorf("confDir = %s, want /etc/dnsmasq.d", got) + } +} + +func TestDnsmasqSetVM_WritesOneReservationPerInterface(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + hosts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".hosts.d", "vm-web")) + for _, want := range []string{"00:22:33:00:00:0a,10.0.5.10,set:vm-web-0", "00:22:33:00:00:0b,10.0.5.11,set:vm-web-1"} { + if !strings.Contains(hosts, want) { + t.Errorf("hosts file missing %q:\n%s", want, hosts) + } + } +} + +func TestDnsmasqSetVM_TagsOnlyTheInterfacesWithoutADefaultRoute(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")) + if strings.Contains(opts, "tag:vm-web-0") { + t.Errorf("the interface carrying the default route must get no override:\n%s", opts) + } + if !strings.Contains(opts, "tag:vm-web-1,3\n") { + t.Errorf("the secondary interface must get a bare option 3:\n%s", opts) + } + if !strings.Contains(opts, "tag:vm-web-1,121,") { + t.Errorf("the secondary interface must get its own option 121:\n%s", opts) + } +} + +func TestDnsmasqSetVM_SecondaryOptionsKeepTheMetadataRoute(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}} + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")) + if !strings.Contains(opts, "169.254.169.254/32,10.0.5.1") { + t.Errorf("overriding option 121 without the metadata route breaks cloud-init:\n%s", opts) + } + if strings.Contains(opts, "0.0.0.0/0") { + t.Errorf("a secondary interface must not receive a default route:\n%s", opts) + } +} + +func TestDnsmasqSetVM_AllInterfacesDefaultRoutedWritesNoOptions(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + path := filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("no options file expected, got %v: the subnet-wide options already carry the default route", err) + } +} + +func TestDnsmasqSetVM_RejectsAVMWithoutReservation(t *testing.T) { + b := dnsmasqBackend(t) + + if err := b.SetVM(testSubnet(t), "vm-web", nil); err == nil { + t.Fatal("a vm with no reservation would get no address: that must be reported") + } +} diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go new file mode 100644 index 0000000..9a2d5b0 --- /dev/null +++ b/internal/dhcpbackend/two.go @@ -0,0 +1,139 @@ +package dhcpbackend + +import ( + "fmt" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + dhcpclient "git.g3e.fr/syonad/two/internal/client/dhcp" + "git.g3e.fr/syonad/two/pkg/db/statefile" + "git.g3e.fr/syonad/two/pkg/systemd" +) + +const ( + readyTimeout = 5 * time.Second + readyPoll = 50 * time.Millisecond +) + +type Two struct { + RunDir string +} + +func (b Two) runDir() string { + if b.RunDir == "" { + return dhcpapi.DefaultRunDir + } + return b.RunDir +} + +func (b Two) Unit(s Subnet) string { + return dhcpapi.Unit(s.Instance()) +} + +func (b Two) client(s Subnet) *dhcpclient.Client { + return dhcpclient.New(dhcpapi.SocketPath(b.runDir(), s.Instance())) +} + +func (b Two) statePath(s Subnet) string { + return dhcpapi.StatePath(b.runDir(), s.Instance()) +} + +func (b Two) waitReady(s Subnet, timeout, poll time.Duration) error { + client := b.client(s) + deadline := time.Now().Add(timeout) + + var err error + for { + if _, _, err = client.GetState(); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("dhcp server for %s did not answer within %s: %w", s.Instance(), timeout, err) + } + time.Sleep(poll) + } +} + +func (b Two) ConfigureSubnet(s Subnet) error { + if err := statefile.Remove(b.statePath(s)); err != nil { + return fmt.Errorf("remove residual state: %w", err) + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + if err := svc.Start(b.Unit(s)); err != nil { + return fmt.Errorf("start dhcp: %w", err) + } + if err := b.waitReady(s, readyTimeout, readyPoll); err != nil { + return err + } + + return b.pushSubnet(s) +} + +func (b Two) pushSubnet(s Subnet) error { + subnet := dhcpapi.Subnet{ + Network: s.Network.String(), + InterfaceIP: s.InterfaceIP.String(), + } + if s.VPCRoute != nil { + subnet.VPCRoute = s.VPCRoute.String() + } + if s.DefaultGateway != nil { + subnet.DefaultGateway = s.DefaultGateway.String() + } + + if err := b.client(s).SetSubnet(subnet); err != nil { + return fmt.Errorf("configure dhcp for %s: %w", s.Instance(), err) + } + return nil +} + +func (b Two) TeardownSubnet(s Subnet) error { + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" { + if err := svc.Stop(unit); err != nil { + return fmt.Errorf("stop dhcp: %w", err) + } + } + + return statefile.Remove(b.statePath(s)) +} + +func (b Two) SetVM(s Subnet, vmName string, res []Reservation) error { + client := b.client(s) + + for _, r := range res { + host := dhcpapi.Host{ + MAC: r.MAC, + IP: r.IP, + VM: vmName, + DefaultRoute: r.DefaultRoute, + } + if err := client.SetHost(host); err != nil { + return fmt.Errorf("reserve %s for vm %s on %s: %w", r.MAC, vmName, s.Instance(), err) + } + } + return nil +} + +func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error { + client := b.client(s) + + for _, r := range res { + if err := client.DelHost(r.MAC); err != nil { + return fmt.Errorf("release %s of vm %s on %s: %w", r.MAC, vmName, s.Instance(), err) + } + } + return nil +} diff --git a/internal/dhcpbackend/two_test.go b/internal/dhcpbackend/two_test.go new file mode 100644 index 0000000..cedbb16 --- /dev/null +++ b/internal/dhcpbackend/two_test.go @@ -0,0 +1,254 @@ +package dhcpbackend + +import ( + "io" + "log/slog" + "net" + "os" + "testing" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func twoBackend(t *testing.T) (Two, Subnet, *dhcpd.Store) { + t.Helper() + + b := Two{RunDir: shortTempDir(t)} + s := testSubnet(t) + + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + return b, s, store +} + +func TestTwo_RunDirDefaultsToTheSharedOne(t *testing.T) { + if got := (Two{}).runDir(); got != dhcpapi.DefaultRunDir { + t.Errorf("runDir = %s, want %s", got, dhcpapi.DefaultRunDir) + } +} + +func TestTwoWaitReady_ReturnsOnceTheServerAnswers(t *testing.T) { + b, s, _ := twoBackend(t) + + if err := b.waitReady(s, time.Second, 10*time.Millisecond); err != nil { + t.Fatalf("waitReady: %v", err) + } +} + +func TestTwoWaitReady_TimesOutWhenNothingListens(t *testing.T) { + b := Two{RunDir: shortTempDir(t)} + s := testSubnet(t) + + start := time.Now() + err := b.waitReady(s, 200*time.Millisecond, 10*time.Millisecond) + if err == nil { + t.Fatal("waitReady must report a server that never came up") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("waitReady took %s, want the 200ms budget to apply", elapsed) + } +} + +func TestTwoPushSubnet_ReachesTheStore(t *testing.T) { + b, s, store := twoBackend(t) + + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + got, configured := store.Subnet() + if !configured { + t.Fatal("the subnet configuration did not reach the server") + } + if !got.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", got.InterfaceIP) + } + if got.VPCRoute == nil || got.VPCRoute.String() != "10.0.0.0/16" { + t.Errorf("vpc route = %v, want 10.0.0.0/16", got.VPCRoute) + } + if !got.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) { + t.Errorf("default gateway = %s, want 10.0.5.254", got.DefaultGateway) + } +} + +func TestTwoPushSubnet_OmitsAnAbsentVPCRouteAndGateway(t *testing.T) { + b, s, store := twoBackend(t) + s.VPCRoute = nil + s.DefaultGateway = nil + + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + got, _ := store.Subnet() + if got.VPCRoute != nil { + t.Errorf("vpc route = %v, want none", got.VPCRoute) + } + if got.DefaultGateway != nil { + t.Errorf("default gateway = %v, want none: a bridge subnet has no gateway of ours", got.DefaultGateway) + } +} + +func TestTwoSetVM_ReservesEveryInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + hosts := store.Hosts() + if len(hosts) != 2 { + t.Fatalf("hosts = %d, want 2", len(hosts)) + } + for _, h := range hosts { + if h.VM != "vm-web" { + t.Errorf("host %s carries vm %q, want vm-web", h.MAC, h.VM) + } + } +} + +func TestTwoSetVM_CarriesTheDefaultRouteFlagPerInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + primary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0a")) + if !known || !primary.DefaultRoute { + t.Errorf("primary interface = %+v, want the default route", primary) + } + secondary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0b")) + if !known || secondary.DefaultRoute { + t.Errorf("secondary interface = %+v, want no default route", secondary) + } +} + +func TestTwoSetVM_IsIdempotent(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + for range 3 { + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + } + if got := len(store.Hosts()); got != 1 { + t.Errorf("hosts = %d, want 1", got) + } +} + +func TestTwoSetVM_RejectsAnInvalidMAC(t *testing.T) { + b, s, _ := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{{Index: 0, MAC: "nope", IP: "10.0.5.10"}} + if err := b.SetVM(s, "vm-web", res); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestTwoDelVM_ReleasesEveryInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + if err := b.DelVM(s, "vm-web", res); err != nil { + t.Fatalf("DelVM: %v", err) + } + + if got := len(store.Hosts()); got != 0 { + t.Errorf("hosts = %d, want 0", got) + } +} + +func TestTwoDelVM_LeavesOtherVMsAlone(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + web := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + db := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", DefaultRoute: true}} + for name, res := range map[string][]Reservation{"vm-web": web, "vm-db": db} { + if err := b.SetVM(s, name, res); err != nil { + t.Fatalf("SetVM %s: %v", name, err) + } + } + + if err := b.DelVM(s, "vm-web", web); err != nil { + t.Fatalf("DelVM: %v", err) + } + + hosts := store.Hosts() + if len(hosts) != 1 || hosts[0].VM != "vm-db" { + t.Errorf("remaining hosts = %+v, want only vm-db", hosts) + } +} + +func TestTwoDelVM_OnAnUnknownMACIsNotAnError(t *testing.T) { + b, s, _ := twoBackend(t) + + res := []Reservation{{Index: 0, MAC: "00:22:33:ff:ff:ff", IP: "10.0.5.99"}} + if err := b.DelVM(s, "vm-gone", res); err != nil { + t.Errorf("releasing an absent reservation must be idempotent, got %v", err) + } +} + +func mustMAC(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 8d71f0c..b2fb5f6 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -84,7 +84,7 @@ func (c CreateSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) e case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second): } } - return subnet.CreateSubnet(db, c.Name) + return subnet.CreateSubnet(db, c.Name, cfg) } type DeleteSubnetCommand struct { @@ -104,8 +104,8 @@ func (c DeleteSubnetCommand) Prepare(db *badger.DB, _ *configuration.Config) err return state.Set(db, c.Key(), state.Deleting) } -func (c DeleteSubnetCommand) Execute(db *badger.DB, _ *configuration.Config) error { - if err := subnet.DeleteSubnet(db, c.Name); err != nil { +func (c DeleteSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) error { + if err := subnet.DeleteSubnet(db, c.Name, cfg); err != nil { return err } current, err := state.Get(db, c.Key()) diff --git a/internal/subnet/create.go b/internal/subnet/create.go index e37b865..1164219 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -3,17 +3,18 @@ package subnet import ( "fmt" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) -func CreateSubnet(db *badger.DB, subnetName string) error { +func CreateSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error { current, err := state.Get(db, "subnet/"+subnetName) if err != nil { return err @@ -27,14 +28,19 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return err } - if err := createSubnet(db, subnetName, d); err != nil { + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + + if err := createSubnet(db, subnetName, d, backend); err != nil { return err } return state.Set(db, "subnet/"+subnetName, state.Running) } -func createSubnet(db *badger.DB, subnetName string, d subnetData) error { +func createSubnet(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { vethE := "v-" + d.subnetID + "-e" vethI := "v-" + d.subnetID + "-i" @@ -110,7 +116,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { } } - return startDHCP(db, subnetName, d) + return startDHCP(db, subnetName, d, backend) } func setupVxlanHost(d subnetData, vethE string) error { @@ -136,35 +142,23 @@ func setupVxlanHost(d subnetData, vethE string) error { return nil } -func startDHCP(db *badger.DB, subnetName string, d subnetData) error { - conf := dhcp.Config{ - Network: d.cidr, - Name: d.vpc + "_" + d.bridge, - ConfDir: dhcp.DefaultConfDir, - InterfaceIP: d.interfaceIP, - } +func startDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway) if err != nil { return err } - conf.DefaultGateway = defaultGateway - conf.VPCRoute = vpcRoute - _, entries, err := dhcp.GenerateConfig(conf) - if err != nil { - return fmt.Errorf("generate dhcp config: %w", err) - } - if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil { + + if err := dhcp.StoreDHCPEntries(db, subnetName, dhcp.Entries(d.cidr)); err != nil { return fmt.Errorf("store dhcp entries: %w", err) } - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil { - return fmt.Errorf("start dnsmasq: %w", err) - } - return nil + return backend.ConfigureSubnet(dhcpbackend.Subnet{ + Name: subnetName, + VPC: d.vpc, + Bridge: d.bridge, + Network: d.cidr, + InterfaceIP: d.interfaceIP, + VPCRoute: vpcRoute, + DefaultGateway: defaultGateway, + }) } diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 7fdf67a..a8f12d0 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -2,21 +2,19 @@ package subnet import ( "fmt" - "os" - "path/filepath" - "git.g3e.fr/syonad/two/internal/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/pkg/db/kv" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) -func DeleteSubnet(db *badger.DB, subnetName string) error { +func DeleteSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error { current, err := state.Get(db, "subnet/"+subnetName) if err != nil { return err @@ -30,7 +28,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return err } - if err := stopDHCP(db, subnetName, d); err != nil { + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + + if err := stopDHCP(db, subnetName, d, backend); err != nil { return err } @@ -50,26 +53,13 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return state.Set(db, "subnet/"+subnetName, state.Deleted) } -func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service" - if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" { - if err := svc.Stop(svcName); err != nil { - return fmt.Errorf("stop dnsmasq: %w", err) - } - } - - if err := os.Remove(filepath.Join(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge+".conf")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove dnsmasq config: %w", err) - } - - if err := dhcp.RemoveSubnetDirs(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge); err != nil { - return fmt.Errorf("remove dnsmasq dirs: %w", err) +func stopDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { + if err := backend.TeardownSubnet(dhcpbackend.Subnet{ + Name: subnetName, + VPC: d.vpc, + Bridge: d.bridge, + }); err != nil { + return err } if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { diff --git a/internal/vm/create.go b/internal/vm/create.go index c976e9c..692cc25 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -8,7 +8,7 @@ import ( "path/filepath" configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" @@ -34,6 +34,11 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } nic := d.primary() + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + for _, n := range d.nics { if err := netif.CreateTap(n.tapID, n.bridge, n.vpcName); err != nil { return fmt.Errorf("create tap of interface %d: %w", n.index, err) @@ -54,7 +59,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("add metadata redirect: %w", err) } - if err := writeDHCPFiles(d, name); err != nil { + if err := writeDHCPFiles(d, name, backend); err != nil { return err } @@ -109,53 +114,37 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Running) } -// writeDHCPFiles écrit, pour chaque subnet touché par la VM, les réservations -// de ses interfaces et les options qui suppriment la route par défaut sur les -// interfaces non primaires. Le subnet de l'interface primaire ne reçoit aucune -// option : les options non taggées du subnet portent déjà la route par défaut. -func writeDHCPFiles(d vmData, name string) error { - type subnetFiles struct { - nic nicData - reservations []dhcp.Reservation - tags []string - } - bySubnet := make(map[string]*subnetFiles) +func dhcpReservations(d vmData) map[string]*subnetReservations { + bySubnet := make(map[string]*subnetReservations) for _, n := range d.nics { - confName := n.vpcName + "_" + n.bridge - if bySubnet[confName] == nil { - bySubnet[confName] = &subnetFiles{nic: n} + key := n.vpcName + "_" + n.bridge + if bySubnet[key] == nil { + bySubnet[key] = &subnetReservations{subnet: dhcpbackend.Subnet{ + Name: n.subnetName, + VPC: n.vpcName, + Bridge: n.bridge, + InterfaceIP: net.ParseIP(n.interfaceIP), + VPCRoute: n.vpcCIDR, + }} } - f := bySubnet[confName] - f.reservations = append(f.reservations, dhcp.Reservation{ - MAC: n.mac, IP: n.ip, Tag: nicTag(name, n.index), + f := bySubnet[key] + f.reservations = append(f.reservations, dhcpbackend.Reservation{ + Index: n.index, MAC: n.mac, IP: n.ip, DefaultRoute: n.primary, }) - if !n.primary { - f.tags = append(f.tags, nicTag(name, n.index)) - } } + return bySubnet +} - for confName, f := range bySubnet { - if err := dhcp.WriteReservations(dhcp.DefaultConfDir, confName, name, f.reservations); err != nil { - return fmt.Errorf("write dhcp reservations on %s: %w", confName, err) - } - if err := dhcp.WriteVMOptions(dhcp.DefaultConfDir, confName, name, f.tags, dhcp.Config{ - InterfaceIP: net.ParseIP(f.nic.interfaceIP), - VPCRoute: f.nic.vpcCIDR, - }); err != nil { - return fmt.Errorf("write dhcp options on %s: %w", confName, err) +func writeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error { + for _, f := range dhcpReservations(d) { + if err := backend.SetVM(f.subnet, name, f.reservations); err != nil { + return err } } return nil } -// nicTag identifie une interface auprès de dnsmasq. Il est par interface et non -// par VM : deux interfaces d'une même VM peuvent partager un subnet, et n'y -// avoir pas le même rôle. -func nicTag(vmName string, index int) string { - return fmt.Sprintf("%s-%d", vmName, index) -} - func copyFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 96f921c..9b2a50c 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -7,14 +7,13 @@ import ( "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/qmp" "git.g3e.fr/syonad/two/internal/state" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) @@ -34,6 +33,11 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } nic := d.primary() + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") if _, err := os.Stat(socketPath); err == nil { @@ -69,7 +73,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } } - if err := removeDHCPFiles(d, name); err != nil { + if err := removeDHCPFiles(d, name, backend); err != nil { return err } @@ -81,52 +85,15 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Deleted) } -// removeDHCPFiles retire les fichiers de la VM dans chaque subnet qu'elle -// touche, puis redémarre les dnsmasq concernés : un fichier ajouté dans un -// dhcp-hostsdir est relu à chaud, un fichier retiré ne l'est pas (vérifié sur -// dnsmasq 2.90). -func removeDHCPFiles(d vmData, name string) error { - seen := make(map[string]bool) - for _, n := range d.nics { - confName := n.vpcName + "_" + n.bridge - if seen[confName] { - continue - } - seen[confName] = true - if err := removeDHCPReservation(confName, name); err != nil { +func removeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error { + for _, f := range dhcpReservations(d) { + if err := backend.DelVM(f.subnet, name, f.reservations); err != nil { return err } } return nil } -func removeDHCPReservation(confName, name string) error { - if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil { - return err - } - - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - unit := dhcp.UnitName(confName) - status, err := svc.Status(unit) - if err != nil || status.ActiveState != "active" { - return nil - } - if err := svc.Restart(unit); err != nil { - return fmt.Errorf("restart %s: %w", unit, err) - } - if status, err := svc.Status(unit); err != nil { - return fmt.Errorf("status %s after restart: %w", unit, err) - } else if status.ActiveState != "active" { - return fmt.Errorf("%s is %s after restart", unit, status.ActiveState) - } - return nil -} - func waitQMPDead(socketPath string, timeout, poll time.Duration) { timer := time.After(timeout) for { diff --git a/internal/vm/dhcp.go b/internal/vm/dhcp.go new file mode 100644 index 0000000..6acc2ae --- /dev/null +++ b/internal/vm/dhcp.go @@ -0,0 +1,10 @@ +package vm + +import ( + "git.g3e.fr/syonad/two/internal/dhcpbackend" +) + +type subnetReservations struct { + subnet dhcpbackend.Subnet + reservations []dhcpbackend.Reservation +} diff --git a/internal/vm/dhcp_test.go b/internal/vm/dhcp_test.go new file mode 100644 index 0000000..6f6e487 --- /dev/null +++ b/internal/vm/dhcp_test.go @@ -0,0 +1,122 @@ +package vm + +import ( + "net" + "testing" +) + +func nic(idx int, subnet, vpc, bridge, ip, mac string, primary bool) nicData { + return nicData{ + index: idx, + subnetName: subnet, + vpcName: vpc, + bridge: bridge, + interfaceIP: "10.0.5.1", + ip: ip, + mac: mac, + primary: primary, + } +} + +func group(t *testing.T, groups map[string]*subnetReservations, key string) *subnetReservations { + t.Helper() + g, ok := groups[key] + if !ok || g == nil { + t.Fatalf("no group %q, got %v", key, keysOf(groups)) + } + return g +} + +func keysOf(groups map[string]*subnetReservations) []string { + keys := make([]string, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + return keys +} + +func TestDHCPReservations_GroupsInterfacesBySubnet(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false), + nic(2, "sn-000002", "vp-admin", "br-000002", "10.0.6.10", "00:22:33:00:00:0c", false), + }} + + got := dhcpReservations(d) + if len(got) != 2 { + t.Fatalf("groups = %d, want one per subnet", len(got)) + } + if n := len(group(t, got, "vp-admin_br-000001").reservations); n != 2 { + t.Errorf("br-000001 carries %d reservations, want 2", n) + } + if n := len(group(t, got, "vp-admin_br-000002").reservations); n != 1 { + t.Errorf("br-000002 carries %d reservations, want 1", n) + } +} + +func TestDHCPReservations_OnlyThePrimaryCarriesTheDefaultRoute(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false), + }} + + res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations + byMAC := map[string]bool{} + for _, r := range res { + byMAC[r.MAC] = r.DefaultRoute + } + if !byMAC["00:22:33:00:00:0a"] { + t.Error("the primary interface must carry the default route") + } + if byMAC["00:22:33:00:00:0b"] { + t.Error("a secondary interface must not carry the default route") + } +} + +func TestDHCPReservations_KeepsTheInterfaceIndex(t *testing.T) { + d := vmData{nics: []nicData{ + nic(3, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + }} + + res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations + if res[0].Index != 3 { + t.Errorf("index = %d, want 3: the dnsmasq tag is derived from it", res[0].Index) + } +} + +func TestDHCPReservations_CarriesTheSubnetIdentity(t *testing.T) { + _, vpcCIDR, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + n := nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true) + n.vpcCIDR = vpcCIDR + + g := group(t, dhcpReservations(vmData{nics: []nicData{n}}), "vp-admin_br-000001") + if g.subnet.Name != "sn-000001" { + t.Errorf("subnet name = %s, want sn-000001", g.subnet.Name) + } + if !g.subnet.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", g.subnet.InterfaceIP) + } + if g.subnet.VPCRoute == nil || g.subnet.VPCRoute.String() != "10.0.0.0/16" { + t.Errorf("vpc route = %v, want 10.0.0.0/16", g.subnet.VPCRoute) + } +} + +func TestDHCPReservations_SameBridgeInTwoVPCsStaysSeparate(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000009", "vp-other", "br-000001", "10.9.5.10", "00:22:33:00:00:0d", false), + }} + + if got := len(dhcpReservations(d)); got != 2 { + t.Errorf("groups = %d, want 2: the vpc is part of the instance identity", got) + } +} + +func TestDHCPReservations_NoNICYieldsNoGroup(t *testing.T) { + if got := len(dhcpReservations(vmData{})); got != 0 { + t.Errorf("groups = %d, want none", got) + } +} diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go index fc62524..0d8ba51 100644 --- a/pkg/db/statefile/statefile.go +++ b/pkg/db/statefile/statefile.go @@ -85,8 +85,12 @@ func (f *File[T]) Save(value T) error { } func (f *File[T]) Remove() error { - if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove %s: %w", f.path, err) + return Remove(f.path) +} + +func Remove(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) } return nil } From a220cce77eb81ce57f34e4ed83e723575953c272 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 9 Sep 2026 21:57:51 +0200 Subject: [PATCH 02/25] f-46: config: fail loudly on an unreadable config file #46 Signed-off-by: GnomeZworc --- internal/config/agent/dhcp_test.go | 26 ++++++++++++++++++++++++++ internal/config/agent/struct.go | 8 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go index 6796e56..50fed1c 100644 --- a/internal/config/agent/dhcp_test.go +++ b/internal/config/agent/dhcp_test.go @@ -54,3 +54,29 @@ func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) { t.Errorf("backend = %q, want two", cfg.DHCP.Backend) } } + +func TestLoadConfig_MalformedFileIsReported(t *testing.T) { + path := writeConfig(t, "--\n\ndhcp:\n backend: two\n") + + if _, err := LoadConfig(path); err == nil { + t.Fatal("an unparseable config must be reported: silently falling back to defaults would run the wrong dhcp backend") + } +} + +func TestLoadConfig_TabIndentedFileIsReported(t *testing.T) { + path := writeConfig(t, "dhcp:\n\tbackend: two\n") + + if _, err := LoadConfig(path); err == nil { + t.Fatal("yaml forbids tabs for indentation: that must surface, not be swallowed") + } +} + +func TestLoadConfig_MissingFileStillFallsBackToDefaults(t *testing.T) { + cfg, err := LoadConfig(filepath.Join(t.TempDir(), "absent.yml")) + if err != nil { + t.Fatalf("an absent file remains valid, only an unreadable one is an error: %v", err) + } + if cfg.DHCP.Backend != BackendDnsmasq { + t.Errorf("backend = %q, want %q", cfg.DHCP.Backend, BackendDnsmasq) + } +} diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index d4afea1..0f84a09 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -1,6 +1,10 @@ package configuration import ( + "errors" + "fmt" + "os" + "github.com/spf13/viper" ) @@ -86,7 +90,9 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("logger.level", "info") v.SetDefault("logger.debug", false) - v.ReadInConfig() + if err := v.ReadInConfig(); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read %s: %w", path, err) + } var cfg Config if err := v.Unmarshal(&cfg); err != nil { From 7d4d856c956ae5f940edf049d48b23fc18e5c4b5 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 9 Sep 2026 22:22:35 +0200 Subject: [PATCH 03/25] f-46: watchdog: reconcile the served dhcp state against the database #46 Signed-off-by: GnomeZworc --- internal/dhcpbackend/dnsmasq.go | 5 + internal/dhcpbackend/two.go | 8 + internal/watchdog/check_dhcp.go | 189 ++++++++++++++ internal/watchdog/check_dhcp_test.go | 348 +++++++++++++++++++++++++ internal/watchdog/check_subnet.go | 28 +- internal/watchdog/check_subnet_test.go | 36 ++- internal/watchdog/helpers_test.go | 7 + internal/watchdog/watchdog.go | 2 +- 8 files changed, 584 insertions(+), 39 deletions(-) create mode 100644 internal/watchdog/check_dhcp.go create mode 100644 internal/watchdog/check_dhcp_test.go diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go index c34c4d0..207ecee 100644 --- a/internal/dhcpbackend/dnsmasq.go +++ b/internal/dhcpbackend/dnsmasq.go @@ -2,6 +2,7 @@ package dhcpbackend import ( "fmt" + "path/filepath" "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/pkg/systemd" @@ -125,3 +126,7 @@ func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error { } return nil } + +func (b Dnsmasq) ConfigPath(s Subnet) string { + return filepath.Join(b.confDir(), s.Instance()+".conf") +} diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go index 9a2d5b0..71a4d37 100644 --- a/internal/dhcpbackend/two.go +++ b/internal/dhcpbackend/two.go @@ -137,3 +137,11 @@ func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error { } return nil } + +func (b Two) StatePath(s Subnet) string { + return b.statePath(s) +} + +func (b Two) State(s Subnet) (dhcpapi.State, string, error) { + return b.client(s).GetState() +} diff --git a/internal/watchdog/check_dhcp.go b/internal/watchdog/check_dhcp.go new file mode 100644 index 0000000..5e03e57 --- /dev/null +++ b/internal/watchdog/check_dhcp.go @@ -0,0 +1,189 @@ +package watchdog + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" + "git.g3e.fr/syonad/two/internal/state" + "git.g3e.fr/syonad/two/internal/watchdog/notify" + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +type configFileReporter interface { + ConfigPath(s dhcpbackend.Subnet) string +} + +type stateReporter interface { + State(s dhcpbackend.Subnet) (dhcpapi.State, string, error) +} + +func expectedHosts(db *badger.DB, subnetName string) ([]dhcpapi.Host, error) { + pairs, err := kv.ListByPrefix(db, prefixVM) + if err != nil { + return nil, fmt.Errorf("listing vms: %w", err) + } + + hosts := make([]dhcpapi.Host, 0) + for _, vmName := range resourceNames(pairs, prefixVM) { + st, err := state.Get(db, prefixVM+vmName) + if err != nil || st != state.Running { + continue + } + + vmHosts, err := expectedVMHosts(db, vmName, subnetName) + if err != nil { + return nil, err + } + hosts = append(hosts, vmHosts...) + } + + dhcpapi.SortHosts(hosts) + return hosts, nil +} + +func expectedVMHosts(db *badger.DB, vmName, subnetName string) ([]dhcpapi.Host, error) { + prefix := prefixVM + vmName + "/nic/" + entries, err := kv.ListByPrefix(db, prefix) + if err != nil { + return nil, fmt.Errorf("listing nics of vm %s: %w", vmName, err) + } + + indexes := make([]int, 0) + for key := range entries { + parts := strings.Split(strings.TrimPrefix(key, prefix), "/") + if len(parts) != 2 || parts[1] != "subnet" { + continue + } + idx, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid nic index %q for vm %s", parts[0], vmName) + } + indexes = append(indexes, idx) + } + sort.Ints(indexes) + + hosts := make([]dhcpapi.Host, 0, len(indexes)) + for _, idx := range indexes { + nic := fmt.Sprintf("%s%d/", prefix, idx) + if entries[nic+"subnet"] != subnetName { + continue + } + + ip := entries[nic+"ip"] + if ip == "" { + return nil, fmt.Errorf("nic %d of vm %s has no ip", idx, vmName) + } + mac, err := dhcp.GetMACForIP(db, subnetName, ip) + if err != nil { + return nil, fmt.Errorf("get mac for ip %s: %w", ip, err) + } + + hosts = append(hosts, dhcpapi.Host{ + MAC: mac, + IP: ip, + VM: vmName, + DefaultRoute: entries[nic+"primary"] == "true", + }) + } + return hosts, nil +} + +func checkDHCP(db *badger.DB, name string, s dhcpbackend.Subnet, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) { + if backend == nil { + return + } + if r, ok := backend.(configFileReporter); ok { + checkDHCPConfigFile(name, r.ConfigPath(s), n) + } + if r, ok := backend.(stateReporter); ok { + checkDHCPState(db, name, s, r, n) + } + checkUnit(kindSubnet, name, backend.Unit(s), u, n) +} + +func checkDHCPConfigFile(name, path string, n notify.Notifier) { + if _, err := os.Stat(path); err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", path, err)) + } +} + +func checkDHCPState(db *badger.DB, name string, s dhcpbackend.Subnet, r stateReporter, n notify.Notifier) { + served, _, err := r.State(s) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp server unreachable: %v", err)) + return + } + if served.Subnet == nil { + n.Notify(kindSubnet, name, "dhcp server has no subnet configuration: it serves nothing") + } + + expected, err := expectedHosts(db, name) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations unreadable in database: %v", err)) + return + } + + expectedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: expected}) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations inconsistent in database: %v", err)) + return + } + servedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: served.Hosts}) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp reservations reported by the server are inconsistent: %v", err)) + return + } + if expectedDigest == servedDigest { + return + } + + for _, gap := range reservationGaps(expected, served.Hosts) { + n.Notify(kindSubnet, name, gap) + } +} + +func reservationGaps(expected, served []dhcpapi.Host) []string { + index := func(hosts []dhcpapi.Host) map[string]dhcpapi.Host { + byMAC := make(map[string]dhcpapi.Host, len(hosts)) + for _, h := range hosts { + if c, err := dhcpapi.CanonicalHost(h); err == nil { + byMAC[c.MAC] = c + } else { + byMAC[h.MAC] = h + } + } + return byMAC + } + + want, got := index(expected), index(served) + + gaps := make([]string, 0) + for mac, h := range want { + s, ok := got[mac] + if !ok { + gaps = append(gaps, fmt.Sprintf("dhcp reservation missing on the server: %s → %s (vm %s)", mac, h.IP, h.VM)) + continue + } + if s.IP != h.IP { + gaps = append(gaps, fmt.Sprintf("dhcp reservation diverges for %s: server serves %s, database says %s (vm %s)", mac, s.IP, h.IP, h.VM)) + } + if s.DefaultRoute != h.DefaultRoute { + gaps = append(gaps, fmt.Sprintf("dhcp default route diverges for %s: server says %t, database says %t (vm %s)", mac, s.DefaultRoute, h.DefaultRoute, h.VM)) + } + } + for mac, h := range got { + if _, ok := want[mac]; !ok { + gaps = append(gaps, fmt.Sprintf("stale dhcp reservation on the server: %s → %s (vm %s)", mac, h.IP, h.VM)) + } + } + sort.Strings(gaps) + return gaps +} diff --git a/internal/watchdog/check_dhcp_test.go b/internal/watchdog/check_dhcp_test.go new file mode 100644 index 0000000..a9c4801 --- /dev/null +++ b/internal/watchdog/check_dhcp_test.go @@ -0,0 +1,348 @@ +package watchdog + +import ( + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "testing" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/internal/state" + + "github.com/dgraph-io/badger/v4" +) + +const ( + testSubnetName = "sn-000001" + testVPC = "vp-admin" + testBridge = "br-000001" +) + +func twoSubnet() dhcpbackend.Subnet { + return dhcpbackend.Subnet{Name: testSubnetName, VPC: testVPC, Bridge: testBridge} +} + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func servedBy(t *testing.T) (dhcpbackend.Two, *dhcpd.Store) { + t.Helper() + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + s := twoSubnet() + + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + if err := store.SetSubnet(dhcpd.SubnetConfig{Network: network, InterfaceIP: net.ParseIP("10.0.5.1")}); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + return b, store +} + +func seedSubnetWithVM(t *testing.T, db *badger.DB, vmName, ip, mac string, primary bool) { + t.Helper() + + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + seedKV(t, db, "subnet/"+testSubnetName+"/dhcp/"+ip, mac) + + if err := state.Set(db, "vm/"+vmName, state.Running); err != nil { + t.Fatalf("state.Set: %v", err) + } + seedKV(t, db, "vm/"+vmName+"/nic/0/subnet", testSubnetName) + seedKV(t, db, "vm/"+vmName+"/nic/0/ip", ip) + seedKV(t, db, "vm/"+vmName+"/nic/0/primary", fmt.Sprintf("%t", primary)) +} + +func TestExpectedHosts_ReadsRunningVMsOnThatSubnet(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 1 { + t.Fatalf("hosts = %+v, want one", hosts) + } + if hosts[0].MAC != "00:22:33:00:00:0A" || hosts[0].IP != "10.0.5.10" { + t.Errorf("host = %+v, want the mac derived from the address plan", hosts[0]) + } + if hosts[0].VM != "vm-web" || !hosts[0].DefaultRoute { + t.Errorf("host = %+v, want vm-web carrying the default route", hosts[0]) + } +} + +func TestExpectedHosts_IgnoresVMsThatAreNotRunning(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + if err := state.Set(db, "vm/vm-web", state.Deleting); err != nil { + t.Fatalf("state.Set: %v", err) + } + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 0 { + t.Errorf("hosts = %+v, want none: a vm being deleted is not expected to be served", hosts) + } +} + +func TestExpectedHosts_IgnoresInterfacesOnOtherSubnets(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + seedKV(t, db, "vm/vm-web/nic/1/subnet", "sn-000002") + seedKV(t, db, "vm/vm-web/nic/1/ip", "10.0.6.10") + seedKV(t, db, "vm/vm-web/nic/1/primary", "false") + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 1 { + t.Errorf("hosts = %+v, want only the interface on this subnet", hosts) + } +} + +func TestExpectedHosts_SortsByMAC(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-b", "10.0.5.11", "00:22:33:00:00:0B", true) + seedSubnetWithVM(t, db, "vm-a", "10.0.5.10", "00:22:33:00:00:0A", true) + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 2 || hosts[0].MAC != "00:22:33:00:00:0A" { + t.Errorf("hosts = %+v, want sorted by mac", hosts) + } +} + +func TestExpectedHosts_ReportsAnIPWithoutAMAC(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + seedKV(t, db, "vm/vm-web/nic/0/ip", "10.0.5.99") + + if _, err := expectedHosts(db, testSubnetName); err == nil { + t.Fatal("an ip absent from the address plan must be reported, not skipped") + } +} + +func TestCheckDHCPState_MACCaseAloneIsNotADivergence(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 0 { + t.Errorf("dhcp.Entries stores uppercase macs and the server normalizes to lowercase: that alone must not read as drift, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ADivergenceIsReportedOnceNotAsBothMissingAndStale(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 1 { + t.Errorf("notifications = %+v, want a single diverging-reservation report", r.calls) + } + if r.hasProblemContaining("missing on the server") || r.hasProblemContaining("stale dhcp") { + t.Errorf("without mac normalization the same host reads as both missing and stale: %+v", r.calls) + } +} + +func TestCheckDHCPState_SilentWhenServerMatchesDatabase(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 0 { + t.Errorf("notifications = %+v, want none when the server agrees with the database", r.calls) + } +} + +func TestCheckDHCPState_ReportsAReservationTheServerNeverGot(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp reservation missing on the server") { + t.Errorf("a lost set-host order must be reported, got %+v", r.calls) + } + if !r.hasProblemContaining("vm vm-web") { + t.Errorf("the report must name the vm, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAStaleReservation(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-gone", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("stale dhcp reservation on the server") { + t.Errorf("a lost del-host order must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsADivergingIP(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp reservation diverges") { + t.Errorf("a mac served with the wrong address must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsADivergingDefaultRoute(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: false}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp default route diverges") { + t.Errorf("a wrong default route would break multi-subnet routing, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAnUnreachableServer(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp server unreachable") { + t.Errorf("a dead server must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAServerWithNoSubnetConfiguration(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + s := twoSubnet() + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + r := &recorder{} + checkDHCPState(db, testSubnetName, s, b, r) + + if !r.hasProblemContaining("no subnet configuration") { + t.Errorf("a server that was never configured serves nothing, got %+v", r.calls) + } +} + +func TestCheckDHCPConfigFile_ReportsAMissingDnsmasqConfig(t *testing.T) { + r := &recorder{} + checkDHCPConfigFile(testSubnetName, filepath.Join(t.TempDir(), "absent.conf"), r) + + if !r.hasProblemContaining("dnsmasq config missing") { + t.Errorf("notifications = %+v, want the missing config reported", r.calls) + } +} + +func TestCheckDHCP_WithoutABackendChecksNothing(t *testing.T) { + db := newTestDB(t) + r := &recorder{} + + checkDHCP(db, testSubnetName, twoSubnet(), nil, nil, r) + + if len(r.calls) != 0 { + t.Errorf("notifications = %+v, want none: the caller already reported the unusable backend", r.calls) + } +} diff --git a/internal/watchdog/check_subnet.go b/internal/watchdog/check_subnet.go index fafb2ff..99dac17 100644 --- a/internal/watchdog/check_subnet.go +++ b/internal/watchdog/check_subnet.go @@ -2,12 +2,11 @@ package watchdog import ( "fmt" - "os" - "path/filepath" "strconv" "strings" - "git.g3e.fr/syonad/two/internal/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/internal/watchdog/notify" @@ -30,16 +29,14 @@ func subnetIfaceNames(subnetName string) (hostVeth, nsVeth, bridge string, err e return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil } -func dnsmasqName(vpc, bridge string) string { - return vpc + "_" + bridge -} - -func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error { +func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error { pairs, err := kv.ListByPrefix(db, prefixSubnet) if err != nil { return fmt.Errorf("watchdog: listing subnets: %w", err) } + backend, backendErr := dhcpbackend.New(cfg) + for _, name := range resourceNames(pairs, prefixSubnet) { st, err := state.Get(db, prefixSubnet+name) if err != nil { @@ -49,12 +46,15 @@ func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error { if st != state.Running { continue } - checkSubnet(db, name, u, n) + checkSubnet(db, name, backend, u, n) + if backendErr != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr)) + } } return nil } -func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) { +func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) { hostVeth, nsVeth, bridge, err := subnetIfaceNames(name) if err != nil { n.Notify(kindSubnet, name, err.Error()) @@ -90,13 +90,7 @@ func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) { checkSubnetNetns(name, vpc, nsVeth, bridge, n) - dnsName := dnsmasqName(vpc, bridge) - conf := filepath.Join(dhcp.DefaultConfDir, dnsName+".conf") - if _, err := os.Stat(conf); err != nil { - n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", conf, err)) - } - - checkUnit(kindSubnet, name, "dnsmasq@"+dnsName+".service", u, n) + checkDHCP(db, name, dhcpbackend.Subnet{Name: name, VPC: vpc, Bridge: bridge}, backend, u, n) } func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) { diff --git a/internal/watchdog/check_subnet_test.go b/internal/watchdog/check_subnet_test.go index 6928e6f..ec4acd4 100644 --- a/internal/watchdog/check_subnet_test.go +++ b/internal/watchdog/check_subnet_test.go @@ -39,17 +39,11 @@ func TestSubnetIfaceNames_TiretFinal(t *testing.T) { } } -func TestDnsmasqName(t *testing.T) { - if got := dnsmasqName("vp-admin", "br-000000"); got != "vp-admin_br-000000" { - t.Errorf("dnsmasqName = %q, attendu vp-admin_br-000000", got) - } -} - func TestCheckSubnets_BaseVide(t *testing.T) { db := newTestDB(t) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } if len(r.calls) != 0 { @@ -64,7 +58,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) { } r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } if len(r.calls) != 0 { @@ -77,7 +71,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) { seedResource(t, db, prefixSubnet, "br-000042", state.Running) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -96,7 +90,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -110,7 +104,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", "macvlan") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -124,7 +118,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -143,7 +137,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -158,7 +152,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -173,7 +167,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -187,7 +181,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -205,7 +199,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) { u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service") r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -223,7 +217,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) { u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed") r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -238,7 +232,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) { u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible")) r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -252,7 +246,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, nil, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), nil, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -267,7 +261,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err) } diff --git a/internal/watchdog/helpers_test.go b/internal/watchdog/helpers_test.go index 891eeba..84e9c79 100644 --- a/internal/watchdog/helpers_test.go +++ b/internal/watchdog/helpers_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/internal/watchdog/notify" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -108,3 +109,9 @@ func seedResource(t *testing.T, db *badger.DB, prefix, name string, s state.Stat t.Fatalf("seedResource %s%s: %v", prefix, name, err) } } + +func dnsmasqConfig() *configuration.Config { + cfg := &configuration.Config{} + cfg.DHCP.Backend = configuration.BackendDnsmasq + return cfg +} diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go index 3ab1d52..6da33b8 100644 --- a/internal/watchdog/watchdog.go +++ b/internal/watchdog/watchdog.go @@ -65,7 +65,7 @@ func (w *Watchdog) tick() { if err := CheckVPCs(w.db, w.notifier); err != nil { w.logger.Error("watchdog: vpc check failed", "err", err) } - if err := CheckSubnets(w.db, u, w.notifier); err != nil { + if err := CheckSubnets(w.db, w.cfg, u, w.notifier); err != nil { w.logger.Error("watchdog: subnet check failed", "err", err) } if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil { From 5b0d1bfa60d625f7f3b1098b6008da1acadf3ba7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 9 Sep 2026 22:38:14 +0200 Subject: [PATCH 04/25] f-46: dhcpapi: always send served on the wire #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Served portait json:",omitempty" : sur un probe dont la MAC n'est pas réservée, false — qui est toute la réponse — disparaissait du fil, et un opérateur lisait un champ absent plutôt qu'une réponse négative. Trouvé en rejouant contre un vrai serveur les exemples de diagnostic écrits pour la documentation. Signed-off-by: GnomeZworc --- internal/api/dhcp/digest_test.go | 12 ++++++++++++ internal/api/dhcp/models.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go index fe5dd29..f8a0510 100644 --- a/internal/api/dhcp/digest_test.go +++ b/internal/api/dhcp/digest_test.go @@ -1,6 +1,8 @@ package dhcpapi import ( + "encoding/json" + "strings" "testing" ) @@ -171,3 +173,13 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) { t.Errorf("hosts = %v, want sorted by mac", got.Hosts) } } + +func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) { + raw, err := json.Marshal(Response{OK: true, Served: false}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(raw), `"served":false`) { + t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw) + } +} diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go index b0268cf..d454194 100644 --- a/internal/api/dhcp/models.go +++ b/internal/api/dhcp/models.go @@ -54,5 +54,5 @@ type Response struct { State *State `json:"state,omitempty"` Digest string `json:"digest,omitempty"` Lease *Lease `json:"lease,omitempty"` - Served bool `json:"served,omitempty"` + Served bool `json:"served"` } From eee15eb68ebcda58dc49d5a4787acd00dda05382 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 9 Sep 2026 22:38:27 +0200 Subject: [PATCH 05/25] f-46: doc: document the dhcp backend and its switch procedure #46 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note de version 0.2.0 (Bael), et reprise des huit pages de docs/ qui parlaient de dnsmasq ou du DHCP. Ajouts de fond : la section Backend DHCP de la page de configuration, avec la procédure de bascule manuelle et l'avertissement qu'elle ne migre rien ; la section du serveur intégré dans les services ; et dans la page de diagnostic comment interroger la socket de contrôle, probe étant le point de départ le plus rapide quand une VM n'obtient pas d'adresse. Le nom de version se déduit du rang, pas du numéro : deuxième release, deuxième nom de codenames.md. Construit avec sphinx-build -W --keep-going, sans avertissement. Signed-off-by: GnomeZworc --- README.md | 2 +- docs/architecture/vue-densemble.rst | 2 +- docs/concepts/vpc-subnet-vm.rst | 3 +- docs/demarrage/installation.rst | 19 ++-- docs/exploitation/api-agent/asynchronisme.rst | 2 +- docs/exploitation/configuration.rst | 76 +++++++++++++++- docs/exploitation/diagnostic.rst | 30 ++++++- docs/exploitation/observabilite.rst | 3 +- docs/exploitation/services.rst | 53 +++++++++-- docs/versions/0.2.0.md | 2 + docs/versions/index.rst | 1 + release_notes/0.2.0.md | 87 +++++++++++++++++++ release_notes/codenames.md | 2 +- 13 files changed, 261 insertions(+), 21 deletions(-) create mode 100644 docs/versions/0.2.0.md create mode 100644 release_notes/0.2.0.md diff --git a/README.md b/README.md index 9439539..20627f4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Options utiles : | `-d` | dry-run : affiche les commandes sans les exécuter | | `-V` | désactiver la vérification des sommes de contrôle | -Un déploiement relève les instances `dnsmasq@` et `metadata@` actives **avant** l'arrêt des +Un déploiement relève les instances `dnsmasq@`, `dhcp@` et `metadata@` actives **avant** l'arrêt des services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer. ## Configuration diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst index b8fb8e8..a0f5c3f 100644 --- a/docs/architecture/vue-densemble.rst +++ b/docs/architecture/vue-densemble.rst @@ -50,7 +50,7 @@ Paquets * - ``internal/vm`` - cycle de vie d'une VM : tap, iptables, metadata, qemu * - ``internal/dhcp`` - - génération des configurations dnsmasq et entrées ip → mac + - plan d'adressage ip → mac, et configurations dnsmasq du backend historique * - ``internal/metadata`` - serveur de metadata cloud-init et ses templates * - ``internal/watchdog`` diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst index ac1e673..6337ea9 100644 --- a/docs/concepts/vpc-subnet-vm.rst +++ b/docs/concepts/vpc-subnet-vm.rst @@ -24,7 +24,8 @@ Subnet ------ Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la -gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests. +gateway vue par les VM. Il fournit aussi le DHCP — dnsmasq ou le serveur intégré selon +``dhcp.backend`` — et les routes annoncées aux guests. ``iface_type`` est une clé **logique** (``vms``, ``internet``, ``admin``…), traduite en nom de bridge physique par la configuration de l'agent. Une clé absente ou inconnue retombe sur diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst index 56c4cda..ed4217a 100644 --- a/docs/demarrage/installation.rst +++ b/docs/demarrage/installation.rst @@ -73,6 +73,10 @@ Ce que fait ``-i`` **masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance dans les netns. +``dnsmasq`` reste installé même avec ``dhcp.backend: two`` : le backend intégré ne le remplace que +pour les subnets créés après la bascule, et le paquet est nécessaire tant qu'un hyperviseur peut +revenir en arrière. Voir :doc:`/exploitation/configuration`. + **Noyau** — chargement de ``br_netfilter``, puis ``net.ipv4.ip_forward = 1`` et ``net.bridge.bridge-nf-call-iptables = 1``. Cette dernière clé est **requise** par la DNAT vers le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init @@ -118,16 +122,21 @@ Binaires installés * - ``db`` - inspection de la base clé-valeur en ligne de commande - ``-conf`` + * - ``dhcp`` + - serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement + avec ``dhcp.backend: two`` + - ``-conf`` -Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir -:doc:`/exploitation/configuration`. +Les quatre partagent le même fichier, ``/etc/two/agent.yml`` — voir +:doc:`/exploitation/configuration`. ``dhcp`` reçoit en plus son bridge et ses deux chemins de +fichiers en paramètres, posés par son script d'enrobage. Mise à jour ----------- -``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les -services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter -les services à la main avant de lancer le script fait perdre cette liste. +``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** +d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles +relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste. Vérifier l'installation ----------------------- diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst index 96b5172..a506d55 100644 --- a/docs/exploitation/api-agent/asynchronisme.rst +++ b/docs/exploitation/api-agent/asynchronisme.rst @@ -18,7 +18,7 @@ Les deux temps d'une requête A->>A: Prepare — valide, écrit "creating" A-->>C: 202 + ressource en creating A->>W: Dispatch (file d'attente) - W->>W: Execute — netns, netif, dnsmasq + W->>W: Execute — netns, netif, dhcp W->>W: état → running (ou error) C->>A: GET /subnets/ A-->>C: 200 + state diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst index 63b29a9..7024527 100644 --- a/docs/exploitation/configuration.rst +++ b/docs/exploitation/configuration.rst @@ -1,12 +1,19 @@ Configuration ============= -Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``, -``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est +Un seul fichier, ``/etc/two/agent.yml``, partagé par les quatre binaires : ``agent -config``, +``metadata -conf``, ``db -conf`` et ``dhcp -conf``. Le fichier de référence commenté est ``conf/agent/config.exemple.yml`` dans le dépôt. Le chargement se fait par **viper** : les clés sont celles ci-dessous, en YAML. +.. warning:: + + Un fichier **absent** est toléré : toutes les valeurs par défaut s'appliquent. Un fichier + **présent mais invalide** fait en revanche échouer le démarrage, volontairement — jusqu'à + la version 0.1.0 il était ignoré en silence, et l'agent tournait alors entièrement sur les + défauts sans le dire. Une tabulation d'indentation ou un ``--`` égaré suffisent. + .. danger:: **L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur @@ -117,6 +124,71 @@ Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paque Debian et Ubuntu). ``uefi_vars_dir`` reçoit une copie inscriptible des variables UEFI par VM, créée au démarrage et supprimée à l'arrêt. +Backend DHCP +------------ + +.. code-block:: yaml + + dhcp: + backend: dnsmasq # ou two + +Choisit qui sert le DHCP des subnets **créés par cet agent** : + +.. list-table:: + :header-rows: 1 + :widths: 14 44 42 + + * - Valeur + - Serveur + - Unit + * - ``dnsmasq`` + - dnsmasq, configuré par fichiers dans ``/etc/dnsmasq.d`` + - ``dnsmasq@_`` + * - ``two`` + - le binaire ``dhcp``, piloté par socket Unix + - ``dhcp@_`` + +Le défaut est ``dnsmasq`` : un fichier de configuration de la 0.1.0, non modifié, se comporte +exactement comme avant. Toute autre valeur que ``dnsmasq`` ou ``two`` fait échouer le démarrage. + +Le répertoire d'exécution du backend ``two`` — ``/run/two/dhcp`` — **n'est pas configurable** : +le script d'enrobage le code en dur, une clé que lui ignorerait serait un mensonge. + +Ce que le backend ``two`` apporte : la configuration DHCP devient modifiable par VM et non plus +seulement par subnet, ce qui permet de n'annoncer la route par défaut que sur **une** interface +d'une VM multi-réseaux. Le watchdog peut en outre interroger le serveur et comparer ce qu'il sert +à ce que la base dit — voir :doc:`diagnostic`. + +Bascule d'un backend à l'autre +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + L'option ne décide que du backend des **nouveaux** subnets. Elle ne migre rien : un subnet + déjà créé continue d'être servi par le serveur qui l'a été. Changer la valeur sans vider + l'hyperviseur laisse l'agent parler à un serveur qui ne tourne pas — les VM existantes + continuent, les nouvelles n'obtiennent pas d'adresse. + +La bascule est **manuelle** et suppose un hyperviseur vide : + +1. Supprimer toutes les VM, puis tous les subnets, puis les VPC. +2. Vérifier qu'il ne reste aucune unit DHCP active et aucun résidu : + + .. code-block:: bash + + systemctl list-units 'dnsmasq@*' 'dhcp@*' + ls /etc/dnsmasq.d/ /run/two/dhcp/ + +3. Modifier ``dhcp.backend`` dans ``/etc/two/agent.yml``. +4. ``systemctl restart agent`` — la valeur est lue au démarrage, pas à chaque commande. +5. Recréer VPC, subnets et VM. +6. Sur la première VM, vérifier l'adresse **et les trois routes** : la route par défaut, la + route vers le CIDR du VPC, et la route ``/32`` vers ``169.254.169.254``. C'est cette + dernière qui conditionne le provisionnement cloud-init. + +Le retour arrière suit la même procédure. Il n'y a pas de bascule à chaud, dans un sens ni dans +l'autre. + Watchdog -------- diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst index 89f7b81..b60200c 100644 --- a/docs/exploitation/diagnostic.rst +++ b/docs/exploitation/diagnostic.rst @@ -25,7 +25,9 @@ partiellement créés subsistent. La VM démarre mais n'a pas d'adresse ------------------------------------ -Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. +Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.backend`` : + +**Backend ``dnsmasq``** .. code-block:: bash @@ -34,7 +36,31 @@ Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. cat /run/dnsmasq-_.leases cat /etc/dnsmasq.d/_.conf -Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non +**Backend ``two``** + +.. code-block:: bash + + systemctl status 'dhcp@_' + journalctl -u 'dhcp@_' -n 50 + + # Ce que le serveur a réellement en mémoire + echo '{"verb":"get-state"}' \ + | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq . + + # Ce qu'il enverrait à une MAC donnée, sans effet de bord + echo '{"verb":"probe","mac":"00:22:33:00:00:0A"}' \ + | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .lease + +``probe`` est le point de départ le plus rapide : il montre l'adresse, le masque, le routeur, les +DNS et les routes classless tels qu'ils partiraient. Une réponse ``"served": false`` signifie que +la MAC n'est pas réservée — l'ordre ``set-host`` n'a jamais atteint le serveur, ou la VM n'a pas +été créée par cet agent. + +Le watchdog signale ces écarts de lui-même, à chaque tick, en comparant l'état servi à la base : +``dhcp reservation missing on the server``, ``stale dhcp reservation``, ``dhcp reservation +diverges``. Regarder ses notifications avant de sonder à la main. + +Si le serveur ne voit passer aucune requête, le problème est en amont : tap absent, bridge non raccordé, VM dans le mauvais netns. La VM a une adresse mais cloud-init n'applique rien diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst index 05392d1..b8f7a4c 100644 --- a/docs/exploitation/observabilite.rst +++ b/docs/exploitation/observabilite.rst @@ -57,7 +57,8 @@ Journaux journalctl -u agent -f journalctl -u 'metadata@i-web' -n 50 - tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log + tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log # backend dnsmasq + journalctl -fu 'dhcp@vp-admin_br-sn000001' # backend two Inspection de la base --------------------- diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst index 4e096cc..146f98c 100644 --- a/docs/exploitation/services.rst +++ b/docs/exploitation/services.rst @@ -1,7 +1,8 @@ Services systemd ================ -Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. +Quatre units, installées sous ``/opt/two/bin`` par ``deploy.sh``. Les deux units DHCP +s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configuration`). .. list-table:: :header-rows: 1 @@ -15,7 +16,10 @@ Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. - processus principal : API, dispatcher, exécution, watchdog * - ``dnsmasq@.service`` - ``_`` - - dnsmasq lancé dans le netns du VPC, un par subnet + - dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq`` + * - ``dhcp@.service`` + - ``_`` + - serveur DHCP intégré, un par subnet — backend ``two`` * - ``metadata@.service`` - ```` - serveur de metadata cloud-init, un par VM @@ -26,14 +30,15 @@ n'y a pas à les démarrer à la main en fonctionnement normal. .. code-block:: bash systemctl status agent - systemctl status 'dnsmasq@vp-admin_br-sn000001' + systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq + systemctl status 'dhcp@vp-admin_br-sn000001' # backend two systemctl status 'metadata@i-web' dnsmasq ------- -Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier -de configuration par subnet, généré par l'agent : +Backend historique. Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq +avec un fichier de configuration par subnet, généré par l'agent : .. list-table:: :widths: 40 60 @@ -50,6 +55,42 @@ de configuration par subnet, généré par l'agent : Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient pas d'adresse. +Serveur DHCP intégré +-------------------- + +Backend ``two``. Le script ``run-dhcp-in-netns.sh`` entre dans le netns puis exécute le binaire +``dhcp``, à qui il passe le bridge à servir et ses deux chemins de fichiers — il ne déduit rien et +ignore le netns dans lequel il tourne : + +.. code-block:: bash + + /opt/two/bin/dhcp -conf /etc/two/agent.yml \ + -interface br-sn000001 \ + -state /run/two/dhcp/vp-admin_br-sn000001.state \ + -socket /run/two/dhcp/vp-admin_br-sn000001.sock + +.. list-table:: + :widths: 40 60 + + * - Socket de contrôle + - ``/run/two/dhcp/_.sock`` + * - État + - ``/run/two/dhcp/_.state`` + * - Journal + - ``journalctl -u 'dhcp@_'`` + +Il n'y a **ni fichier de configuration ni fichier de baux**. L'agent pousse l'état désiré sur la +socket de contrôle : la configuration du subnet à sa création, une réservation par interface à +chaque création ou suppression de VM. Les réservations sont statiques — une MAC inconnue n'obtient +rien, et le serveur reste silencieux plutôt que de répondre par un refus. + +Le fichier d'état **appartient au processus**, qui l'écrit et le relit à son démarrage. L'agent ne +l'écrit jamais ; il le supprime seulement, à la création du subnet pour écarter un résidu et à sa +suppression après avoir arrêté l'unit. Il vit dans ``/run`` parce qu'il n'a aucun sens sans le +netns, qui ne survit pas au redémarrage de l'host. + +Diagnostic : voir :doc:`diagnostic`, qui montre comment interroger la socket. + QEMU n'est pas une unit ----------------------- @@ -86,6 +127,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie. Mise à jour ----------- -``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les +``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste. diff --git a/docs/versions/0.2.0.md b/docs/versions/0.2.0.md new file mode 100644 index 0000000..a8f52a5 --- /dev/null +++ b/docs/versions/0.2.0.md @@ -0,0 +1,2 @@ +```{include} ../../release_notes/0.2.0.md +``` diff --git a/docs/versions/index.rst b/docs/versions/index.rst index 4333ae4..09f40fa 100644 --- a/docs/versions/index.rst +++ b/docs/versions/index.rst @@ -6,6 +6,7 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e .. toctree:: :maxdepth: 1 + 0.2.0 0.1.0 .. include:: ../../release_notes/codenames.md diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md new file mode 100644 index 0000000..5f42489 --- /dev/null +++ b/release_notes/0.2.0.md @@ -0,0 +1,87 @@ +# Bael + +Serveur DHCP intégré, en coexistence avec dnsmasq, et documentation du projet. + +## Fonctionnalités + +**Serveur DHCP intégré** + +- Nouveau binaire `dhcp`, une instance par subnet lancée dans le netns du VPC par l'unit + `dhcp@_`. Il reçoit son bridge et ses deux chemins de fichiers en paramètres : + il ne compose aucun chemin et ignore le netns dans lequel il tourne +- Piloté par l'agent sur une **socket Unix** `/run/two/dhcp/_.sock`, en JSON par + ligne. Ordres idempotents en remplacement intégral : configuration du subnet à sa création, + une réservation par interface à chaque création ou suppression de VM +- **Réservations statiques uniquement**, pas de baux : une MAC inconnue n'obtient rien, et le + serveur reste silencieux plutôt que de répondre par un refus. Aucun `DHCPNAK` n'est émis +- État auto-persisté dans `/run/two/dhcp/_.state`, en écriture atomique et lisible + par le seul `root`. Le fichier appartient au processus, qui le relit à son démarrage ; l'agent + ne l'écrit jamais et se borne à le supprimer — à la création du subnet pour écarter un résidu, + à sa suppression après avoir arrêté l'unit +- La route par défaut est décidée **par interface** et non plus par subnet, ce qui permet de ne + l'annoncer que sur une interface d'une VM multi-réseaux +- L'encodage RFC 3442 de l'option 121 est délégué à `github.com/insomniacslk/dhcp` + +**Coexistence avec dnsmasq** + +- Nouvelle clé `dhcp.backend`, `dnsmasq` ou `two`, qui choisit le serveur des subnets **créés par + cet agent**. Le défaut est `dnsmasq` : un fichier de configuration de la 0.1.0, non modifié, se + comporte exactement comme avant +- La bascule est une **opération manuelle** sur un hyperviseur vide — l'option ne migre rien, un + subnet déjà créé reste servi par le serveur qui l'a été. La procédure est documentée +- Toute valeur autre que `dnsmasq` ou `two` fait échouer le démarrage de l'agent + +**Exploitation** + +- Le watchdog interroge le serveur DHCP intégré et compare les réservations servies à celles que + la base implique. Il nomme ce qui diverge — ordre perdu à la création, ordre perdu à la + suppression, adresse ou route par défaut divergente — et reste en lecture seule +- La socket de contrôle répond à `get-state` et à `probe`, ce dernier montrant sans effet de bord + ce qui serait envoyé à une MAC donnée : adresse, masque, routeur, DNS et routes +- Documentation Sphinx du projet : concepts, architecture, déploiement et exploitation + +## Correctifs + +- **Un fichier de configuration présent mais invalide fait désormais échouer le démarrage.** + Jusqu'en 0.1.0 l'erreur de lecture était ignorée : l'agent tournait alors entièrement sur ses + valeurs par défaut sans le dire, ce qui rendait indétectable une simple tabulation d'indentation +- `probe` annonce explicitement `"served": false` pour une MAC non réservée, au lieu d'omettre le + champ et de le rendre indistinguable d'une réponse tronquée + +## Changements internes + +- Le module passe à **Go 1.25**, exigé par la bibliothèque DHCP retenue. La version de Go du + workflow de build, restée à 1.21 alors que le module en demandait davantage, est alignée +- Nouveau paquet `pkg/db/statefile` : persistance générique d'un état de composant dans un + fichier, en écriture atomique. Badger a été écarté pour cet usage — une instance par subnet + coûterait une memtable de 64 Mio et quatre goroutines de compaction pour environ un kilo-octet + d'état, dans un `tmpfs`, et laisserait un verrou résiduel après un arrêt brutal +- `internal/subnet` et `internal/vm` ne parlent plus à dnsmasq en direct mais à une interface + `Backend` à deux implémentations + +## Périmètre et limites connues + +Celles de la 0.1.0 restent valables, sauf mention contraire ci-dessus. S'y ajoutent : + +- **dnsmasq n'est pas retiré** et reste un paquet requis : le backend intégré ne sert que les + subnets créés après la bascule, et le retour arrière suppose dnsmasq installé +- Pas de DNS, pas de pool dynamique, pas de PXE, pas de DHCPv6 dans le serveur intégré +- La comparaison faite par le watchdog porte sur les **réservations** et non sur la configuration + du subnet : celle-ci dépend de la route par défaut de l'host, dont la lecture au moment du + contrôle produirait de faux écarts. Un serveur dépourvu de configuration est en revanche signalé +- L'option 249 (routes classless de Microsoft) n'est pas émise, comme dnsmasq ne l'émet pas +- Le serveur intégré écoute UDP/67 sans authentification, comme tout serveur DHCP : l'isolation + entre locataires d'un même subnet repose sur les règles ebtables anti-usurpation, inchangées +- `internal/dhcpbackend` n'est testé sous Linux que pour ses appels systemd ; le reste, y compris + le dialogue avec le serveur intégré, est couvert sur toute plateforme + +## Mise à jour depuis la 0.1.0 + +```bash +curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh +bash ./deploy.sh -t 0.2.0 +``` + +Aucune action n'est requise : sans `dhcp.backend` dans `/etc/two/agent.yml`, le comportement est +celui de la 0.1.0. Pour passer au serveur intégré, suivre la procédure de bascule dans la +documentation d'exploitation — elle suppose un hyperviseur vidé. diff --git a/release_notes/codenames.md b/release_notes/codenames.md index 17161c2..fe77557 100644 --- a/release_notes/codenames.md +++ b/release_notes/codenames.md @@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig | # | Nom | Nature | Version | Date | |---|---|---|---|---| | 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 | -| 2 | Bael | démon | 0.2.0 | | +| 2 | Bael | démon | [0.2.0](0.2.0.md) | | | 3 | Gabriel | ange | | | | 4 | Agares | démon | | | | 5 | Raphael | ange | | | From 25895fbb7a5482cb1bf3611476dbdc514d37e9ce Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 9 Sep 2026 22:54:23 +0200 Subject: [PATCH 06/25] ci: build and publish the documentation to the pages branch Signed-off-by: GnomeZworc --- .forgejo/workflows/docs.yml | 168 +++++++++++++++++++++++++++++++ .gitignore | 3 + docs/conf.py | 44 ++++++++ docs/deploiement/image-qcow2.rst | 4 +- 4 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 .forgejo/workflows/docs.yml diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml new file mode 100644 index 0000000..e8b7b07 --- /dev/null +++ b/.forgejo/workflows/docs.yml @@ -0,0 +1,168 @@ +name: Documentation + +# Un tag de release ajoute une version au site : la publication est donc +# déclenchée par les deux, sans filtre de chemin sur les tags — c'est le tag +# lui-même qui est la nouveauté, pas un fichier modifié. +on: + push: + branches: + - main + tags: + - '[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + +# Deux publications simultanées se pousseraient l'une sur l'autre. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + publish: + runs-on: docker + env: + TOKEN: ${{ secrets.RELEASE }} + SITE_DIR: /tmp/site + # Préfixe de chemin sous lequel le site est servi. Vide = racine du + # domaine, ce qui couvre le cas courant et un serveur de test local. + # À renseigner (par exemple /two) seulement si les pages sont publiées + # sous un sous-chemin. Aucun nom d'hôte ici : les liens du menu de + # version sont relatifs à l'origine, donc le site fonctionne à + # l'identique en local et en production. + DOCS_BASE_PATH: '' + steps: + # fetch-depth: 0 — les tags et leur contenu sont nécessaires : chaque + # version est construite depuis son propre ref. + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Installer Sphinx + run: | + apt-get update + apt-get install -y python3 python3-venv git + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet --upgrade pip + /tmp/venv/bin/pip install --quiet -r docs/requirements.txt + + # Les versions publiables : main, plus les tags finaux qui contiennent + # déjà un répertoire docs/. 0.1.0 est antérieure à la documentation et + # n'est donc pas constructible — le filtre l'écarte de lui-même, sans + # liste à maintenir. + - name: Choisir les versions à publier + run: | + versions="" + for tag in $(git tag --sort=-v:refname) + do + case "${tag}" in *rc*) continue ;; esac + if git ls-tree --name-only "${tag}" | grep -qx docs + then + versions="${versions} ${tag}" + else + echo "ignoré : ${tag} n'a pas de docs/" + fi + done + echo "VERSIONS=${versions# }" >> "${GITHUB_ENV}" + echo "versions retenues : main${versions}" + + # main est construite en premier et son échec est fatal : la doc courante + # doit toujours partir. L'échec d'une version figée est signalé mais ne + # bloque pas la publication — une vieille version qui ne se reconstruit + # plus ne doit pas empêcher de publier la doc du jour. + - name: Construire chaque version + run: | + build () { + local ref="$1" src="$2" + DOCS_VERSION="${ref}" \ + /tmp/venv/bin/sphinx-build -b html -W --keep-going \ + -d "/tmp/doctrees-${ref}" "${src}/docs" "${SITE_DIR}/${ref}" + } + + build main . + + for version in ${VERSIONS} + do + rm -rf "/tmp/src-${version}" + git worktree add --quiet --detach "/tmp/src-${version}" "${version}" + if build "${version}" "/tmp/src-${version}" + then + echo "construit : ${version}" + else + echo "::warning::la version ${version} ne se construit plus, elle est absente du site" + rm -rf "${SITE_DIR}/${version}" + fi + git worktree remove --force "/tmp/src-${version}" + done + + # Les liens du menu de version sont relatifs à l'origine : le thème les + # concatène au chemin de la page courante avant de les poser en href, si + # bien qu'un chemin relatif y serait résolu depuis la page et casserait + # selon sa profondeur. Une barre initiale les ancre à la racine du site, + # sans jamais nommer d'hôte. + - name: Assembler la racine du site + run: | + preferred="$(echo ${VERSIONS} | tr ' ' '\n' | head -1)" + [ -n "${preferred}" ] || preferred="main" + + { + echo '[' + echo ' {"name": "dev (main)", "version": "main", "url": "'"${DOCS_BASE_PATH}"'/main/"},' + first=1 + for version in ${VERSIONS} + do + [ -d "${SITE_DIR}/${version}" ] || continue + [ ${first} -eq 1 ] && suffix=', "preferred": true' || suffix='' + first=0 + echo ' {"name": "'"${version}"'", "version": "'"${version}"'", "url": "'"${DOCS_BASE_PATH}"'/'"${version}"'/"'"${suffix}"'},' + done + } | sed '$ s/,$//' > "${SITE_DIR}/switcher.json" + echo ']' >> "${SITE_DIR}/switcher.json" + + python3 -c "import json,sys; json.load(open('${SITE_DIR}/switcher.json'))" + cat "${SITE_DIR}/switcher.json" + + # La racine ne sert qu'à rediriger : le contenu vit dans les + # sous-répertoires de version. + cat > "${SITE_DIR}/index.html" < + + + + two — documentation + + +

Documentation de two

+ + HTML + + - name: Alléger le site + run: | + find "${SITE_DIR}" -name '*.map' -delete + find "${SITE_DIR}" -name '.buildinfo' -delete + touch "${SITE_DIR}/.nojekyll" + du -sh "${SITE_DIR}" + + - name: Publier sur la branche pages + run: | + cd "${SITE_DIR}" + git init --quiet --initial-branch=pages + git config user.name "forgejo-actions" + git config user.email "forgejo-actions@git.g3e.fr" + git remote add origin "https://${TOKEN}@git.g3e.fr/${{ github.repository }}.git" + + if git fetch --quiet --depth=1 origin pages 2>/dev/null + then + git reset --soft FETCH_HEAD + else + echo "branche pages absente : premier build" + fi + + git add -A + if git diff --cached --quiet + then + echo "site identique au précédent, rien à publier" + exit 0 + fi + + git commit --quiet -m "docs: build de ${GITHUB_SHA}" + git push --quiet origin pages + echo "publié : $(git rev-parse --short HEAD) — $(git ls-files | wc -l) fichiers" diff --git a/.gitignore b/.gitignore index 68ae417..fd7d60e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # ignore local info data/ + +# Sphinx build output +docs/_build/ diff --git a/docs/conf.py b/docs/conf.py index 19d4d9e..e6cdf00 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,6 +3,8 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import os + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -43,6 +45,48 @@ html_theme = 'sphinx_book_theme' html_static_path = [] html_show_sphinx = False +# Le thème publie le source de chaque page dans _sources/ et l'expose derrière +# un bouton de téléchargement. Les deux vont ensemble : couper la copie sans +# couper le bouton laisserait un lien mort vers un répertoire vide. +html_copy_source = False +html_show_sourcelink = False + +# Le sélecteur de version est piloté par le workflow de publication : hors CI +# la variable est absente, le sélecteur n'apparaît pas, et le build ne dépend +# d'aucun réseau. +_docs_version = os.environ.get('DOCS_VERSION') + html_theme_options = { 'home_page_in_toc': True, + 'use_download_button': False, + 'icon_links': [ + { + 'name': 'Dépôt', + 'url': 'https://git.g3e.fr/syonad/two', + 'icon': 'fa-solid fa-code-branch', + 'type': 'fontawesome', + }, + ], } + +if _docs_version: + html_theme_options['switcher'] = { + # Chemin relatif volontairement : le thème le résout contre la racine + # de la version courante, donc toujours dans la même origine que la + # page. Une URL absolue ferait échouer la requête en CORS dès que le + # site est consulté depuis un autre hôte — un serveur de test local, + # par exemple. + 'json_url': '../switcher.json', + 'version_match': _docs_version, + } + # Le thème book vide navbar_start et place tout dans la barre latérale : le + # sélecteur doit donc y être inséré explicitement, à côté du logo. + html_sidebars = { + '**': [ + 'navbar-logo.html', + 'icon-links.html', + 'version-switcher.html', + 'search-button-field.html', + 'sbt-sidebar-nav.html', + ] + } diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index 63ddc28..1283f52 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -182,7 +182,7 @@ disques : le disque de travail et le disque cible ne doivent pas être confondus cd /work curl "${os_link}" -O - qemu-img convert ./*.qcow2 -O raw ${os_disk} + qemu-img convert ./*.qcow2 -O raw "${os_disk}" L'image du fournisseur est écrite **en brut** directement sur le disque cible : le qcow2 obtenu côté host contient donc une image disque complète et amorçable, sans backing file. @@ -194,7 +194,7 @@ côté host contient donc une image disque complète et amorçable, sans backing sleep 2 # La partition racine est la plus grande du disque - root_partition=$(fdisk -lo device,size /dev/sda | grep -E '^\/dev\/' | tr -s ' ' \ + root_partition=$(fdisk -lo device,size "${os_disk}" | grep -E '^/dev/' | tr -s ' ' \ | sort -rhk2 | head -n1 | cut -d ' ' -f1) mount -o nouuid $root_partition /mnt From a73bf976975bba11b0456d32b565fefcceff8906 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 19:56:42 +0200 Subject: [PATCH 07/25] f-51: netif: poser l'adresse VTEP locale des VXLAN #51 Signed-off-by: GnomeZworc --- internal/netif/vxlan.go | 28 ++++++++++++++++ internal/netif/vxlan_test.go | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/netif/vxlan_test.go diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go index 70740bc..0930cb3 100644 --- a/internal/netif/vxlan.go +++ b/internal/netif/vxlan.go @@ -1,14 +1,31 @@ package netif import ( + "fmt" + "net" + "syscall" + "github.com/vishvananda/netlink" ) +const ( + scopeUniverse = 0 + addressSecondary = 0x01 +) + func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { link, err := netlink.LinkByName(localIface) if err != nil { return err } + addrs, err := netlink.AddrList(link, syscall.AF_INET) + if err != nil { + return err + } + local, err := vtepAddress(localIface, addrs) + if err != nil { + return err + } vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, @@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { VxlanId: vxlanID, Port: 4789, VtepDevIndex: link.Attrs().Index, + SrcAddr: local, Learning: false, } return netlink.LinkAdd(vxlan) } + +func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) { + for _, a := range addrs { + if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 { + continue + } + return a.IP.To4(), nil + } + return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface) +} diff --git a/internal/netif/vxlan_test.go b/internal/netif/vxlan_test.go new file mode 100644 index 0000000..6ea7ca4 --- /dev/null +++ b/internal/netif/vxlan_test.go @@ -0,0 +1,64 @@ +package netif + +import ( + "net" + "strings" + "testing" + + "github.com/vishvananda/netlink" +) + +func addr(cidr string, scope, flags int) netlink.Addr { + ip, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + network.IP = ip + return netlink.Addr{IPNet: network, Scope: scope, Flags: flags} +} + +func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) { + addrs := []netlink.Addr{ + addr("127.0.0.1/8", 254, 0), + addr("169.254.0.3/28", 0, 0x01), + addr("192.168.14.11/24", 0, 0x80), + addr("192.168.14.99/24", 0, 0x01), + } + got, err := vtepAddress("br-000000", addrs) + if err != nil || got.String() != "192.168.14.11" { + t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err) + } +} + +func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) { + addrs := []netlink.Addr{ + addr("169.254.0.3/28", 253, 0), + addr("192.168.14.99/24", 0, 0x01), + addr("192.168.14.12/24", 0, 0), + } + got, err := vtepAddress("br-000000", addrs) + if err != nil || got.String() != "192.168.14.12" { + t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err) + } +} + +func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) { + for name, addrs := range map[string][]netlink.Addr{ + "no address": nil, + "only secondary": {addr("192.168.14.99/24", 0, 0x01)}, + "only link": {addr("169.254.0.3/28", 253, 0)}, + "only IPv6": {addr("fd00::11/64", 0, 0)}, + } { + _, err := vtepAddress("br-000000", addrs) + if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") { + t.Errorf("%s: error = %v", name, err) + } + } +} + +func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) { + got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)}) + if err != nil || len(got) != 4 { + t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err) + } +} From 81eb0ebe3be3e2ad4a2c3ebb827882542071d2b4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 00:16:20 +0200 Subject: [PATCH 08/25] f-50: lab: cycle de vie du serveur de lab Scaleway #50 Signed-off-by: GnomeZworc --- docs/developpement/index.rst | 10 + docs/developpement/lab.rst | 247 +++++++++++++ docs/index.rst | 9 + scripts/lab-host.sh | 396 ++++++++++++++++++++ scripts/lab-host_test.sh | 685 +++++++++++++++++++++++++++++++++++ 5 files changed, 1347 insertions(+) create mode 100644 docs/developpement/index.rst create mode 100644 docs/developpement/lab.rst create mode 100644 scripts/lab-host.sh create mode 100644 scripts/lab-host_test.sh diff --git a/docs/developpement/index.rst b/docs/developpement/index.rst new file mode 100644 index 0000000..2d78704 --- /dev/null +++ b/docs/developpement/index.rst @@ -0,0 +1,10 @@ +Développement +============= + +Outillage réservé au développement de two : il ne s'installe sur aucun hyperviseur et ne sert +pas à exploiter un cluster. + +.. toctree:: + :maxdepth: 1 + + lab diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst new file mode 100644 index 0000000..3da14eb --- /dev/null +++ b/docs/developpement/lab.rst @@ -0,0 +1,247 @@ +Lab de test multi-nœud +====================== + +Ce qui fait l'intérêt de two ne se voit qu'à partir de **deux hyperviseurs** : sur un nœud isolé, +le trafic reste sur le bridge local et l'absence de plan de contrôle passe inaperçue (voir +:doc:`/deploiement/architecture-cluster`). Le lab reproduit la topologie du cluster — +hyperviseurs, route reflector, switch L3 — sous forme de VM, sur un serveur physique loué à +l'heure. + +Le pourquoi des choix (serveur physique plutôt que VM cloud, câbles QEMU, MTU 9000, versions) est +consigné sur le ticket `#50 `_. Cette page décrit +comment s'en servir. + +.. note:: + + État actuel : seule l'étape **E0** est livrée — 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 + avec les étapes suivantes, et cette page avec elles. + +Le serveur de lab +----------------- + +Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé. + +.. list-table:: + :widths: 25 75 + + * - Offre + - ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans + frais de mise en service + * - Matériel + - 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD + * - Système + - Debian 12, installé par Scaleway à la création + * - Pourquoi Intel + - lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations + distinctes (``kvm_intel``, ``kvm_amd``) + +*Or equivalent* n'est pas une clause de style : le premier serveur livré était un +**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever +``lscpu`` au début de chaque campagne. + +Prérequis côté Scaleway +----------------------- + +#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime + tout serveur de lab du projet : il ne doit rien y avoir d'autre. +#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH + du projet. Rien d'autre. +#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle, + le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de + continuer. +#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et* + l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation → + Quotas → Elastic Metal. Voir `les quotas Scaleway + `_. + +Fichiers locaux +--------------- + +Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt. + +``~/.config/two-lab/scaleway.env`` + Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun : + + .. code-block:: bash + + SCW_SECRET_KEY= + SCW_DEFAULT_PROJECT_ID= + SCW_DEFAULT_ZONE=fr-par-1 + + * le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par + d'autres que son propriétaire ; + * il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ; + * une variable d'environnement du même nom l'emporte sur le fichier ; + * la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé + secrète, dans l'en-tête ``X-Auth-Token``. + + Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier. + +``~/.config/two-lab/ssh/lab_ed25519`` + Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans + intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le + script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur + l'agent SSH. + + Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur + lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une + autre : + + .. code-block:: bash + + mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh + ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519 + +``~/.cache/two-lab/`` + État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé + par ``down``. + +Commandes +--------- + +.. code-block:: text + + usage: lab-host.sh [arguments] + + plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création + et le prix ; ne crée rien + up crée le serveur de lab, attend la fin de son installation et son SSH + status liste les serveurs de lab du projet + ssh [commande] se connecte au serveur de lab + down supprime tous les serveurs de lab du projet et attend leur disparition + session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive + +``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**. + +Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH, +et montre exactement ce qui serait commandé : + +.. code-block:: text + + $ scripts/lab-host.sh plan + == offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available + == prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR + == os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root + == clés : 2 clé(s) SSH du projet + == requête : POST /baremetal/v1/zones/fr-par-1/servers + +``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande +réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal). + +.. code-block:: text + + $ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm' + == création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT) + == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down' + == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install + == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing + … + == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed + == SSH pas encore joignable, nouvel essai dans 20s + … + == prêt : root@ + Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux + CPU(s): 32 + Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz + … + Virtualization: VT-x + total used free shared buff/cache available + Mem: 251 1 250 0 0 249 + nested=Y + crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm + == session terminée (code 0), suppression du serveur + == suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957 + == aucun serveur de lab ne reste dans le projet + +Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première +campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation — +environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de +``session`` est celui de la commande distante. + +``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression +à la charge de l'utilisateur. + +Facturation +----------- + +.. warning:: + + Un serveur Elastic Metal est facturé **de sa création à sa suppression, éteint compris**. + Éteindre ne suffit pas : il faut supprimer. La granularité n'est pas documentée par Scaleway — + compter chaque heure entamée comme une heure pleine. + +Ce que le script garantit : + +* il ne commande **jamais** d'offre mensuelle : il exige une seule offre au nom demandé, en + facturation horaire, en stock et sans frais de mise en service, sinon il refuse avant toute + création. La CLI ``scw`` n'est pas utilisée pour cette raison : son ``server create type=…`` + choisit l'offre par son seul nom et peut tomber sur la mensuelle, qui engage un mois ; +* il refuse de créer un second serveur si un serveur de lab existe déjà ; +* ``down`` agit sur **tous** les serveurs portant le tag ``two-lab`` dans le projet, et + ``session`` y ajoute l'identifiant reçu à la création : un serveur créé juste avant une + interruption est rattrapé ; +* une suppression refusée pendant la livraison ou l'installation est réessayée tant qu'elle dure, + dans la limite du délai d'installation augmenté du délai de suppression ; +* le serveur n'est déclaré supprimé qu'au 404 de l'API, jamais sur une erreur passagère ; +* un échec de suppression se termine par ``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` et un code + d'erreur. + +Ce qu'il ne peut pas garantir : un ``SIGKILL``, une coupure de courant ou une mise en veille du +poste qui lance la session. En cas de doute, toujours : + +.. code-block:: bash + + scripts/lab-host.sh status + scripts/lab-host.sh down + +Diagnostic +---------- + +``aucune clé SSH active dans le projet`` + Aucune clé SSH n'est enregistrée dans le projet de lab. En ajouter une dans la console (projet + → Clés SSH). + +``offre horaire EM-B212X-SSD : 0 correspondance(s)`` + L'offre n'existe pas dans la zone en facturation horaire. Vérifier ``SCW_DEFAULT_ZONE``. + +``création incertaine`` + La création a échoué ou n'a pas rendu d'identifiant. Le serveur a pu être créé malgré tout — + cas typique : le quota (identité non validée), ou une réponse perdue. Le script indique s'il + voit un serveur de lab ; dans tous les cas, lancer ``status`` puis ``down``. + +``SSH injoignable`` + L'installation est terminée mais SSH ne répond pas après 10 minutes. Avec une clé matérielle + (Yubikey), chaque connexion demande un PIN ou un toucher : utiliser la clé dédiée du lab. Le + serveur est toujours facturé : ``down``. + +``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` + La suppression n'a pas abouti dans les délais. Relancer ``down`` ; si l'erreur persiste, + supprimer depuis la console Scaleway. + +``HTTP 403 insufficient permissions`` + La clé d'API est authentifiée mais n'a pas le droit demandé — en général la lecture des clés + SSH du projet. Compléter la politique de la clé, limitée au projet. + +Sécurité +-------- + +* La clé secrète n'apparaît ni dans les arguments des processus (elle est passée à ``curl`` par + un descripteur de fichier), ni dans les journaux, ni dans l'environnement de ``ssh``. +* **Ne jamais lancer le script sous** ``bash -x`` : la trace afficherait la clé. +* Le serveur n'expose que SSH, par clé. Le lab n'a aucune donnée personnelle ni secret de + production. +* Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal + partagé, capture d'écran) se régénère. + +Tests +----- + +.. code-block:: bash + + bash scripts/lab-host_test.sh + +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, +suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client. +Elle tourne sous bash 5 comme sous le bash 3.2 de macOS. diff --git a/docs/index.rst b/docs/index.rst index b72be04..8eba581 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,9 @@ Par où commencer :doc:`/exploitation/index` Configuration, services, API de l'agent, métriques et diagnostic sur un nœud en service. +:doc:`/developpement/index` + Outillage de développement de two, dont le lab de test multi-nœud sur serveur loué à l'heure. + :doc:`/concepts/index` Comment les éléments fonctionnent entre eux : modèle de données, modes réseau, cycle de vie, metadata. À lire avant de diagnostiquer un comportement inattendu. @@ -57,6 +60,12 @@ Par où commencer exploitation/index +.. toctree:: + :hidden: + :caption: Développement + + developpement/index + .. toctree:: :hidden: :caption: Interne diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh new file mode 100644 index 0000000..56015d7 --- /dev/null +++ b/scripts/lab-host.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash + +API_URL="${SCW_API_URL:-https://api.scaleway.com}" +CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}" +OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}" +OS_NAME="${LAB_OS_NAME:-Debian}" +OS_VERSION="${LAB_OS_VERSION:-12}" +LAB_NAME="${LAB_NAME:-two-lab}" +LAB_TAG="${LAB_TAG:-two-lab}" +INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}" +DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}" +SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}" +POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}" +HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}" +HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}" +STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}" +SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}" +CREATED_ID="" + +info () { echo "== ${1}" >&2; } +warn () { echo "!! ${1}" >&2; } +die () { echo "!! ${1}" >&2; exit 1; } + +usage () { + cat >&2 < [arguments] + + plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création + et le prix ; ne crée rien + up crée le serveur de lab, attend la fin de son installation et son SSH + status liste les serveurs de lab du projet + ssh [commande] se connecte au serveur de lab + down supprime tous les serveurs de lab du projet et attend leur disparition + session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive + +environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE}, + fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}), + LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}), + LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH) + +ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY. +EOF + exit 2 +} + +load_credentials () { + local KEY VALUE + if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then + [[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \ + || die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600" + while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do + case "${KEY}" in + SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;; + SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;; + SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;; + esac + done < "${CREDENTIALS_FILE}" + fi + ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}" + PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}" +} + +require_env () { + [[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement" + [[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement" + command -v jq >/dev/null 2>&1 || die "jq est requis" + command -v curl >/dev/null 2>&1 || die "curl est requis" +} + +http_code () { + cat "${STATE_DIR}/last_http_code" 2>/dev/null +} + +api () { + local METHOD="${1}" + local URL_PATH="${2}" + local BODY="${3:-}" + local OUT CODE + local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}' + --connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}") + + : > "${STATE_DIR}/last_http_code" + [[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}") + OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \ + || { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; } + CODE="${OUT##*$'\n'}" + OUT="${OUT%$'\n'*}" + printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code" + if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then + warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}" + return 1 + fi + printf '%s\n' "${OUT}" +} + +http_failure_is_final () { + local CODE + CODE=$(http_code) + [[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]] +} + +money () { + jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end" +} + +resolve_offer () { + local JSON OFFER COUNT + JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \ + || die "impossible de lister les offres" + OFFER=$(jq -c --arg n "${OFFER_NAME}" \ + '[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}") + COUNT=$(jq 'length' <<< "${OFFER}") + [[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue" + OFFER=$(jq -c '.[0]' <<< "${OFFER}") + [[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée" + [[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}" + [[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \ + || die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus" + printf '%s\n' "${OFFER}" +} + +resolve_os () { + local OFFER_ID="${1}" + local JSON OS COUNT + JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \ + || die "impossible de lister les OS" + OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \ + '[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase)) + and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}") + COUNT=$(jq 'length' <<< "${OS}") + if [[ "${COUNT}" -ne 1 ]]; then + warn "OS disponibles pour ${OFFER_NAME} :" + jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2 + die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue" + fi + jq -c '.[0]' <<< "${OS}" +} + +resolve_ssh_keys () { + local JSON + JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \ + || die "impossible de lister les clés SSH du projet" + JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}") + [[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}" + printf '%s\n' "${JSON}" +} + +build_request () { + local OFFER="${1}" + local OS="${2}" + local KEYS="${3}" + jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \ + --arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \ + '{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)", + tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}' +} + +lab_servers () { + local JSON + JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \ + || return 1 + jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \ + '[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}" +} + +server_ip () { + jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty' +} + +server_is_installing () { + jq -e '.status == "delivering" or .status == "ordered" + or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null +} + +cmd_plan () { + local OFFER OS KEYS + OFFER=$(resolve_offer) || exit 1 + OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 + KEYS=$(resolve_ssh_keys) || exit 1 + info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")" + info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")" + info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")" + info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet" + info "requête : POST /baremetal/v1/zones/${ZONE}/servers" + build_request "${OFFER}" "${OS}" "${KEYS}" | jq . +} + +wait_installed () { + local ID="${1}" + local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT )) + local JSON STATUS INSTALL + while (( SECONDS < DEADLINE )); do + if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then + if http_failure_is_final; then + warn "erreur définitive HTTP $(http_code) en attendant l'installation" + return 1 + fi + sleep "${POLL_INTERVAL}" + continue + fi + STATUS=$(jq -r '.status' <<< "${JSON}") + INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}") + info "serveur ${ID} : ${STATUS}, installation ${INSTALL}" + case "${STATUS}/${INSTALL}" in + ready/completed) printf '%s\n' "${JSON}"; return 0 ;; + error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*) + warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;; + esac + sleep "${POLL_INTERVAL}" + done + warn "installation non terminée après ${INSTALL_TIMEOUT}s" + return 1 +} + +ssh_run () { + local -a OPTS=() + while [[ $# -gt 0 && "${1}" != "--" ]]; do + OPTS+=("${1}") + shift + done + [[ "${1:-}" == "--" ]] && shift + [[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none) + env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \ + "${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@" +} + +wait_ssh () { + local DEADLINE=$(( SECONDS + SSH_TIMEOUT )) + while (( SECONDS < DEADLINE )); do + ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0 + info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s" + sleep "${POLL_INTERVAL}" + done + return 1 +} + +require_no_lab_server () { + local EXISTING + EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab" + [[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \ + || die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'" +} + +report_uncertain_creation () { + local FOUND + if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then + warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")" + fi + die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'" +} + +cmd_up () { + local OFFER OS KEYS BODY JSON ID IP SSH_USER + require_no_lab_server + OFFER=$(resolve_offer) || exit 1 + OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 + KEYS=$(resolve_ssh_keys) || exit 1 + BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}") + info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)" + JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation + ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null) + [[ -n "${ID}" ]] || report_uncertain_creation + CREATED_ID="${ID}" + info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'" + JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'" + IP=$(server_ip <<< "${JSON}") + [[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'" + SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}") + printf '%s\n' "${IP}" > "${STATE_DIR}/ip" + printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user" + : > "${STATE_DIR}/known_hosts" + wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'" + info "prêt : ${SSH_USER:-root}@${IP}" +} + +cmd_status () { + local SERVERS + SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab" + if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then + info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})" + return 0 + fi + jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}" +} + +cmd_ssh () { + [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord" + ssh_run -- "$@" +} + +delete_server () { + local ID="${1}" + local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) + local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT )) + local JSON + while (( SECONDS < DEADLINE )); do + api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0 + [[ $(http_code) == "404" ]] && return 0 + if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then + DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) + (( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}" + warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s" + else + warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s" + fi + sleep "${POLL_INTERVAL}" + done + return 1 +} + +wait_gone () { + local ID="${1}" + local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) + while (( SECONDS < DEADLINE )); do + if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then + [[ $(http_code) == "404" ]] && return 0 + fi + sleep "${POLL_INTERVAL}" + done + return 1 +} + +lab_server_ids () { + local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) + local SERVERS + while (( SECONDS < DEADLINE )); do + if SERVERS=$(lab_servers); then + jq -r '.[].id' <<< "${SERVERS}" + return 0 + fi + warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s" + sleep "${POLL_INTERVAL}" + done + return 1 +} + +cmd_down () { + local IDS ID FAILED=0 + if ! IDS=$(lab_server_ids); then + warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s" + FAILED=1 + IDS="" + fi + IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u) + for ID in ${IDS}; do + info "suppression de ${ID}" + if ! delete_server "${ID}"; then + warn "suppression de ${ID} toujours refusée" + FAILED=1 + continue + fi + if ! wait_gone "${ID}"; then + warn "${ID} toujours présent après ${DELETE_TIMEOUT}s" + FAILED=1 + fi + done + rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts" + [[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway" + info "aucun serveur de lab ne reste dans le projet" +} + +cmd_session () { + local RC + require_no_lab_server + trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + trap 'exit 129' HUP + cmd_up + cmd_ssh "$@" + RC=$? + info "session terminée (code ${RC}), suppression du serveur" + return "${RC}" +} + +main () { + local COMMAND="${1:-}" + [[ $# -gt 0 ]] && shift + load_credentials + [[ -n "${COMMAND}" ]] || usage + mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}" + chmod 700 "${STATE_DIR}" + case "${COMMAND}" in + plan) require_env; cmd_plan ;; + up) require_env; cmd_up ;; + status) require_env; cmd_status ;; + ssh) cmd_ssh "$@" ;; + down) require_env; cmd_down ;; + session) require_env; cmd_session "$@" ;; + *) usage ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then + main "$@" +fi diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh new file mode 100644 index 0000000..e2a93ed --- /dev/null +++ b/scripts/lab-host_test.sh @@ -0,0 +1,685 @@ +#!/usr/bin/env bash + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lab-host.sh" +PASS=0 +FAIL=0 +SECRET="secret-de-test-a1b2c3" +PROJECT="11111111-1111-1111-1111-111111111111" +OTHER_PROJECT="22222222-2222-2222-2222-222222222222" +HOURLY_ID="offer-hourly-0001" +MONTHLY_ID="offer-monthly-0001" + +fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } + +write_fakes () { + local DIR="${1}" + mkdir -p "${DIR}/bin" + cat > "${DIR}/bin/curl" <<'PY' +#!/usr/bin/env python3 +import json, os, sys, time, urllib.parse + +d = os.environ["FAKE_DIR"] +args = sys.argv[1:] +with open(os.path.join(d, "argv.log"), "a") as f: + f.write(json.dumps(args) + "\n") + +method, body, headers, url = "GET", None, [], None +i = 0 +while i < len(args): + a = args[i] + if a == "-X": + method = args[i + 1]; i += 2; continue + if a == "-H": + h = args[i + 1] + if h.startswith("@"): + with open(h[1:]) as f: + headers += [l.strip() for l in f if l.strip()] + else: + headers.append(h) + i += 2; continue + if a == "--data": + body = args[i + 1]; i += 2; continue + if a in ("-w", "--max-time", "--connect-timeout"): + i += 2; continue + if a.startswith("-"): + i += 1; continue + url = a; i += 1 + +state_path = os.path.join(d, "state.json") +with open(state_path) as f: + st = json.load(f) + +def reply(code, obj): + with open(state_path, "w") as f: + json.dump(st, f) + sys.stdout.write(json.dumps(obj) + "\n" + str(code)) + sys.exit(0) + +u = urllib.parse.urlparse(url) +path = u.path +with open(os.path.join(d, "calls.log"), "a") as f: + f.write(method + " " + path + ("?" + u.query if u.query else "") + "\n") + +if "X-Auth-Token: " + os.environ["FAKE_SECRET"] not in headers: + reply(401, {"message": "unauthorized"}) + +zone = "/baremetal/v1/zones/fr-par-1" +if method == "GET" and path == zone + "/offers": + reply(200, {"offers": st["offers"], "total_count": len(st["offers"])}) +if method == "GET" and path == zone + "/os": + reply(200, {"os": st["os"], "total_count": len(st["os"])}) +if method == "GET" and path == "/iam/v1alpha1/ssh-keys": + reply(200, {"ssh_keys": st["keys"], "total_count": len(st["keys"])}) +if method == "GET" and path == zone + "/servers": + if st["list_errors"] > 0: + st["list_errors"] -= 1 + reply(503, {"message": "service unavailable"}) + listed = [v for v in st["servers"].values() if not v.get("hidden")] + reply(200, {"servers": listed, "total_count": len(listed)}) +if method == "POST" and path == zone + "/servers": + req = json.loads(body) + with open(os.path.join(d, "create.json"), "w") as f: + json.dump(req, f) + st["seq"] += 1 + sid = "srv-%d" % st["seq"] + st["servers"][sid] = {"id": sid, "name": req["name"], "project_id": req["project_id"], + "tags": req["tags"], "offer_name": "EM-B212X-SSD", "status": "delivering", + "install": {"status": "to_install"}, "ips": [], "created_at": "now", + "hidden": st["hide_from_list"]} + if st["post_fail_after_create"]: + reply(502, {"message": "bad gateway"}) + if st["post_no_id"]: + reply(200, {}) + reply(200, st["servers"][sid]) +if path.startswith(zone + "/servers/"): + sid = path.rsplit("/", 1)[1] + if sid not in st["servers"]: + reply(404, {"message": "not found"}) + srv = st["servers"][sid] + if method == "GET" and st["get_errors"] > 0: + st["get_errors"] -= 1 + reply(503, {"message": "service unavailable"}) + if method == "GET" and srv["status"] == "deleting": + if st["linger"] > 0: + st["linger"] -= 1 + reply(200, srv) + del st["servers"][sid] + reply(404, {"message": "not found"}) + if method == "GET" and st["vanish_after"] == 0: + del st["servers"][sid] + reply(404, {"message": "not found"}) + if method == "GET" and st["vanish_after"] > 0: + st["vanish_after"] -= 1 + if method == "GET": + if st["progress"]: + status, install = st["progress"].pop(0) + srv["status"] = status + srv["install"]["status"] = install + if status == "ready": + srv["ips"] = [{"address": "2001:db8::1", "version": "IPv6"}, + {"address": "203.0.113.7", "version": "IPv4"}] + reply(200, srv) + if method == "DELETE": + if time.time() < st["refuse_until"]: + reply(409, {"message": "server is installing"}) + if st["delete_refusals"] > 0: + st["delete_refusals"] -= 1 + reply(409, {"message": "server is installing"}) + if st["linger"] > 0: + srv["status"] = "deleting" + else: + del st["servers"][sid] + reply(200, srv) +reply(500, {"message": "route inconnue du faux serveur : " + method + " " + path}) +PY + cat > "${DIR}/bin/ssh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${FAKE_DIR}/ssh.log" +printf 'key=%s\n' "${SCW_SECRET_KEY:-}" >> "${FAKE_DIR}/ssh-env.log" +case "$*" in + *BatchMode=yes*) + N=$(cat "${FAKE_DIR}/probe_fails" 2>/dev/null || echo 0) + if [ "${N}" -gt 0 ]; then + echo $(( N - 1 )) > "${FAKE_DIR}/probe_fails" + exit 255 + fi + exit 0 ;; +esac +[ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}" +exit "${FAKE_SSH_RC:-0}" +SH + chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" +} + +base_state () { + jq -n --arg h "${HOURLY_ID}" --arg m "${MONTHLY_ID}" --arg p "${PROJECT}" --arg o "${OTHER_PROJECT}" '{ + seq: 0, progress: [], delete_refusals: 0, linger: 0, get_errors: 0, list_errors: 0, + hide_from_list: false, refuse_until: 0, post_fail_after_create: false, post_no_id: false, vanish_after: -1, + offers: [ + {id: $m, name: "EM-B212X-SSD", subscription_period: "monthly", enable: true, stock: "available", + price_per_hour: null, price_per_month: {currency_code: "EUR", units: 115, nanos: 990000000}, + fee: {currency_code: "EUR", units: 115, nanos: 990000000}}, + {id: "offer-other", name: "EM-A610R-NVMe", subscription_period: "hourly", enable: true, stock: "available", + price_per_hour: {currency_code: "EUR", units: 0, nanos: 110000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}}, + {id: $h, name: "EM-B212X-SSD", subscription_period: "hourly", enable: true, stock: "available", + price_per_hour: {currency_code: "EUR", units: 0, nanos: 321000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}} + ], + os: [ + {id: "os-deb11", name: "Debian", version: "11 (Bullseye)", enabled: true, allowed: true, user: {default_value: "debian"}}, + {id: "os-deb12", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true, user: {default_value: "debian"}}, + {id: "os-deb13", name: "Debian", version: "13 (Trixie)", enabled: true, allowed: true, user: {default_value: "debian"}}, + {id: "os-ubu", name: "Ubuntu", version: "24.04 LTS (Noble Numbat)", enabled: true, allowed: true, user: {default_value: "ubuntu"}} + ], + keys: [{id: "key-1", disabled: false}, {id: "key-off", disabled: true}], + servers: { + "foreign-1": {id: "foreign-1", name: "prod-db", project_id: $o, tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}, + "foreign-2": {id: "foreign-2", name: "autre", project_id: $p, tags: ["autre"], status: "ready", install: {status: "completed"}, ips: []} + } + }' +} + +setup () { + CURRENT="${1}" + CASE_OK=1 + WORK=$(mktemp -d "${TMPDIR:-/tmp}/labhost.XXXXXX") + write_fakes "${WORK}" + base_state > "${WORK}/state.json" + : > "${WORK}/calls.log" + : > "${WORK}/argv.log" + : > "${WORK}/ssh.log" + : > "${WORK}/ssh-env.log" +} + +mutate () { + local TMP + TMP=$(jq "${1}" "${WORK}/state.json") && printf '%s\n' "${TMP}" > "${WORK}/state.json" +} + +lab_env () { + local EXEC="" + [[ "${1}" == "exec" ]] && { EXEC="exec"; shift; } + ${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ + FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \ + FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" \ + SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \ + SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \ + LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \ + LAB_SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-60}" \ + "$@" +} + +run_lab () { + lab_env bash "${SCRIPT}" "$@" > "${WORK}/out.log" 2>&1 +} + +remaining () { + jq -r '.servers | keys | sort | join(",")' "${WORK}/state.json" +} + +teardown () { + [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" + [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } + rm -rf "${WORK}" +} + +test_plan_picks_hourly_offer_even_when_monthly_is_listed_first () { + setup "plan : offre horaire choisie même si la mensuelle est listée d'abord" + run_lab plan || fail "code de sortie $?" + grep -q "\"offer_id\": \"${HOURLY_ID}\"" "${WORK}/out.log" || fail "offre horaire absente de la requête" + grep -q "${MONTHLY_ID}" "${WORK}/out.log" && fail "offre mensuelle présente dans la sortie" + grep -q "^POST" "${WORK}/calls.log" && fail "plan a créé quelque chose" + teardown +} + +test_plan_selects_debian_12_only () { + setup "plan : Debian 12 et rien d'autre" + run_lab plan || fail "code de sortie $?" + grep -q '"os_id": "os-deb12"' "${WORK}/out.log" || fail "os-deb12 non retenu" + teardown +} + +test_plan_passes_only_enabled_ssh_keys () { + setup "plan : seules les clés SSH actives sont transmises" + run_lab plan || fail "code de sortie $?" + grep -q '"key-1"' "${WORK}/out.log" || fail "key-1 absente" + grep -q '"key-off"' "${WORK}/out.log" && fail "clé désactivée transmise" + teardown +} + +test_refuses_offer_with_setup_fee () { + setup "refus d'une offre horaire avec frais de mise en service" + mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .fee.units) = 30' + run_lab up && fail "up a réussi malgré des frais" + grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé malgré des frais" + teardown +} + +test_refuses_when_hourly_offer_missing () { + setup "refus quand seule l'offre mensuelle existe" + mutate 'del(.offers[] | select(.id == "'"${HOURLY_ID}"'"))' + run_lab up && fail "up a réussi sans offre horaire" + grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé sur une offre mensuelle" + teardown +} + +test_refuses_ambiguous_os () { + setup "refus d'un OS ambigu" + mutate '.os += [{id: "os-deb12b", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true}]' + run_lab plan && fail "plan a réussi avec deux Debian 12" + teardown +} + +test_refuses_out_of_stock () { + setup "refus d'une offre en rupture" + mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .stock) = "empty"' + run_lab up && fail "up a réussi en rupture de stock" + grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé en rupture de stock" + teardown +} + +test_up_creates_and_waits_for_installation () { + setup "up : création puis attente de la fin d'installation" + mutate '.progress = [["delivering","to_install"],["ready","installing"],["ready","completed"]]' + run_lab up || fail "code de sortie $?" + [[ $(jq -r '.offer_id' "${WORK}/create.json") == "${HOURLY_ID}" ]] || fail "offre créée incorrecte" + [[ $(jq -r '.tags | join(",")' "${WORK}/create.json") == "two-lab" ]] || fail "tag absent" + [[ $(jq -r '.project_id' "${WORK}/create.json") == "${PROJECT}" ]] || fail "projet incorrect" + [[ $(cat "${WORK}/.cache/two-lab/ip" 2>/dev/null) == "203.0.113.7" ]] || fail "IPv4 non retenue" + [[ $(cat "${WORK}/.cache/two-lab/user" 2>/dev/null) == "debian" ]] || fail "utilisateur non retenu" + teardown +} + +test_up_refuses_when_lab_server_exists () { + setup "up : refus si un serveur de lab existe déjà" + mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + run_lab up && fail "up a réussi" + grep -q "^POST" "${WORK}/calls.log" && fail "second serveur créé" + teardown +} + +test_secret_never_in_argv () { + setup "la clé d'API n'apparaît jamais dans les arguments de curl" + mutate '.progress = [["ready","completed"]]' + run_lab up || fail "code de sortie $?" + grep -q "${SECRET}" "${WORK}/argv.log" && fail "secret visible dans argv" + grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" + teardown +} + +test_down_deletes_only_lab_servers_of_project () { + setup "down : ne supprime que les serveurs de lab du projet" + mutate '.servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + run_lab down || fail "code de sortie $?" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_down_retries_refused_deletion () { + setup "down : réessaie une suppression refusée" + mutate '.delete_refusals = 2 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' + run_lab down || fail "code de sortie $?" + [[ $(grep -c "^DELETE" "${WORK}/calls.log") -eq 3 ]] || fail "$(grep -c "^DELETE" "${WORK}/calls.log") DELETE au lieu de 3" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_down_fails_loudly_when_deletion_keeps_failing () { + setup "down : échec bruyant si la suppression n'aboutit jamais" + mutate '.delete_refusals = 1000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' + LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" + grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" + teardown +} + +test_down_waits_until_server_is_gone () { + setup "down : attend que le serveur ait disparu" + mutate '.linger = 3 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + run_lab down || fail "code de sortie $?" + [[ $(grep -c "^GET /baremetal/v1/zones/fr-par-1/servers/srv-lab$" "${WORK}/calls.log") -ge 4 ]] || fail "disparition non attendue" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_down_fails_loudly_when_server_never_disappears () { + setup "down : échec bruyant si le serveur ne disparaît jamais" + mutate '.linger = 100000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" + grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" + teardown +} + +test_down_does_not_mistake_api_error_for_disappearance () { + setup "down : une erreur de l'API n'est pas prise pour une disparition" + mutate '.linger = 100000 | .get_errors = 1 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi alors que le serveur existe encore" + grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" + teardown +} + +test_session_deletes_server_after_remote_command () { + setup "session : serveur supprimé après la commande distante" + mutate '.progress = [["ready","completed"]]' + run_lab session uname -a || fail "code de sortie $?" + grep -q "uname -a" "${WORK}/ssh.log" || fail "commande distante non lancée" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_session_deletes_server_and_propagates_failure_of_remote_command () { + setup "session : serveur supprimé et code d'échec propagé" + mutate '.progress = [["ready","completed"]]' + FAKE_SSH_RC=7 run_lab session false + [[ $? -eq 7 ]] || fail "code de sortie différent de 7" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_session_deletes_server_when_installation_fails () { + setup "session : serveur supprimé quand l'installation échoue" + mutate '.progress = [["ready","installing"],["ready","error"]]' + run_lab session true && fail "session a réussi" + grep -q "true" "${WORK}/ssh.log" && fail "commande lancée sur un serveur en échec" + grep -q "état terminal en échec : ready/error" "${WORK}/out.log" || fail "échec d'installation non reconnu comme terminal" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_session_deletes_server_when_installation_times_out () { + setup "session : serveur supprimé quand l'installation n'aboutit pas" + mutate '.progress = [range(0; 100000) | ["delivering","to_install"]]' + LAB_INSTALL_TIMEOUT=1 run_lab session true && fail "session a réussi" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_session_does_not_touch_existing_lab_server () { + setup "session : ne supprime pas un serveur de lab lancé à côté" + mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' + run_lab session true && fail "session a réussi" + grep -q "^DELETE" "${WORK}/calls.log" && fail "suppression lancée" + [[ $(remaining) == "foreign-1,foreign-2,srv-old" ]] || fail "restants : $(remaining)" + teardown +} + +test_missing_credentials_are_refused_before_any_call () { + setup "refus sans clé d'API, avant tout appel" + env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" FAKE_DIR="${WORK}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 \ + && fail "plan a réussi sans clé" + [[ -s "${WORK}/calls.log" ]] && fail "appel réseau sans clé" + teardown +} + +LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' +INSTALLING_LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' + +test_down_keeps_retrying_while_server_is_installing () { + setup "down : réessaie au-delà du délai tant que le serveur s'installe" + mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 5)" + LAB_DELETE_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 run_lab down || fail "code de sortie $?" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + grep -q "pendant la livraison ou l'installation" "${WORK}/out.log" || fail "installation non reconnue" + teardown +} + +test_down_gives_up_on_installing_server_after_hard_deadline () { + local RC + setup "down : abandonne quand même après délai d'installation + délai de suppression" + mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 100000)" + LAB_DELETE_TIMEOUT=2 LAB_INSTALL_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 lab_env timeout 30 bash "${SCRIPT}" down > "${WORK}/out.log" 2>&1 + RC=$? + [[ "${RC}" -ne 124 ]] || fail "aucun plafond : down tourne encore après 30 s" + [[ "${RC}" -ne 0 ]] || fail "down a réussi" + grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" + teardown +} + +test_down_retries_listing_servers () { + setup "down : réessaie la liste des serveurs" + mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 2" + run_lab down || fail "code de sortie $?" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_down_fails_loudly_when_listing_never_works () { + setup "down : échec bruyant si la liste reste indisponible" + mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 100000" + LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" + grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" + teardown +} + +test_session_deletes_created_server_even_if_missing_from_list () { + setup "session : supprime le serveur créé même absent de la liste" + mutate '.hide_from_list = true | .progress = [["ready","completed"]]' + run_lab session true || fail "code de sortie $?" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_every_api_call_has_timeouts () { + setup "chaque appel à l'API a un délai de connexion et un délai total" + mutate '.progress = [["ready","completed"]]' + run_lab up || fail "code de sortie $?" + [[ -s "${WORK}/argv.log" ]] || fail "aucun appel" + [[ $(jq -s '[.[] | select((index("--max-time") | not) or (index("--connect-timeout") | not))] | length' "${WORK}/argv.log") -eq 0 ]] \ + || fail "appel sans délai" + teardown +} + +test_up_reports_uncertain_creation_when_post_fails () { + setup "up : création incertaine signalée quand le POST échoue" + mutate '.post_fail_after_create = true' + run_lab up && fail "up a réussi" + grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" + grep -q "un serveur de lab existe bien : srv-1" "${WORK}/out.log" || fail "serveur créé non signalé" + teardown +} + +test_session_deletes_server_when_post_fails_after_creation () { + setup "session : serveur supprimé quand le POST échoue après création" + mutate '.post_fail_after_create = true' + run_lab session true && fail "session a réussi" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_up_refuses_response_without_id () { + setup "up : réponse de création sans identifiant refusée" + mutate '.post_no_id = true' + run_lab up && fail "up a réussi" + grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" + grep -q "servers/null" "${WORK}/calls.log" && fail "appel sur un identifiant null" + teardown +} + +test_up_stops_at_once_when_server_vanishes () { + setup "up : arrêt immédiat si le serveur disparaît pendant l'installation" + mutate '.vanish_after = 1 | .progress = [["delivering","to_install"]]' + LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" + grep -q "erreur définitive HTTP 404" "${WORK}/out.log" || fail "404 non traité comme définitif" + teardown +} + +test_up_stops_at_once_when_server_is_stopped_during_installation () { + setup "up : arrêt immédiat si le serveur passe à l'arrêt pendant l'installation" + mutate '.progress = [["delivering","to_install"],["stopped","installing"]]' + LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" + grep -q "état terminal en échec : stopped/installing" "${WORK}/out.log" || fail "arrêt non reconnu comme terminal" + teardown +} + +test_session_cleanup_survives_a_second_signal () { + local PID RC I + setup "session : un second signal n'interrompt pas le nettoyage" + mutate '.progress = [["ready","completed"]] | .linger = 15' + set -m + FAKE_SSH_SLEEP=5 LAB_POLL_INTERVAL=0.2 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & + PID=$! + set +m + for I in $(seq 1 100); do + grep -q "sleep-long" "${WORK}/ssh.log" && break + sleep 0.1 + done + kill -TERM -- "-${PID}" + for I in $(seq 1 100); do + grep -q "suppression de srv-1" "${WORK}/out.log" && break + sleep 0.1 + done + grep -q "suppression de srv-1" "${WORK}/out.log" || fail "nettoyage jamais commencé" + kill -INT -- "-${PID}" 2>/dev/null + kill -TERM -- "-${PID}" 2>/dev/null + wait "${PID}" + RC=$? + [[ "${RC}" -eq 143 ]] || fail "code ${RC} au lieu de 143" + grep -q "aucun serveur de lab ne reste" "${WORK}/out.log" || fail "nettoyage interrompu" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_ssh_never_receives_api_key () { + setup "ssh ne reçoit jamais la clé d'API dans son environnement" + mutate '.progress = [["ready","completed"]]' + run_lab session uname || fail "code de sortie $?" + [[ -s "${WORK}/ssh-env.log" ]] || fail "ssh jamais lancé" + grep -qv '^key=$' "${WORK}/ssh-env.log" && fail "clé transmise à ssh" + teardown +} + +test_session_waits_for_ssh_before_running_command () { + setup "session : attend que SSH réponde avant la commande" + mutate '.progress = [["ready","completed"]]' + echo 3 > "${WORK}/probe_fails" + run_lab session uname || fail "code de sortie $?" + [[ $(grep -c "BatchMode=yes" "${WORK}/ssh.log") -eq 4 ]] || fail "$(grep -c "BatchMode=yes" "${WORK}/ssh.log") sondes au lieu de 4" + [[ $(tail -n 1 "${WORK}/ssh.log") == *" uname" ]] || fail "commande non lancée en dernier" + teardown +} + +test_session_deletes_server_when_ssh_never_answers () { + setup "session : serveur supprimé si SSH ne répond jamais" + mutate '.progress = [["ready","completed"]]' + echo 100000 > "${WORK}/probe_fails" + LAB_SSH_TIMEOUT=1 LAB_POLL_INTERVAL=0.2 run_lab session uname && fail "session a réussi" + grep -qv "BatchMode=yes" "${WORK}/ssh.log" && fail "commande lancée sans SSH joignable" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_ssh_uses_dedicated_lab_key_only_when_present () { + setup "ssh : clé dédiée du lab utilisée seule quand elle existe" + mutate '.progress = [["ready","completed"]]' + mkdir -p "${WORK}/.config/two-lab/ssh" + : > "${WORK}/.config/two-lab/ssh/lab_ed25519" + run_lab session uname || fail "code de sortie $?" + grep -q " uname" "${WORK}/ssh.log" || fail "commande non lancée" + [[ $(grep -c -- "-i ${WORK}/.config/two-lab/ssh/lab_ed25519 -o IdentitiesOnly=yes -o IdentityAgent=none" "${WORK}/ssh.log") -eq $(wc -l < "${WORK}/ssh.log") ]] \ + || fail "une connexion n'a pas utilisé la clé dédiée seule" + teardown +} + +test_ssh_falls_back_to_agent_without_lab_key () { + setup "ssh : agent SSH utilisé quand la clé dédiée est absente" + mutate '.progress = [["ready","completed"]]' + run_lab session uname || fail "code de sortie $?" + grep -q -- "-i " "${WORK}/ssh.log" && fail "clé imposée alors qu'elle n'existe pas" + grep -q "IdentityAgent=none" "${WORK}/ssh.log" && fail "agent désactivé sans clé dédiée" + teardown +} + +run_lab_without_env_credentials () { + env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ + FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \ + LAB_POLL_INTERVAL=0 "$@" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 +} + +write_credentials () { + mkdir -p "${WORK}/.config/two-lab" + printf '%s\n' "$@" > "${WORK}/.config/two-lab/scaleway.env" + chmod 600 "${WORK}/.config/two-lab/scaleway.env" +} + +test_credentials_file_is_used_when_environment_is_empty () { + setup "identifiants : fichier lu quand l'environnement est vide" + write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" "SCW_DEFAULT_ZONE=fr-par-1" + run_lab_without_env_credentials || fail "code de sortie $?" + grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet du fichier non utilisé" + grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" + teardown +} + +test_credentials_file_without_final_newline_is_read () { + setup "identifiants : dernière ligne sans retour à la ligne lue quand même" + mkdir -p "${WORK}/.config/two-lab" + printf 'SCW_DEFAULT_PROJECT_ID=%s\nSCW_SECRET_KEY=%s' "${PROJECT}" "${SECRET}" > "${WORK}/.config/two-lab/scaleway.env" + chmod 600 "${WORK}/.config/two-lab/scaleway.env" + run_lab_without_env_credentials || fail "code de sortie $?" + teardown +} + +test_environment_wins_over_credentials_file () { + setup "identifiants : l'environnement l'emporte sur le fichier" + write_credentials "SCW_SECRET_KEY=mauvais-secret" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" + run_lab_without_env_credentials SCW_SECRET_KEY="${SECRET}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" + grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement non retenu" + teardown +} + +test_environment_value_kept_when_file_completes_the_rest () { + setup "identifiants : une valeur de l'environnement n'est pas écrasée quand le fichier complète" + write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" + run_lab_without_env_credentials SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" + grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement écrasé par le fichier" + teardown +} + +test_credentials_file_readable_by_others_is_refused () { + setup "identifiants : fichier lisible par d'autres refusé, sans aucun appel" + write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" + chmod 644 "${WORK}/.config/two-lab/scaleway.env" + run_lab_without_env_credentials && fail "plan a réussi" + grep -q "chmod 600" "${WORK}/out.log" || fail "pas de consigne de droits" + [[ -s "${WORK}/calls.log" ]] && fail "appel réseau avec un fichier exposé" + teardown +} + +test_credentials_file_is_never_executed () { + setup "identifiants : le fichier est lu, jamais exécuté" + write_credentials "SCW_SECRET_KEY=\$(touch ${WORK}/pwned)" "touch ${WORK}/pwned2" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" + run_lab_without_env_credentials && fail "plan a réussi avec un secret invalide" + [[ -e "${WORK}/pwned" || -e "${WORK}/pwned2" ]] && fail "contenu du fichier exécuté" + teardown +} + +signal_during_session () { + local SIGNAL="${1}" + local EXPECTED="${2}" + local PID RC I + setup "session : ${SIGNAL} pendant la commande distante, serveur supprimé, code ${EXPECTED}" + mutate '.progress = [["ready","completed"]]' + set -m + FAKE_SSH_SLEEP=5 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & + PID=$! + set +m + for I in $(seq 1 100); do + grep -q "sleep-long" "${WORK}/ssh.log" && break + sleep 0.1 + done + grep -q "sleep-long" "${WORK}/ssh.log" || fail "commande distante jamais lancée" + kill "-${SIGNAL}" -- "-${PID}" + wait "${PID}" + RC=$? + [[ "${RC}" -eq "${EXPECTED}" ]] || fail "code ${RC} au lieu de ${EXPECTED}" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_session_handles_term () { signal_during_session TERM 143; } +test_session_handles_hup () { signal_during_session HUP 129; } +test_session_handles_int () { signal_during_session INT 130; } + +for T in $(declare -F | awk '{print $3}' | grep '^test_'); do + "${T}" +done +echo "${PASS} réussi(s), ${FAIL} échec(s)" +[[ "${FAIL}" -eq 0 ]] From 4d0fc7e18d490ef0996d8fce1c8de72bc3f676cb Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 00:49:21 +0200 Subject: [PATCH 09/25] f-50: lab: topologie et calcul du plan #50 Signed-off-by: GnomeZworc --- cmd/lab/main.go | 50 ++++ cmd/lab/main_test.go | 72 +++++ conf/lab/evpn-2hv.yml | 18 ++ docs/developpement/lab.rst | 99 ++++++- go.mod | 2 +- internal/lab/topology/format.go | 34 +++ internal/lab/topology/plan.go | 195 ++++++++++++++ internal/lab/topology/plan_test.go | 351 +++++++++++++++++++++++++ internal/lab/topology/topology.go | 151 +++++++++++ internal/lab/topology/topology_test.go | 172 ++++++++++++ internal/lab/topology/validate.go | 170 ++++++++++++ 11 files changed, 1310 insertions(+), 4 deletions(-) create mode 100644 cmd/lab/main.go create mode 100644 cmd/lab/main_test.go create mode 100644 conf/lab/evpn-2hv.yml create mode 100644 internal/lab/topology/format.go create mode 100644 internal/lab/topology/plan.go create mode 100644 internal/lab/topology/plan_test.go create mode 100644 internal/lab/topology/topology.go create mode 100644 internal/lab/topology/topology_test.go create mode 100644 internal/lab/topology/validate.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go new file mode 100644 index 0000000..dc0199d --- /dev/null +++ b/cmd/lab/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "os" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const usage = `usage: lab + + 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 +} diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go new file mode 100644 index 0000000..3c87f8a --- /dev/null +++ b/cmd/lab/main_test.go @@ -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) + } +} diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml new file mode 100644 index 0000000..3d389f6 --- /dev/null +++ b/conf/lab/evpn-2hv.yml @@ -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] } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 3da14eb..7c55361 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,9 +13,10 @@ comment s'en servir. .. note:: - État actuel : seule l'étape **E0** est livrée — 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 - avec les étapes suivantes, et cette page avec elles. + État actuel : étapes **E0** et **E1** livrées — le cycle de vie du serveur qui portera le lab + (``scripts/lab-host.sh``), puis la description de la topologie et le calcul de son plan + (``lab plan``). Le lancement des VM viendra avec les étapes suivantes, et cette page avec + elles. 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 à 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-`` + * - 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::::`` — 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``, 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 ----------- @@ -240,6 +332,7 @@ Tests .. code-block:: bash 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 qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets, diff --git a/go.mod b/go.mod index 0e59eb9..9bcc86b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.1 github.com/vishvananda/netns v0.0.5 + go.yaml.in/yaml/v3 v3.0.4 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/trace v1.37.0 // 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/text v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go new file mode 100644 index 0000000..e501bff --- /dev/null +++ b/internal/lab/topology/format.go @@ -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() +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go new file mode 100644 index 0000000..5d91389 --- /dev/null +++ b/internal/lab/topology/plan.go @@ -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 +} diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go new file mode 100644 index 0000000..9c65b6d --- /dev/null +++ b/internal/lab/topology/plan_test.go @@ -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") +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go new file mode 100644 index 0000000..bd184d7 --- /dev/null +++ b/internal/lab/topology/topology.go @@ -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 +} diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go new file mode 100644 index 0000000..e59b189 --- /dev/null +++ b/internal/lab/topology/topology_test.go @@ -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") +} diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go new file mode 100644 index 0000000..8a5abf9 --- /dev/null +++ b/internal/lab/topology/validate.go @@ -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..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 +} From f3515ca3705e6a7f6777a7236f816a893926f125 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 01:17:12 +0200 Subject: [PATCH 10/25] f-50: lab: arguments QEMU et seeds cloud-init des VM #50 Signed-off-by: GnomeZworc --- cmd/lab/main.go | 117 +++++- cmd/lab/main_test.go | 51 ++- docs/developpement/lab.rst | 113 +++++- internal/lab/render/qemu.go | 54 +++ internal/lab/render/render.go | 132 +++++++ internal/lab/render/render_test.go | 477 +++++++++++++++++++++++++ internal/lab/render/seed.go | 192 ++++++++++ internal/lab/topology/topology_test.go | 1 + internal/lab/topology/validate.go | 5 + 9 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 internal/lab/render/qemu.go create mode 100644 internal/lab/render/render.go create mode 100644 internal/lab/render/render_test.go create mode 100644 internal/lab/render/seed.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go index dc0199d..936e09a 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -1,45 +1,73 @@ package main import ( + "flag" "fmt" "io" "os" + "path/filepath" + "strings" + "git.g3e.fr/syonad/two/internal/lab/render" "git.g3e.fr/syonad/two/internal/lab/topology" ) -const usage = `usage: lab +const usage = `usage: lab [options] [dir] - plan validate the topology and print the deterministic plan: addresses, cables, ports + plan + validate the topology and print the deterministic plan: addresses, cables, ports + + render -key + write, for each node, //qemu.args (one argument per line) and the + cloud-init seed files meta-data, user-data and network-config ` +type keyFiles []string + +func (k *keyFiles) String() string { return strings.Join(*k, ",") } +func (k *keyFiles) Set(v string) error { *k = append(*k, v); return nil } + 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 { + if len(args) == 0 { fmt.Fprint(stderr, usage) return 2 } switch args[0] { case "plan": + if len(args) != 2 { + fmt.Fprint(stderr, usage) + return 2 + } return plan(args[1], stdout, stderr) + case "render": + return renderCmd(args[1:], stdout, stderr) default: fmt.Fprint(stderr, usage) return 2 } } -func plan(path string, stdout, stderr io.Writer) int { +func load(path string, stderr io.Writer) (*topology.Plan, bool) { t, err := topology.Load(path) if err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 + return nil, false } p, err := topology.Compute(t) if err != nil { fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err) + return nil, false + } + return p, true +} + +func plan(path string, stdout, stderr io.Writer) int { + p, ok := load(path, stderr) + if !ok { return 1 } if err := p.Write(stdout); err != nil { @@ -48,3 +76,82 @@ func plan(path string, stdout, stderr io.Writer) int { } return 0 } + +func renderCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("render", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, usage) } + var keys keyFiles + fs.Var(&keys, "key", "public key file allowed to log in, repeatable") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 2 || len(keys) == 0 { + fmt.Fprint(stderr, usage) + return 2 + } + + p, ok := load(fs.Arg(0), stderr) + if !ok { + return 1 + } + dir, err := filepath.Abs(fs.Arg(1)) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + authorized, err := readKeys(keys) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized}) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + for _, n := range nodes { + if err := writeNode(n); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "%s\n", n.Dir) + } + return 0 +} + +func readKeys(files []string) ([]string, error) { + var keys []string + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + return nil, err + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + keys = append(keys, line) + } + } + return keys, nil +} + +func writeNode(n render.Node) error { + if err := os.MkdirAll(n.Dir, 0o700); err != nil { + return err + } + files := map[string][]byte{ + "qemu.args": []byte(strings.Join(n.QEMU, "\n") + "\n"), + "meta-data": n.MetaData, + "user-data": n.UserData, + "network-config": n.NetworkConfig, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { + return err + } + } + return nil +} diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index 3c87f8a..a1cc588 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -15,7 +15,7 @@ func runLab(args ...string) (int, string, string) { } func TestRun_UsageOnMissingArguments(t *testing.T) { - for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}} { + for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}, {"render", "a.yml", "dir"}, {"render", "-key", "k.pub", "a.yml"}, {"render", "-bogus"}} { code, _, stderr := runLab(args...) if code != 2 || !strings.Contains(stderr, "usage: lab") { t.Errorf("args %v: code %d, stderr %q", args, code, stderr) @@ -70,3 +70,52 @@ func TestRun_MissingFile(t *testing.T) { t.Errorf("code %d, stderr %q", code, stderr) } } + +func TestRun_RenderWritesEveryNodeFiles(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "lab.pub") + if err := os.WriteFile(key, []byte("# lab key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "run") + code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out) + if code != 0 { + t.Fatalf("code %d, stderr %s", code, stderr) + } + for _, node := range []string{"sw1", "rr1", "hv1", "hv2"} { + if !strings.Contains(stdout, filepath.Join(out, node)) { + t.Errorf("stdout does not list %s:\n%s", node, stdout) + } + for _, f := range []string{"qemu.args", "meta-data", "user-data", "network-config"} { + info, err := os.Stat(filepath.Join(out, node, f)) + if err != nil { + t.Errorf("%s/%s: %v", node, f, err) + continue + } + if info.Mode().Perm() != 0o600 { + t.Errorf("%s/%s mode %o, want 600", node, f, info.Mode().Perm()) + } + } + } + args, err := os.ReadFile(filepath.Join(out, "hv1", "qemu.args")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "\n-netdev\nuser,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22\n") { + t.Errorf("qemu.args is not one argument per line:\n%s", args) + } + userData, err := os.ReadFile(filepath.Join(out, "hv1", "user-data")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(userData), "two-lab-automation") || strings.Contains(string(userData), "# lab key") { + t.Errorf("keys not read as an authorized_keys file:\n%s", userData) + } +} + +func TestRun_RenderRefusesMissingKeyFile(t *testing.T) { + code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), t.TempDir()) + if code != 1 || !strings.Contains(stderr, "absent.pub") { + t.Errorf("code %d, stderr %q", code, stderr) + } +} diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 7c55361..7c0371d 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,10 +13,10 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** et **E1** livrées — le cycle de vie du serveur qui portera le lab - (``scripts/lab-host.sh``), puis la description de la topologie et le calcul de son plan - (``lab plan``). Le lancement des VM viendra avec les étapes suivantes, et cette page avec - elles. + État actuel : étapes **E0** à **E2** livrées — le cycle de vie du serveur qui portera le lab + (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan + (``lab plan``), puis la génération des arguments QEMU et des fichiers cloud-init de chaque VM + (``lab render``). Le lancement des VM viendra avec l'étape suivante, et cette page avec elle. Le serveur de lab ----------------- @@ -254,6 +254,111 @@ champs inconnus et les clés en double sont refusés aussi : Les plages d'adresses de l'exemple sont des valeurs de travail : le plan d'adressage du lab reste à définir (#50). +Rendu des VM +------------ + +``lab render`` produit, pour chaque nœud, ce qu'il faut pour démarrer sa VM — sans rien lancer : + +.. code-block:: text + + $ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub conf/lab/evpn-2hv.yml + +``//`` reçoit : + +``qemu.args`` + Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à + interpréter par un shell. + +``meta-data``, ``user-data``, ``network-config`` + Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``. + +Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``) +sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier +``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``. + +Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau : + +.. code-block:: text + + -netdev + user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22 + -device + virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile= + -netdev + dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003 + -device + virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile= + +Les choix qui s'y lisent : + +* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en + dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ; +* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:::00:00``, SSH redirigé + sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un + nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe. + ``ipv6=off`` partout (voir plus bas) ; +* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent + (port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au + guest ; +* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli + sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM + restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de + ce repli ; la cause n'a pas été isolée, l'option est une précaution. + +Ce que fait cloud-init : + +* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p``), + ``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par + clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ; +* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS + (``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le + switch ; +* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge + ``br-`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la + passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le + service est rejoué à chaque démarrage. + +Vérifié sur de vraies VM +~~~~~~~~~~~~~~~~~~~~~~~~ + +Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de +l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et +les fichiers produits par ``lab render``. + +.. list-table:: + :header-rows: 1 + :widths: 60 40 + + * - Vérification + - Résultat + * - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24`` + - conforme + * - service ``lab-switch`` actif, y compris après redémarrage + - conforme + * - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation) + - passe + * - ``ping -M do -s 8973`` (MTU 9001) + - refusé : ``message too long, mtu=9000`` + * - Internet depuis ``rr1`` en IPv4 + - passe, par ``10.250.0.1`` + * - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient + - bloqué + * - ``rr1`` vers Internet par ``mgmt0`` + - bloqué + +Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un +préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque +``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS +renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4. + +.. note:: + + Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de + QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de + l'hôte, avec un témoin qui y parvient, le prouve. + +Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué. + Facturation ----------- diff --git a/internal/lab/render/qemu.go b/internal/lab/render/qemu.go new file mode 100644 index 0000000..9a870d2 --- /dev/null +++ b/internal/lab/render/qemu.go @@ -0,0 +1,54 @@ +package render + +import ( + "fmt" + "path/filepath" + "strconv" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const loopback = "127.0.0.1" + +func qemuArgs(p *topology.Plan, n topology.NodePlan, index int, dir string) []string { + args := []string{ + "-name", n.Name, + "-machine", "q35", + "-accel", "kvm", + "-cpu", "host", + "-smp", strconv.Itoa(n.CPUs), + "-m", strconv.Itoa(n.Memory), + "-nodefaults", + "-display", "none", + "-serial", "file:" + filepath.Join(dir, ConsoleFile), + "-qmp", "unix:" + filepath.Join(dir, QMPFile) + ",server=on,wait=off", + "-pidfile", filepath.Join(dir, PIDFile), + "-drive", "file=" + filepath.Join(dir, DiskFile) + ",if=virtio,format=qcow2", + "-drive", "file=" + filepath.Join(dir, SeedFile) + ",media=cdrom,readonly=on", + } + + restrict := "on" + if n.Role == topology.RoleSwitch { + restrict = "off" + } + args = append(args, + "-netdev", fmt.Sprintf("user,id=%s,restrict=%s,ipv6=off,hostfwd=tcp:%s:%d-:22", AdminInterface, restrict, loopback, n.SSHPort), + "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,romfile=", AdminInterface, adminMAC(index)), + ) + + for _, c := range nodeCables(p, n.Name) { + args = append(args, cable(c.NodeInterface, c.NodePort, c.SwitchPort, c.NodeMAC.String(), c.MTU)...) + } + for _, c := range switchCables(p, n.Name) { + args = append(args, cable(c.SwitchInterface, c.SwitchPort, c.NodePort, c.SwitchMAC.String(), c.MTU)...) + } + return args +} + +func cable(id string, local, remote int, mac string, mtu int) []string { + return []string{ + "-netdev", fmt.Sprintf("dgram,id=%s,local.type=inet,local.host=%s,local.port=%d,remote.type=inet,remote.host=%s,remote.port=%d", + id, loopback, local, loopback, remote), + "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,host_mtu=%d,romfile=", id, mac, mtu), + } +} diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go new file mode 100644 index 0000000..49b3089 --- /dev/null +++ b/internal/lab/render/render.go @@ -0,0 +1,132 @@ +package render + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + QEMUBinary = "qemu-system-x86_64" + + AdminInterface = topology.ReservedInterface + AdminAddress = "10.0.2.15/24" + AdminGateway = "10.0.2.2" + AdminDNS = "10.0.2.3" + + DiskFile = "disk.qcow2" + SeedFile = "seed.iso" + ConsoleFile = "console.log" + QMPFile = "qmp.sock" + PIDFile = "qemu.pid" +) + +var Nameservers = []string{"1.1.1.1", "8.8.8.8"} + +type Options struct { + RunDir string + AuthorizedKeys []string +} + +type Node struct { + Name string + Dir string + QEMU []string + MetaData []byte + UserData []byte + NetworkConfig []byte +} + +func Render(p *topology.Plan, o Options) ([]Node, error) { + if err := o.validate(); err != nil { + return nil, err + } + var nodes []Node + for index, n := range p.Nodes { + dir := filepath.Join(o.RunDir, n.Name) + meta, err := metaData(p, n) + if err != nil { + return nil, err + } + user, err := userData(p, n, o.AuthorizedKeys) + if err != nil { + return nil, err + } + network, err := networkConfig(p, n, index) + if err != nil { + return nil, err + } + nodes = append(nodes, Node{ + Name: n.Name, + Dir: dir, + QEMU: qemuArgs(p, n, index, dir), + MetaData: meta, + UserData: user, + NetworkConfig: network, + }) + } + return nodes, nil +} + +func (o Options) validate() error { + var errs []error + if !filepath.IsAbs(o.RunDir) { + errs = append(errs, fmt.Errorf("run dir %q must be an absolute path", o.RunDir)) + } + if len(o.AuthorizedKeys) == 0 { + errs = append(errs, errors.New("at least one authorized ssh key is required")) + } + for i, k := range o.AuthorizedKeys { + if strings.ContainsAny(k, "\r\n") { + errs = append(errs, fmt.Errorf("authorized key %d spans several lines", i+1)) + continue + } + fields := strings.Fields(k) + if len(fields) < 2 || !validKeyType(fields[0]) { + errs = append(errs, fmt.Errorf("authorized key %d is not an ssh public key", i+1)) + } + } + return errors.Join(errs...) +} + +func validKeyType(t string) bool { + return strings.HasPrefix(t, "ssh-") || strings.HasPrefix(t, "ecdsa-sha2-") || strings.HasPrefix(t, "sk-") +} + +func adminMAC(index int) net.HardwareAddr { + return net.HardwareAddr{0x02, 0x4d, byte(index >> 8), byte(index), 0x00, 0x00} +} + +func nodeCables(p *topology.Plan, name string) []topology.Cable { + var out []topology.Cable + for _, c := range p.Cables { + if c.Node == name { + out = append(out, c) + } + } + return out +} + +func switchCables(p *topology.Plan, name string) []topology.Cable { + var out []topology.Cable + for _, c := range p.Cables { + if c.Switch == name { + out = append(out, c) + } + } + return out +} + +func switchSegments(p *topology.Plan, name string) []topology.SegmentPlan { + var out []topology.SegmentPlan + for _, s := range p.Segments { + if s.Switch == name { + out = append(out, s) + } + } + return out +} diff --git a/internal/lab/render/render_test.go b/internal/lab/render/render_test.go new file mode 100644 index 0000000..f270d13 --- /dev/null +++ b/internal/lab/render/render_test.go @@ -0,0 +1,477 @@ +package render + +import ( + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation" + +const twoHypervisors = `name: evpn-2hv +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +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] } +` + +const twoSegments = `name: two-seg +images: + deb: + url: https://example.invalid/deb.qcow2 + sums: https://example.invalid/SHA512SUMS +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] } +` + +func plan(t *testing.T, doc string) *topology.Plan { + t.Helper() + topo, err := topology.Parse([]byte(doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + p, err := topology.Compute(topo) + if err != nil { + t.Fatalf("Compute: %v", err) + } + return p +} + +func renderAll(t *testing.T, doc string) map[string]Node { + t.Helper() + nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}}) + if err != nil { + t.Fatalf("Render: %v", err) + } + out := map[string]Node{} + for _, n := range nodes { + out[n.Name] = n + } + return out +} + +func nodeNamed(t *testing.T, nodes map[string]Node, name string) Node { + t.Helper() + n, ok := nodes[name] + if !ok { + t.Fatalf("no rendered node %s", name) + } + return n +} + +func value(t *testing.T, args []string, flag string) []string { + t.Helper() + var out []string + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + out = append(out, args[i+1]) + } + } + if len(out) == 0 { + t.Fatalf("no %s in %v", flag, args) + } + return out +} + +func hasValue(args []string, flag, want string) bool { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag && args[i+1] == want { + return true + } + } + return false +} + +func network(t *testing.T, n Node) networkDoc { + t.Helper() + var doc networkDoc + if err := yaml.Unmarshal(n.NetworkConfig, &doc); err != nil { + t.Fatalf("network-config of %s is not yaml: %v", n.Name, err) + } + return doc +} + +func iface(t *testing.T, doc networkDoc, name string) ethernet { + t.Helper() + e, ok := doc.Ethernets[name] + if !ok { + t.Fatalf("no interface %s in %v", name, doc.Ethernets) + } + return e +} + +func user(t *testing.T, n Node) cloudConfig { + t.Helper() + if !strings.HasPrefix(string(n.UserData), "#cloud-config\n") { + t.Fatalf("user-data of %s does not start with #cloud-config", n.Name) + } + var cfg cloudConfig + if err := yaml.Unmarshal(n.UserData, &cfg); err != nil { + t.Fatalf("user-data of %s is not yaml: %v", n.Name, err) + } + return cfg +} + +func fileAt(t *testing.T, cfg cloudConfig, path string) writeFile { + t.Helper() + for _, f := range cfg.WriteFiles { + if f.Path == path { + return f + } + } + t.Fatalf("no write_files entry %s", path) + return writeFile{} +} + +func TestQEMU_HypervisorArguments(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") + for flag, want := range map[string]string{ + "-name": "hv1", + "-machine": "q35", + "-accel": "kvm", + "-cpu": "host", + "-smp": "4", + "-m": "16384", + "-display": "none", + "-serial": "file:/srv/lab/hv1/console.log", + "-qmp": "unix:/srv/lab/hv1/qmp.sock,server=on,wait=off", + "-pidfile": "/srv/lab/hv1/qemu.pid", + } { + if !hasValue(hv.QEMU, flag, want) { + t.Errorf("%s %s missing from %v", flag, want, hv.QEMU) + } + } + for _, want := range []string{ + "file=/srv/lab/hv1/disk.qcow2,if=virtio,format=qcow2", + "file=/srv/lab/hv1/seed.iso,media=cdrom,readonly=on", + } { + if !hasValue(hv.QEMU, "-drive", want) { + t.Errorf("-drive %s missing", want) + } + } + if hv.Dir != "/srv/lab/hv1" { + t.Errorf("Dir = %s", hv.Dir) + } + nodefaults := 0 + for _, a := range hv.QEMU { + if a == "-nodefaults" { + nodefaults++ + } + } + if nodefaults != 1 { + t.Errorf("-nodefaults appears %d times", nodefaults) + } +} + +func TestQEMU_HypervisorNetwork(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") + wantNetdevs := []string{ + "user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22", + "dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003", + } + wantDevices := []string{ + "virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=", + "virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=", + } + if got := value(t, hv.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { + t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) + } + if got := value(t, hv.QEMU, "-device"); !reflect.DeepEqual(got, wantDevices) { + t.Errorf("devices = %v\nwant %v", got, wantDevices) + } +} + +func TestQEMU_SwitchNetwork(t *testing.T) { + sw := nodeNamed(t, renderAll(t, twoHypervisors), "sw1") + wantNetdevs := []string{ + "user,id=mgmt0,restrict=off,ipv6=off,hostfwd=tcp:127.0.0.1:2200-:22", + "dgram,id=p0,local.type=inet,local.host=127.0.0.1,local.port=20001,remote.type=inet,remote.host=127.0.0.1,remote.port=20000", + "dgram,id=p1,local.type=inet,local.host=127.0.0.1,local.port=20003,remote.type=inet,remote.host=127.0.0.1,remote.port=20002", + "dgram,id=p2,local.type=inet,local.host=127.0.0.1,local.port=20005,remote.type=inet,remote.host=127.0.0.1,remote.port=20004", + } + if got := value(t, sw.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { + t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) + } + if !hasValue(sw.QEMU, "-device", "virtio-net-pci,netdev=p2,mac=02:4c:00:03:00:01,host_mtu=9000,romfile=") { + t.Errorf("p2 device missing: %v", sw.QEMU) + } +} + +func TestQEMU_OnlyTheSwitchReachesTheOutsideThroughAdministration(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + admin := value(t, n.QEMU, "-netdev")[0] + wantRestrict := "restrict=on" + if name == "sw1" { + wantRestrict = "restrict=off" + } + if !strings.Contains(admin, ","+wantRestrict+",") { + t.Errorf("%s admin netdev %q, want %s", name, admin, wantRestrict) + } + if !strings.Contains(admin, "hostfwd=tcp:127.0.0.1:") { + t.Errorf("%s ssh forward not bound to loopback: %q", name, admin) + } + } +} + +func TestQEMU_EveryCableEndsMatch(t *testing.T) { + ends := map[string]int{} + for _, n := range renderAll(t, twoHypervisors) { + for _, nd := range value(t, n.QEMU, "-netdev") { + if !strings.HasPrefix(nd, "dgram,") { + continue + } + var local, remote string + for _, kv := range strings.Split(nd, ",") { + if v, ok := strings.CutPrefix(kv, "local.port="); ok { + local = v + } + if v, ok := strings.CutPrefix(kv, "remote.port="); ok { + remote = v + } + } + ends[local+">"+remote]++ + } + } + for pair, count := range ends { + local, remote, _ := strings.Cut(pair, ">") + if count != 1 || ends[remote+">"+local] != 1 { + t.Errorf("cable end %s has no single matching end", pair) + } + } + if len(ends) != 6 { + t.Errorf("%d cable ends, want 6", len(ends)) + } +} + +func TestNetworkConfig_Hypervisor(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) + if doc.Version != 2 || len(doc.Ethernets) != 2 { + t.Fatalf("network-config = %+v", doc) + } + admin := iface(t, doc, "mgmt0") + if admin.Match.MACAddress != "02:4d:00:02:00:00" || admin.SetName != "mgmt0" || + !reflect.DeepEqual(admin.Addresses, []string{"10.0.2.15/24"}) || len(admin.Routes) != 0 || admin.Nameservers != nil { + t.Errorf("mgmt0 = %+v", admin) + } + under := iface(t, doc, "underlay") + if under.Match.MACAddress != "02:4c:00:02:00:00" || under.SetName != "underlay" || under.MTU != 9000 || + !reflect.DeepEqual(under.Addresses, []string{"10.250.0.3/24"}) { + t.Errorf("underlay = %+v", under) + } + if !reflect.DeepEqual(under.Routes, []route{{To: "0.0.0.0/0", Via: "10.250.0.1"}}) { + t.Errorf("underlay routes = %+v", under.Routes) + } + if under.Nameservers == nil || !reflect.DeepEqual(under.Nameservers.Addresses, []string{"1.1.1.1", "8.8.8.8"}) { + t.Errorf("underlay nameservers = %+v", under.Nameservers) + } +} + +func TestNetworkConfig_DHCPIsExplicitlyOffEverywhere(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + if c := strings.Count(string(n.NetworkConfig), "dhcp4: false"); c != len(network(t, n).Ethernets) { + t.Errorf("%s: %d explicit dhcp4: false for %d interfaces", name, c, len(network(t, n).Ethernets)) + } + } +} + +func TestNetworkConfig_Switch(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) + admin := iface(t, doc, "mgmt0") + if !reflect.DeepEqual(admin.Routes, []route{{To: "0.0.0.0/0", Via: "10.0.2.2"}}) || + admin.Nameservers == nil || !reflect.DeepEqual(admin.Nameservers.Addresses, []string{"10.0.2.3"}) { + t.Errorf("switch mgmt0 = %+v", admin) + } + for port, mac := range map[string]string{"p0": "02:4c:00:01:00:01", "p1": "02:4c:00:02:00:01", "p2": "02:4c:00:03:00:01"} { + e := iface(t, doc, port) + if e.Match.MACAddress != mac || e.SetName != port || e.MTU != 9000 || len(e.Addresses) != 0 || len(e.Routes) != 0 { + t.Errorf("%s = %+v", port, e) + } + } +} + +func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) { + doc := network(t, nodeNamed(t, renderAll(t, twoSegments), "hv")) + red, blue := iface(t, doc, "red"), iface(t, doc, "blue") + if !reflect.DeepEqual(red.Routes, []route{{To: "0.0.0.0/0", Via: "10.1.0.1"}}) || red.Nameservers == nil { + t.Errorf("red = %+v", red) + } + if len(blue.Routes) != 0 || blue.Nameservers != nil || blue.MTU != 1500 { + t.Errorf("blue = %+v", blue) + } +} + +func TestUserData_Hypervisor(t *testing.T) { + cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) + if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 || len(cfg.Packages) != 0 { + t.Errorf("hv1 user-data = %+v", cfg) + } + if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) { + t.Errorf("keys = %v", cfg.SSHAuthorizedKeys) + } +} + +func TestUserData_PasswordLoginIsExplicitlyOff(t *testing.T) { + for name, n := range renderAll(t, twoHypervisors) { + if !strings.Contains(string(n.UserData), "\nssh_pwauth: false\n") || !strings.Contains(string(n.UserData), "\ndisable_root: true\n") { + t.Errorf("%s user-data:\n%s", name, n.UserData) + } + } +} + +func TestUserData_SwitchBuildsBridgeGatewayAndNAT(t *testing.T) { + cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) + if !reflect.DeepEqual(cfg.Packages, []string{"nftables"}) { + t.Errorf("packages = %v", cfg.Packages) + } + script := fileAt(t, cfg, "/usr/local/sbin/lab-switch") + if script.Permissions != "0755" { + t.Errorf("script permissions = %s", script.Permissions) + } + wantScript := `#!/bin/sh +set -eu +sysctl -qw net.ipv4.ip_forward=1 +ip link add br-underlay type bridge stp_state 0 2>/dev/null || true +ip link set dev p0 master br-underlay +ip link set dev p0 up +ip link set dev p1 master br-underlay +ip link set dev p1 up +ip link set dev p2 master br-underlay +ip link set dev p2 up +ip link set dev br-underlay mtu 9000 +ip addr replace 10.250.0.1/24 dev br-underlay +ip link set dev br-underlay up +nft -f /etc/lab-switch.nft +` + if script.Content != wantScript { + t.Errorf("script:\n%s\nwant:\n%s", script.Content, wantScript) + } + nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content + for _, want := range []string{ + "add table ip lab_nat\ndelete table ip lab_nat\n", + "type nat hook postrouting priority srcnat;", + `ip saddr { 10.250.0.0/24 } oifname "mgmt0" masquerade`, + } { + if !strings.Contains(nft, want) { + t.Errorf("nft rules miss %q:\n%s", want, nft) + } + } + unit := fileAt(t, cfg, "/etc/systemd/system/lab-switch.service").Content + for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/sbin/lab-switch", "After=network-online.target", "WantedBy=multi-user.target"} { + if !strings.Contains(unit, want) { + t.Errorf("unit misses %q", want) + } + } + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) { + t.Errorf("runcmd = %v", cfg.Runcmd) + } +} + +func TestUserData_SwitchWithTwoSegments(t *testing.T) { + cfg := user(t, nodeNamed(t, renderAll(t, twoSegments), "sw")) + script := fileAt(t, cfg, "/usr/local/sbin/lab-switch").Content + for _, want := range []string{ + "ip link set dev p0 master br-red\n", + "ip link set dev p1 master br-blue\n", + "ip link set dev br-blue mtu 1500\n", + "ip addr replace 10.2.0.1/24 dev br-blue\n", + } { + if !strings.Contains(script, want) { + t.Errorf("script misses %q:\n%s", want, script) + } + } + if strings.Contains(script, "p1 master br-red") || strings.Contains(script, "p0 master br-blue") { + t.Errorf("port bridged on the wrong segment:\n%s", script) + } + if nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content; !strings.Contains(nft, "ip saddr { 10.1.0.0/24, 10.2.0.0/24 }") { + t.Errorf("nft:\n%s", nft) + } +} + +func TestMetaData(t *testing.T) { + var doc metaDoc + if err := yaml.Unmarshal(nodeNamed(t, renderAll(t, twoHypervisors), "hv2").MetaData, &doc); err != nil { + t.Fatal(err) + } + if doc.InstanceID != "evpn-2hv-hv2" || doc.LocalHostname != "hv2" { + t.Errorf("meta-data = %+v", doc) + } +} + +func TestRender_AdminMacsAreUniqueAndApartFromCableMacs(t *testing.T) { + seen := map[string]string{} + for name, n := range renderAll(t, twoHypervisors) { + for _, d := range value(t, n.QEMU, "-device") { + for _, kv := range strings.Split(d, ",") { + if mac, ok := strings.CutPrefix(kv, "mac="); ok { + if other, dup := seen[mac]; dup { + t.Errorf("mac %s used by %s and %s", mac, other, name) + } + seen[mac] = name + } + } + } + } + if len(seen) != 10 { + t.Errorf("%d distinct macs, want 10", len(seen)) + } +} + +func TestRender_IsStable(t *testing.T) { + first := renderAll(t, twoHypervisors) + for i := 0; i < 20; i++ { + if again := renderAll(t, twoHypervisors); !reflect.DeepEqual(first, again) { + t.Fatalf("render differs on run %d", i) + } + } +} + +func TestRender_RejectsBadOptions(t *testing.T) { + p := plan(t, twoHypervisors) + cases := []struct { + name string + opts Options + want string + }{ + {"relative run dir", Options{RunDir: "lab", AuthorizedKeys: []string{labKey}}, `run dir "lab" must be an absolute path`}, + {"no key", Options{RunDir: "/srv/lab"}, "at least one authorized ssh key"}, + {"multi-line key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey + "\nssh-rsa AAAA x"}}, "authorized key 1 spans several lines"}, + {"not a key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"hello world"}}, "authorized key 1 is not an ssh public key"}, + {"type only", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"ssh-ed25519"}}, "authorized key 1 is not an ssh public key"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Render(p, c.opts) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + }) + } +} + +func TestRender_AcceptsUsualKeyTypes(t *testing.T) { + p := plan(t, twoHypervisors) + for _, k := range []string{ + "ssh-rsa AAAAB3NzaC1yc2E user", + "ecdsa-sha2-nistp256 AAAAE2VjZHNh user", + "sk-ssh-ed25519@openssh.com AAAAGnNr user", + } { + if _, err := Render(p, Options{RunDir: "/srv/lab", AuthorizedKeys: []string{k}}); err != nil { + t.Errorf("%s rejected: %v", strings.Fields(k)[0], err) + } + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go new file mode 100644 index 0000000..c959d3c --- /dev/null +++ b/internal/lab/render/seed.go @@ -0,0 +1,192 @@ +package render + +import ( + "fmt" + "strings" + + "go.yaml.in/yaml/v3" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + SwitchScript = "/usr/local/sbin/lab-switch" + SwitchNFT = "/etc/lab-switch.nft" + SwitchUnit = "/etc/systemd/system/lab-switch.service" +) + +type metaDoc struct { + InstanceID string `yaml:"instance-id"` + LocalHostname string `yaml:"local-hostname"` +} + +type writeFile struct { + Path string `yaml:"path"` + Permissions string `yaml:"permissions"` + Content string `yaml:"content"` +} + +type cloudConfig struct { + Hostname string `yaml:"hostname"` + SSHPwauth bool `yaml:"ssh_pwauth"` + DisableRoot bool `yaml:"disable_root"` + SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys"` + Packages []string `yaml:"packages,omitempty"` + WriteFiles []writeFile `yaml:"write_files,omitempty"` + Runcmd [][]string `yaml:"runcmd,omitempty"` +} + +type match struct { + MACAddress string `yaml:"macaddress"` +} + +type route struct { + To string `yaml:"to"` + Via string `yaml:"via"` +} + +type nameservers struct { + Addresses []string `yaml:"addresses"` +} + +type ethernet struct { + Match match `yaml:"match"` + SetName string `yaml:"set-name"` + DHCP4 bool `yaml:"dhcp4"` + MTU int `yaml:"mtu,omitempty"` + Addresses []string `yaml:"addresses,omitempty"` + Routes []route `yaml:"routes,omitempty"` + Nameservers *nameservers `yaml:"nameservers,omitempty"` +} + +type networkDoc struct { + Version int `yaml:"version"` + Ethernets map[string]ethernet `yaml:"ethernets"` +} + +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) { + cfg := cloudConfig{ + Hostname: n.Name, + SSHPwauth: false, + DisableRoot: true, + SSHAuthorizedKeys: keys, + } + if n.Role == topology.RoleSwitch { + cfg.Packages = []string{"nftables"} + cfg.WriteFiles = []writeFile{ + {Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)}, + {Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)}, + {Path: SwitchUnit, Permissions: "0644", Content: switchUnit()}, + } + cfg.Runcmd = [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "lab-switch.service"}, + } + } + body, err := yaml.Marshal(cfg) + if err != nil { + return nil, err + } + return append([]byte("#cloud-config\n"), body...), nil +} + +func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) { + doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}} + admin := ethernet{ + Match: match{MACAddress: adminMAC(index).String()}, + SetName: AdminInterface, + Addresses: []string{AdminAddress}, + } + if n.Role == topology.RoleSwitch { + admin.Routes = []route{{To: "0.0.0.0/0", Via: AdminGateway}} + admin.Nameservers = &nameservers{Addresses: []string{AdminDNS}} + for _, c := range switchCables(p, n.Name) { + doc.Ethernets[c.SwitchInterface] = ethernet{ + Match: match{MACAddress: c.SwitchMAC.String()}, + SetName: c.SwitchInterface, + MTU: c.MTU, + } + } + } + doc.Ethernets[AdminInterface] = admin + + for i, c := range nodeCables(p, n.Name) { + e := ethernet{ + Match: match{MACAddress: c.NodeMAC.String()}, + SetName: c.NodeInterface, + MTU: c.MTU, + Addresses: []string{c.NodeAddress.String()}, + } + if i == 0 { + e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}} + e.Nameservers = &nameservers{Addresses: Nameservers} + } + doc.Ethernets[c.NodeInterface] = e + } + return yaml.Marshal(doc) +} + +func gatewayOf(p *topology.Plan, segment string) string { + for _, s := range p.Segments { + if s.Name == segment { + return s.Gateway.String() + } + } + return "" +} + +func switchScript(p *topology.Plan, name string) string { + var b strings.Builder + b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n") + for _, s := range switchSegments(p, name) { + fmt.Fprintf(&b, "ip link add %s type bridge stp_state 0 2>/dev/null || true\n", s.Bridge) + for _, c := range switchCables(p, name) { + if c.Segment != s.Name { + continue + } + fmt.Fprintf(&b, "ip link set dev %s master %s\n", c.SwitchInterface, s.Bridge) + fmt.Fprintf(&b, "ip link set dev %s up\n", c.SwitchInterface) + } + 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) + fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge) + } + fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT) + return b.String() +} + +func switchNFT(p *topology.Plan, name string) string { + var networks []string + for _, s := range switchSegments(p, name) { + networks = append(networks, s.Network.String()) + } + return fmt.Sprintf(`add table ip lab_nat +delete table ip lab_nat +table ip lab_nat { + chain postrouting { + type nat hook postrouting priority srcnat; policy accept; + ip saddr { %s } oifname "%s" masquerade + } +} +`, strings.Join(networks, ", "), AdminInterface) +} + +func switchUnit() string { + return fmt.Sprintf(`[Unit] +Description=Lab switch: bridges, gateways and NAT +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=%s + +[Install] +WantedBy=multi-user.target +`, SwitchScript) +} diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go index e59b189..d050a0f 100644 --- a/internal/lab/topology/topology_test.go +++ b/internal/lab/topology/topology_test.go @@ -133,6 +133,7 @@ func TestValidate_Rejections(t *testing.T) { {"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 named like the admin interface", strings.ReplaceAll(valid, "under", "mgmt0"), "segment mgmt0: name is reserved"}, {"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"}, diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 8a5abf9..6a7d53e 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -15,6 +15,8 @@ const ( MinMemory = 256 MaxPrefix = 30 MinPrefix = 8 + + ReservedInterface = "mgmt0" ) var ( @@ -63,6 +65,9 @@ func (t *Topology) Validate() error { if !segmentPattern.MatchString(s.Name) { add("segment %s: name must match %s", s.Name, segmentPattern) } + if s.Name == ReservedInterface { + add("segment %s: name is reserved for the administration interface", s.Name) + } sw, ok := nodes[s.Switch] switch { case s.Switch == "": From 4b176c22d7655e42d93856501236a7912406b5b7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 14:21:20 +0200 Subject: [PATCH 11/25] =?UTF-8?q?f-50:=20lab:=20image=20v=C3=A9rifi=C3=A9e?= =?UTF-8?q?,=20disques=20et=20seeds=20des=20VM=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main.go | 21 +- internal/lab/provision/image.go | 185 +++++++++++++ internal/lab/provision/image_test.go | 334 +++++++++++++++++++++++ internal/lab/provision/provision.go | 151 ++++++++++ internal/lab/provision/provision_test.go | 317 +++++++++++++++++++++ internal/lab/topology/plan.go | 3 +- internal/lab/topology/plan_test.go | 8 + 7 files changed, 999 insertions(+), 20 deletions(-) create mode 100644 internal/lab/provision/image.go create mode 100644 internal/lab/provision/image_test.go create mode 100644 internal/lab/provision/provision.go create mode 100644 internal/lab/provision/provision_test.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 936e09a..13db735 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" + "git.g3e.fr/syonad/two/internal/lab/provision" "git.g3e.fr/syonad/two/internal/lab/render" "git.g3e.fr/syonad/two/internal/lab/topology" ) @@ -111,7 +112,7 @@ func renderCmd(args []string, stdout, stderr io.Writer) int { return 1 } for _, n := range nodes { - if err := writeNode(n); err != nil { + if err := provision.WriteFiles(n); err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 } @@ -137,21 +138,3 @@ func readKeys(files []string) ([]string, error) { } return keys, nil } - -func writeNode(n render.Node) error { - if err := os.MkdirAll(n.Dir, 0o700); err != nil { - return err - } - files := map[string][]byte{ - "qemu.args": []byte(strings.Join(n.QEMU, "\n") + "\n"), - "meta-data": n.MetaData, - "user-data": n.UserData, - "network-config": n.NetworkConfig, - } - for name, content := range files { - if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { - return err - } - } - return nil -} diff --git a/internal/lab/provision/image.go b/internal/lab/provision/image.go new file mode 100644 index 0000000..c4e8347 --- /dev/null +++ b/internal/lab/provision/image.go @@ -0,0 +1,185 @@ +package provision + +import ( + "bufio" + "bytes" + "context" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const maxSumsSize = 1 << 20 + +var sha512Pattern = regexp.MustCompile(`^[0-9a-f]{128}$`) + +type Fetcher struct { + Client *http.Client + CacheDir string +} + +func (f Fetcher) Image(ctx context.Context, img topology.Image) (string, error) { + if !filepath.IsAbs(f.CacheDir) { + return "", fmt.Errorf("image cache dir %q must be an absolute path", f.CacheDir) + } + name, err := fileName(img.URL) + if err != nil { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + + sums, err := f.get(ctx, img.Sums, maxSumsSize) + if err != nil { + return "", fmt.Errorf("image %s: sums: %w", img.Name, err) + } + want, err := expectedSum(sums, name) + if err != nil { + return "", fmt.Errorf("image %s: %s: %w", img.Name, img.Sums, err) + } + + dir := filepath.Join(f.CacheDir, img.Name) + target := filepath.Join(dir, name) + if got, err := fileSum(target); err == nil && got == want { + return target, nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + if err := f.download(ctx, img.URL, dir, target, want); err != nil { + return "", fmt.Errorf("image %s: %w", img.Name, err) + } + return target, nil +} + +func (f Fetcher) client() *http.Client { + if f.Client != nil { + return f.Client + } + return http.DefaultClient +} + +func (f Fetcher) open(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := f.client().Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status) + } + return resp, nil +} + +func (f Fetcher) get(ctx context.Context, rawURL string, limit int64) ([]byte, error) { + resp, err := f.open(ctx, rawURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("GET %s: larger than %d bytes", rawURL, limit) + } + return data, nil +} + +func (f Fetcher) download(ctx context.Context, rawURL, dir, target, want string) error { + resp, err := f.open(ctx, rawURL) + if err != nil { + return err + } + defer resp.Body.Close() + + tmp, err := os.CreateTemp(dir, filepath.Base(target)+".part-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + + h := sha512.New() + if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { + tmp.Close() + return fmt.Errorf("GET %s: %w", rawURL, err) + } + if err := tmp.Close(); err != nil { + return err + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("GET %s: sha512 %s, want %s", rawURL, got, want) + } + if err := os.Chmod(tmp.Name(), 0o644); err != nil { + return err + } + return os.Rename(tmp.Name(), target) +} + +func fileName(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + name := path.Base(u.Path) + if name == "" || name == "." || name == "/" { + return "", fmt.Errorf("url %q does not name a file", rawURL) + } + return name, nil +} + +func expectedSum(sums []byte, name string) (string, error) { + var found []string + sc := bufio.NewScanner(bytes.NewReader(sums)) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { + continue + } + sum := strings.ToLower(fields[0]) + if !sha512Pattern.MatchString(sum) { + return "", fmt.Errorf("entry for %s is not a sha512 sum", name) + } + found = append(found, sum) + } + if err := sc.Err(); err != nil { + return "", err + } + switch { + case len(found) == 0: + return "", fmt.Errorf("no sum for %s", name) + case len(found) > 1: + return "", fmt.Errorf("%d sums for %s", len(found), name) + } + return found[0], nil +} + +func fileSum(p string) (string, error) { + file, err := os.Open(p) + if err != nil { + return "", err + } + defer file.Close() + h := sha512.New() + if _, err := io.Copy(h, file); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/lab/provision/image_test.go b/internal/lab/provision/image_test.go new file mode 100644 index 0000000..cd1ab20 --- /dev/null +++ b/internal/lab/provision/image_test.go @@ -0,0 +1,334 @@ +package provision + +import ( + "context" + "crypto/sha512" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const imageName = "debian-12-generic-amd64.qcow2" + +type mirror struct { + mu sync.Mutex + files map[string][]byte + status map[string]int + hits map[string]int + server *httptest.Server +} + +func newMirror(t *testing.T) *mirror { + t.Helper() + m := &mirror{files: map[string][]byte{}, status: map[string]int{}, hits: map[string]int{}} + m.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + m.hits[r.URL.Path]++ + if code := m.status[r.URL.Path]; code != 0 { + w.WriteHeader(code) + return + } + data, ok := m.files[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Write(data) + })) + t.Cleanup(m.server.Close) + return m +} + +func (m *mirror) put(path string, data []byte) { + m.mu.Lock() + defer m.mu.Unlock() + m.files[path] = data +} + +func (m *mirror) count(path string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.hits[path] +} + +func (m *mirror) image() topology.Image { + return topology.Image{ + Name: "debian12", + URL: m.server.URL + "/bookworm/latest/" + imageName, + Sums: m.server.URL + "/bookworm/latest/SHA512SUMS", + } +} + +func sum(data []byte) string { + h := sha512.Sum512(data) + return hex.EncodeToString(h[:]) +} + +func publish(m *mirror, content []byte) { + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte( + sum([]byte("other"))+" debian-12-genericcloud-amd64.qcow2\n"+ + sum(content)+" "+imageName+"\n"+ + sum([]byte("raw"))+" debian-12-generic-amd64.raw\n")) +} + +func fetch(t *testing.T, m *mirror, cache string) string { + t.Helper() + got, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) + if err != nil { + t.Fatalf("Image: %v", err) + } + return got +} + +func fetchError(t *testing.T, m *mirror, cache string) string { + t.Helper() + _, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) + if err == nil { + t.Fatal("Image: no error") + } + return err.Error() +} + +func entries(t *testing.T, dir string) []string { + t.Helper() + list, err := os.ReadDir(dir) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + var names []string + for _, e := range list { + names = append(names, e.Name()) + } + return names +} + +func TestImage_DownloadsAndVerifies(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + + got := fetch(t, m, cache) + + if want := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2"); got != want { + t.Errorf("path = %s, want %s", got, want) + } + data, err := os.ReadFile(got) + if err != nil || string(data) != "qcow2 image" { + t.Errorf("content = %q, %v", data, err) + } + info, err := os.Stat(got) + if err != nil || info.Mode().Perm() != 0o644 { + t.Errorf("mode = %v, %v", info.Mode().Perm(), err) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { + t.Errorf("cache dir holds %v, want only the image", names) + } +} + +func TestImage_ReusesAVerifiedCacheButAlwaysRereadsTheSums(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + + fetch(t, m, cache) + fetch(t, m, cache) + + if n := m.count("/bookworm/latest/" + imageName); n != 1 { + t.Errorf("image downloaded %d times, want 1", n) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 2 { + t.Errorf("sums read %d times, want 2", n) + } +} + +func TestImage_ReplacesACorruptedCache(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + cache := t.TempDir() + path := fetch(t, m, cache) + if err := os.WriteFile(path, []byte("bit rot"), 0o644); err != nil { + t.Fatal(err) + } + + fetch(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "qcow2 image" { + t.Errorf("content = %q, want the published image", data) + } + if n := m.count("/bookworm/latest/" + imageName); n != 2 { + t.Errorf("image downloaded %d times, want 2", n) + } +} + +func TestImage_FollowsANewReleaseOfTheSameFile(t *testing.T) { + m := newMirror(t) + publish(m, []byte("release 1")) + cache := t.TempDir() + path := fetch(t, m, cache) + + publish(m, []byte("release 2")) + fetch(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "release 2" { + t.Errorf("content = %q, want release 2", data) + } +} + +func TestImage_RejectsAMismatchAndLeavesNothing(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + m.put("/bookworm/latest/"+imageName, []byte("tampered")) + cache := t.TempDir() + + msg := fetchError(t, m, cache) + + if !strings.Contains(msg, "sha512") { + t.Errorf("error = %q, want a sha512 mismatch", msg) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v after a mismatch, want nothing", names) + } +} + +func TestImage_KeepsTheVerifiedCacheWhenANewDownloadFails(t *testing.T) { + m := newMirror(t) + publish(m, []byte("release 1")) + cache := t.TempDir() + path := fetch(t, m, cache) + + publish(m, []byte("release 2")) + m.put("/bookworm/latest/"+imageName, []byte("truncated")) + fetchError(t, m, cache) + + if data, _ := os.ReadFile(path); string(data) != "release 1" { + t.Errorf("content = %q, want release 1 kept", data) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { + t.Errorf("cache dir holds %v, want only the image", names) + } +} + +func TestImage_AcceptsBinaryModeSumLines(t *testing.T) { + m := newMirror(t) + content := []byte("qcow2 image") + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(strings.ToUpper(sum(content))+" *"+imageName+"\n")) + + fetch(t, m, t.TempDir()) +} + +func TestImage_SumsRejections(t *testing.T) { + content := []byte("qcow2 image") + cases := map[string]struct { + sums string + want string + }{ + "no entry": {sum(content) + " debian-12-generic-arm64.qcow2\n", "no sum for " + imageName}, + "two entries": {sum(content) + " " + imageName + "\n" + sum(content) + " " + imageName + "\n", "2 sums for " + imageName}, + "not sha512": {"d41d8cd98f00b204e9800998ecf8427e " + imageName + "\n", "is not a sha512 sum"}, + "prefix of name": {sum(content) + " " + imageName + ".sig\n", "no sum for " + imageName}, + "name in directory": {sum(content) + " nested/" + imageName + "\n", "no sum for " + imageName}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + m := newMirror(t) + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(c.sums)) + if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, c.want) { + t.Errorf("error = %q, want %q", msg, c.want) + } + if n := m.count("/bookworm/latest/" + imageName); n != 0 { + t.Errorf("image downloaded %d times before the sums were trusted", n) + } + }) + } +} + +func TestImage_RefusesOversizedSums(t *testing.T) { + m := newMirror(t) + m.put("/bookworm/latest/SHA512SUMS", make([]byte, 1<<20+1)) + if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, "larger than 1048576 bytes") { + t.Errorf("error = %q", msg) + } +} + +func TestImage_AcceptsSumsOfExactlyTheLimit(t *testing.T) { + m := newMirror(t) + content := []byte("qcow2 image") + line := sum(content) + " " + imageName + "\n" + m.put("/bookworm/latest/"+imageName, content) + m.put("/bookworm/latest/SHA512SUMS", []byte(strings.Repeat("\n", 1<<20-len(line))+line)) + + fetch(t, m, t.TempDir()) +} + +func TestImage_HTTPErrors(t *testing.T) { + for _, path := range []string{"/bookworm/latest/SHA512SUMS", "/bookworm/latest/" + imageName} { + t.Run(path, func(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + m.status[path] = http.StatusServiceUnavailable + cache := t.TempDir() + if msg := fetchError(t, m, cache); !strings.Contains(msg, "503") { + t.Errorf("error = %q, want the http status", msg) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v, want nothing", names) + } + }) + } +} + +func TestImage_RefusesARelativeCacheDir(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + if msg := fetchError(t, m, "cache"); !strings.Contains(msg, "must be an absolute path") { + t.Errorf("error = %q", msg) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 { + t.Errorf("sums read %d times", n) + } +} + +func TestImage_RefusesAURLWithoutFileName(t *testing.T) { + m := newMirror(t) + img := m.image() + img.URL = m.server.URL + "/" + _, err := Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}.Image(context.Background(), img) + if err == nil || !strings.Contains(err.Error(), "does not name a file") { + t.Errorf("error = %v", err) + } +} + +func TestImage_RejectsAnInterruptedDownloadAndLeavesNothing(t *testing.T) { + content := []byte("qcow2 image") + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "SHA512SUMS") { + w.Write([]byte(sum(content) + " " + imageName + "\n")) + return + } + w.Header().Set("Content-Length", "1000") + w.Write(content[:4]) + })) + defer server.Close() + cache := t.TempDir() + img := topology.Image{Name: "debian12", URL: server.URL + "/" + imageName, Sums: server.URL + "/SHA512SUMS"} + + _, err := Fetcher{Client: server.Client(), CacheDir: cache}.Image(context.Background(), img) + + if err == nil || !strings.Contains(err.Error(), "unexpected EOF") { + t.Errorf("error = %v, want an interrupted transfer", err) + } + if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { + t.Errorf("cache dir holds %v, want nothing", names) + } +} diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go new file mode 100644 index 0000000..98c1b2f --- /dev/null +++ b/internal/lab/provision/provision.go @@ -0,0 +1,151 @@ +package provision + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.g3e.fr/syonad/two/internal/lab/render" + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + KeyFile = "lab_ed25519" + KeyLabel = "two-lab" + DiskSize = "20G" + SeedLabel = "cidata" + + ArgsFile = "qemu.args" +) + +var SeedFiles = []string{"user-data", "meta-data", "network-config"} + +type Runner interface { + Run(ctx context.Context, name string, args ...string) error +} + +type ExecRunner struct{} + +func (ExecRunner) Run(ctx context.Context, name string, args ...string) error { + out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + return nil +} + +type Options struct { + RunDir string + Fetcher Fetcher + Runner Runner +} + +func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, error) { + if !filepath.IsAbs(o.RunDir) { + return nil, fmt.Errorf("run dir %q must be an absolute path", o.RunDir) + } + if err := os.MkdirAll(o.RunDir, 0o700); err != nil { + return nil, err + } + + images := map[string]string{} + for _, n := range p.Nodes { + if _, ok := images[n.Image]; ok { + continue + } + img, ok := imageNamed(p, n.Image) + if !ok { + return nil, fmt.Errorf("node %s: image %q is not declared", n.Name, n.Image) + } + base, err := o.Fetcher.Image(ctx, img) + if err != nil { + return nil, err + } + images[n.Image] = base + } + + key, err := EnsureKey(ctx, o.Runner, o.RunDir) + if err != nil { + return nil, err + } + nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}}) + if err != nil { + return nil, err + } + for i, n := range nodes { + if err := Stage(ctx, o.Runner, n, images[p.Nodes[i].Image]); err != nil { + return nil, fmt.Errorf("node %s: %w", n.Name, err) + } + } + return nodes, 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) { + if err := r.Run(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", KeyLabel, "-f", private); err != nil { + return "", err + } + } else if err != nil { + return "", err + } + data, err := os.ReadFile(private + ".pub") + if err != nil { + return "", err + } + key := strings.TrimSpace(string(data)) + if key == "" || strings.ContainsAny(key, "\r\n") { + return "", fmt.Errorf("%s.pub: not a single public key", private) + } + return key, nil +} + +func WriteFiles(n render.Node) error { + if err := os.MkdirAll(n.Dir, 0o700); err != nil { + return err + } + files := map[string][]byte{ + ArgsFile: []byte(strings.Join(n.QEMU, "\n") + "\n"), + "meta-data": n.MetaData, + "user-data": n.UserData, + "network-config": n.NetworkConfig, + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { + return err + } + } + return nil +} + +func Stage(ctx context.Context, r Runner, n render.Node, base string) error { + if !filepath.IsAbs(base) { + return fmt.Errorf("base image %q must be an absolute path", base) + } + if err := WriteFiles(n); err != nil { + return err + } + disk := filepath.Join(n.Dir, render.DiskFile) + seed := filepath.Join(n.Dir, render.SeedFile) + if err := r.Run(ctx, "qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, disk, DiskSize); err != nil { + return err + } + args := []string{"-quiet", "-output", seed, "-volid", SeedLabel, "-joliet", "-rock"} + for _, f := range SeedFiles { + args = append(args, filepath.Join(n.Dir, f)) + } + return r.Run(ctx, "genisoimage", args...) +} + +func imageNamed(p *topology.Plan, name string) (topology.Image, bool) { + for _, i := range p.Images { + if i.Name == name { + return i, true + } + } + return topology.Image{}, false +} diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go new file mode 100644 index 0000000..b05d693 --- /dev/null +++ b/internal/lab/provision/provision_test.go @@ -0,0 +1,317 @@ +package provision + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "git.g3e.fr/syonad/two/internal/lab/render" + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const generatedKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab" + +type fakeRunner struct { + calls [][]string + fail string +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.calls = append(f.calls, append([]string{name}, args...)) + if name == f.fail { + return errors.New(name + " failed") + } + if name == "ssh-keygen" { + private := args[len(args)-1] + if err := os.WriteFile(private, []byte("private"), 0o600); err != nil { + return err + } + return os.WriteFile(private+".pub", []byte(generatedKey+"\n"), 0o644) + } + return nil +} + +func (f *fakeRunner) commands(name string) [][]string { + var out [][]string + for _, c := range f.calls { + if c[0] == name { + out = append(out, c) + } + } + return out +} + +func labPlan(t *testing.T, m *mirror) *topology.Plan { + t.Helper() + img := m.image() + doc := `name: evpn-2hv +images: + debian12: + url: ` + img.URL + ` + sums: ` + img.Sums + ` +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] } +` + topo, err := topology.Parse([]byte(doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + p, err := topology.Compute(topo) + if err != nil { + t.Fatalf("Compute: %v", err) + } + return p +} + +func prepare(t *testing.T, runner *fakeRunner) (string, string, *mirror, []render.Node, error) { + t.Helper() + m := newMirror(t) + publish(m, []byte("qcow2 image")) + root := t.TempDir() + run := filepath.Join(root, "run") + cache := filepath.Join(root, "cache") + nodes, err := Prepare(context.Background(), labPlan(t, m), Options{ + RunDir: run, + Fetcher: Fetcher{Client: m.server.Client(), CacheDir: cache}, + Runner: runner, + }) + return run, cache, m, nodes, err +} + +func TestPrepare_StagesEveryNode(t *testing.T) { + runner := &fakeRunner{} + run, cache, _, nodes, err := prepare(t, runner) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + + base := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2") + want := [][]string{ + {"ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "two-lab", "-f", filepath.Join(run, "lab_ed25519")}, + } + for _, n := range []string{"sw1", "rr1", "hv1", "hv2"} { + dir := filepath.Join(run, n) + want = append(want, + []string{"qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, filepath.Join(dir, "disk.qcow2"), "20G"}, + []string{"genisoimage", "-quiet", "-output", filepath.Join(dir, "seed.iso"), "-volid", "cidata", "-joliet", "-rock", + filepath.Join(dir, "user-data"), filepath.Join(dir, "meta-data"), filepath.Join(dir, "network-config")}, + ) + } + if !reflect.DeepEqual(runner.calls, want) { + t.Errorf("commands:\n got %q\nwant %q", runner.calls, want) + } + + var names []string + for _, n := range nodes { + names = append(names, n.Name) + } + if !reflect.DeepEqual(names, []string{"sw1", "rr1", "hv1", "hv2"}) { + t.Errorf("nodes = %v", names) + } + if info, err := os.Stat(run); err != nil || info.Mode().Perm() != 0o700 { + t.Errorf("run dir mode = %v, %v", info.Mode().Perm(), err) + } +} + +func TestPrepare_DownloadsASharedImageOnce(t *testing.T) { + _, _, m, _, err := prepare(t, &fakeRunner{}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + if n := m.count("/bookworm/latest/" + imageName); n != 1 { + t.Errorf("image downloaded %d times for four nodes, want 1", n) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 1 { + t.Errorf("sums read %d times for four nodes, want 1", n) + } +} + +func TestPrepare_TheGeneratedKeyIsTheOnlyAuthorizedKey(t *testing.T) { + _, _, _, nodes, err := prepare(t, &fakeRunner{}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + for _, n := range nodes { + data, err := os.ReadFile(filepath.Join(n.Dir, "user-data")) + if err != nil { + t.Fatal(err) + } + var cfg struct { + Keys []string `yaml:"ssh_authorized_keys"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("%s user-data: %v", n.Name, err) + } + if !reflect.DeepEqual(cfg.Keys, []string{generatedKey}) { + t.Errorf("%s authorized keys = %q, want only the generated key", n.Name, cfg.Keys) + } + } +} + +func TestPrepare_WritesTheRenderedFilesPrivately(t *testing.T) { + run, _, _, nodes, err := prepare(t, &fakeRunner{}) + if err != nil { + t.Fatalf("Prepare: %v", err) + } + hv1 := filepath.Join(run, "hv1") + if nodes[2].Dir != hv1 { + t.Fatalf("hv1 dir = %s, want %s", nodes[2].Dir, hv1) + } + for _, f := range []string{"qemu.args", "user-data", "meta-data", "network-config"} { + info, err := os.Stat(filepath.Join(hv1, f)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("%s mode = %v, %v", f, info.Mode().Perm(), err) + } + } + args, err := os.ReadFile(filepath.Join(hv1, "qemu.args")) + if err != nil || !strings.HasPrefix(string(args), "-name\nhv1\n") || !strings.HasSuffix(string(args), "\n") { + t.Errorf("qemu.args = %q, %v", args, err) + } +} + +func TestPrepare_StopsAtTheFirstFailedCommand(t *testing.T) { + runner := &fakeRunner{fail: "qemu-img"} + _, _, _, _, err := prepare(t, runner) + if err == nil || !strings.Contains(err.Error(), "node sw1: qemu-img failed") { + t.Errorf("error = %v", err) + } + if n := len(runner.commands("genisoimage")); n != 0 { + t.Errorf("genisoimage ran %d times after qemu-img failed", n) + } +} + +func TestPrepare_StopsBeforeAnyCommandWhenTheImageIsWrong(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + m.put("/bookworm/latest/"+imageName, []byte("tampered")) + runner := &fakeRunner{} + root := t.TempDir() + _, err := Prepare(context.Background(), labPlan(t, m), Options{ + RunDir: filepath.Join(root, "run"), + Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")}, + Runner: runner, + }) + if err == nil || !strings.Contains(err.Error(), "sha512") { + t.Errorf("error = %v", err) + } + if len(runner.calls) != 0 { + t.Errorf("commands ran after a bad image: %q", runner.calls) + } +} + +func TestPrepare_RefusesARelativeRunDir(t *testing.T) { + m := newMirror(t) + publish(m, []byte("qcow2 image")) + runner := &fakeRunner{} + _, err := Prepare(context.Background(), labPlan(t, m), Options{ + RunDir: "run", + Fetcher: Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}, + Runner: runner, + }) + if err == nil || !strings.Contains(err.Error(), `run dir "run" must be an absolute path`) { + t.Errorf("error = %v", err) + } + if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 { + t.Errorf("sums read %d times before the run dir was checked", n) + } +} + +func TestPrepare_RefusesAnUndeclaredImage(t *testing.T) { + m := newMirror(t) + p := labPlan(t, m) + p.Images = nil + _, err := Prepare(context.Background(), p, Options{RunDir: t.TempDir(), Runner: &fakeRunner{}}) + if err == nil || !strings.Contains(err.Error(), `node sw1: image "debian12" is not declared`) { + t.Errorf("error = %v", err) + } +} + +func TestEnsureKey_GeneratesOnlyOnce(t *testing.T) { + dir := t.TempDir() + runner := &fakeRunner{} + + for i := 0; i < 2; i++ { + key, err := EnsureKey(context.Background(), runner, dir) + if err != nil || key != generatedKey { + t.Fatalf("EnsureKey #%d = %q, %v", i+1, key, err) + } + } + if n := len(runner.commands("ssh-keygen")); n != 1 { + t.Errorf("ssh-keygen ran %d times, want 1", n) + } +} + +func TestEnsureKey_Rejections(t *testing.T) { + cases := map[string]string{ + "empty": "\n", + "two keys": generatedKey + "\n" + generatedKey + "\n", + "stray break": "ssh-ed25519\rAAAA", + } + for name, pub := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "lab_ed25519"), []byte("private"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "lab_ed25519.pub"), []byte(pub), 0o644); err != nil { + t.Fatal(err) + } + if _, err := EnsureKey(context.Background(), &fakeRunner{}, dir); err == nil || !strings.Contains(err.Error(), "not a single public key") { + t.Errorf("error = %v", err) + } + }) + } +} + +func TestEnsureKey_ReportsAFailedGeneration(t *testing.T) { + if _, err := EnsureKey(context.Background(), &fakeRunner{fail: "ssh-keygen"}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "ssh-keygen failed") { + t.Errorf("error = %v", err) + } +} + +func TestStage_RefusesARelativeBaseImage(t *testing.T) { + runner := &fakeRunner{} + err := Stage(context.Background(), runner, render.Node{Name: "hv1", Dir: t.TempDir()}, "debian.qcow2") + if err == nil || !strings.Contains(err.Error(), "must be an absolute path") { + t.Errorf("error = %v", err) + } + if len(runner.calls) != 0 { + t.Errorf("commands ran: %q", runner.calls) + } +} + +func TestExecRunner_ReportsTheCommandOutput(t *testing.T) { + err := ExecRunner{}.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 3") + if err == nil || !strings.Contains(err.Error(), "sh: exit status 3: boom") { + t.Errorf("error = %v", err) + } + if err := (ExecRunner{}).Run(context.Background(), "true"); err != nil { + t.Errorf("true: %v", err) + } +} + +func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) { + dir := t.TempDir() + key, err := EnsureKey(context.Background(), ExecRunner{}, dir) + if err != nil { + t.Fatalf("EnsureKey: %v", err) + } + if !strings.HasPrefix(key, "ssh-ed25519 ") || !strings.HasSuffix(key, " two-lab") { + t.Errorf("key = %q", key) + } + info, err := os.Stat(filepath.Join(dir, "lab_ed25519")) + if err != nil || info.Mode().Perm() != 0o600 { + t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err) + } +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 5d91389..0fd0ee6 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -18,6 +18,7 @@ const ( type Plan struct { Name string + Images []Image Segments []SegmentPlan Nodes []NodePlan Cables []Cable @@ -62,7 +63,7 @@ func Compute(t *Topology) (*Plan, error) { return nil, err } - p := &Plan{Name: t.Name} + p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)} for i, n := range t.Nodes { p.Nodes = append(p.Nodes, NodePlan{ Name: n.Name, diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 9c65b6d..1621621 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -107,6 +107,14 @@ func TestCompute_TwoHypervisorsPlan(t *testing.T) { } } +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++ { From d5dbbe6c66b853d16359cd6e510784860c4d8b22 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 14:49:34 +0200 Subject: [PATCH 12/25] =?UTF-8?q?f-50:=20lab:=20lancement,=20=C3=A9tat,=20?= =?UTF-8?q?arr=C3=AAt=20et=20acc=C3=A8s=20SSH=20des=20VM=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/lifecycle.go | 193 ++++++++++ cmd/lab/main.go | 24 ++ cmd/lab/main_test.go | 180 +++++++++ cmd/lab/terminal.go | 14 + cmd/lab/terminal_darwin.go | 5 + cmd/lab/terminal_linux.go | 5 + cmd/lab/terminal_other.go | 7 + docs/developpement/lab.rst | 73 +++- internal/lab/machine/machine.go | 269 +++++++++++++ internal/lab/machine/machine_test.go | 543 +++++++++++++++++++++++++++ 10 files changed, 1309 insertions(+), 4 deletions(-) create mode 100644 cmd/lab/lifecycle.go create mode 100644 cmd/lab/terminal.go create mode 100644 cmd/lab/terminal_darwin.go create mode 100644 cmd/lab/terminal_linux.go create mode 100644 cmd/lab/terminal_other.go create mode 100644 internal/lab/machine/machine.go create mode 100644 internal/lab/machine/machine_test.go diff --git a/cmd/lab/lifecycle.go b/cmd/lab/lifecycle.go new file mode 100644 index 0000000..c91205e --- /dev/null +++ b/cmd/lab/lifecycle.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "git.g3e.fr/syonad/two/internal/lab/machine" + "git.g3e.fr/syonad/two/internal/lab/provision" +) + +const ( + topologyFile = "topology.yml" + pollInterval = 5 * time.Second + stopTimeout = 30 * time.Second +) + +var ( + execve = syscall.Exec + procDir = "/proc" +) + +func defaultDir(parts ...string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(append([]string{home}, parts...)...) +} + +func flags(name string, stderr io.Writer) (*flag.FlagSet, *string) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { fmt.Fprint(stderr, usage) } + return fs, fs.String("run", defaultDir("lab-run"), "run directory of the lab") +} + +func lab(runDir string, plan string, stdout, stderr io.Writer) (machine.Lab, bool) { + dir, err := filepath.Abs(runDir) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return machine.Lab{}, false + } + p, ok := load(plan, stderr) + if !ok { + return machine.Lab{}, false + } + return machine.Lab{ + Plan: p, + RunDir: dir, + ProcDir: procDir, + Runner: provision.ExecRunner{}, + Poll: pollInterval, + Stop: stopTimeout, + Out: stdout, + }, true +} + +func current(runDir string, stdout, stderr io.Writer) (machine.Lab, bool) { + saved := filepath.Join(runDir, topologyFile) + if _, err := os.Stat(saved); err != nil { + fmt.Fprintf(stderr, "lab: no lab in %s: %v\n", runDir, err) + return machine.Lab{}, false + } + return lab(runDir, saved, stdout, stderr) +} + +func upCmd(args []string, stdout, stderr io.Writer) int { + fs, runDir := flags("up", stderr) + cacheDir := fs.String("cache", defaultDir(".cache", "two-lab"), "image cache directory") + timeout := fs.Duration("timeout", 20*time.Minute, "how long to wait for the nodes to be ready") + if err := fs.Parse(args); err != nil || fs.NArg() != 1 { + fs.Usage() + return 2 + } + cache, err := filepath.Abs(*cacheDir) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + + if _, err := os.Stat(filepath.Join(*runDir, topologyFile)); err == nil { + previous, ok := current(*runDir, stdout, stderr) + if !ok { + return 1 + } + running, err := previous.Running() + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + if len(running) > 0 { + fmt.Fprintf(stderr, "lab: lab %s is still running in %s: 'lab down' first\n", previous.Plan.Name, previous.RunDir) + return 1 + } + } + + l, ok := lab(*runDir, fs.Arg(0), stdout, stderr) + if !ok { + return 1 + } + source, err := os.ReadFile(fs.Arg(0)) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + if err := os.MkdirAll(l.RunDir, 0o700); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + if err := os.WriteFile(filepath.Join(l.RunDir, topologyFile), source, 0o600); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + fetcher := provision.Fetcher{Client: &http.Client{}, CacheDir: cache} + if err := l.Up(ctx, fetcher, *timeout); err != nil { + fmt.Fprintf(stderr, "lab: %v\nlab: started nodes keep running: 'lab status', 'lab down'\n", err) + return 1 + } + return 0 +} + +func statusCmd(args []string, stdout, stderr io.Writer) int { + fs, runDir := flags("status", stderr) + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + fs.Usage() + return 2 + } + l, ok := current(*runDir, stdout, stderr) + if !ok { + return 1 + } + if err := l.Status(stdout); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + return 0 +} + +func downCmd(args []string, stdout, stderr io.Writer) int { + fs, runDir := flags("down", stderr) + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + fs.Usage() + return 2 + } + l, ok := current(*runDir, stdout, stderr) + if !ok { + return 1 + } + if err := l.Down(context.Background()); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + return 0 +} + +func sshCmd(args []string, stdout, stderr io.Writer) int { + fs, runDir := flags("ssh", stderr) + if err := fs.Parse(args); err != nil || fs.NArg() < 1 { + fs.Usage() + return 2 + } + l, ok := current(*runDir, stdout, stderr) + if !ok { + return 1 + } + argv, err := l.SSH(fs.Arg(0), isTerminal(os.Stdin), fs.Args()[1:]) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + path, err := exec.LookPath(argv[0]) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + if err := execve(path, argv, os.Environ()); err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + return 0 +} diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 13db735..3dc6425 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -21,6 +21,22 @@ const usage = `usage: lab [options] [dir] render -key write, for each node, //qemu.args (one argument per line) and the cloud-init seed files meta-data, user-data and network-config + + up [-run dir] [-cache dir] [-timeout duration] + download and verify the images, create fresh disks and seeds, start every node + (switches first) and wait until cloud-init has finished on each of them; + the topology is kept in /topology.yml for the commands below + + status [-run dir] + list the nodes of the lab and whether their qemu process is running + + down [-run dir] + stop every qemu process of the lab (SIGTERM, then SIGKILL) + + ssh [-run dir] [command...] + open a shell on a node, or run a command on it, with the key generated by up + + defaults: -run $HOME/lab-run, -cache $HOME/.cache/two-lab, -timeout 20m ` type keyFiles []string @@ -46,6 +62,14 @@ func run(args []string, stdout, stderr io.Writer) int { return plan(args[1], stdout, stderr) case "render": return renderCmd(args[1:], stdout, stderr) + case "up": + return upCmd(args[1:], stdout, stderr) + case "status": + return statusCmd(args[1:], stdout, stderr) + case "down": + return downCmd(args[1:], stdout, stderr) + case "ssh": + return sshCmd(args[1:], stdout, stderr) default: fmt.Fprint(stderr, usage) return 2 diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index a1cc588..e8972b8 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -3,8 +3,11 @@ package main import ( "bytes" "os" + "os/exec" "path/filepath" + "strconv" "strings" + "syscall" "testing" ) @@ -119,3 +122,180 @@ func TestRun_RenderRefusesMissingKeyFile(t *testing.T) { t.Errorf("code %d, stderr %q", code, stderr) } } + +func savedLab(t *testing.T) string { + t.Helper() + run := filepath.Join(t.TempDir(), "run") + if err := os.MkdirAll(run, 0o700); err != nil { + t.Fatal(err) + } + example, err := os.ReadFile(filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(run, "topology.yml"), example, 0o600); err != nil { + t.Fatal(err) + } + return run +} + +func TestRun_LifecycleUsage(t *testing.T) { + for _, args := range [][]string{{"up"}, {"up", "a.yml", "b.yml"}, {"status", "x"}, {"down", "x"}, {"ssh"}, {"up", "-bogus", "a.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_CommandsWithoutALabInTheRunDir(t *testing.T) { + run := t.TempDir() + for _, cmd := range []string{"status", "down", "ssh"} { + args := []string{cmd, "-run", run} + if cmd == "ssh" { + args = append(args, "hv1") + } + code, _, stderr := runLab(args...) + if code != 1 || !strings.Contains(stderr, "lab: no lab in "+run) { + t.Errorf("%s: code %d, stderr %q", cmd, code, stderr) + } + } +} + +func TestRun_StatusOfAStoppedLab(t *testing.T) { + code, stdout, stderr := runLab("status", "-run", savedLab(t)) + want := `node role state pid ssh +sw1 switch stopped - 127.0.0.1:2200 +rr1 rr stopped - 127.0.0.1:2201 +hv1 hypervisor stopped - 127.0.0.1:2202 +hv2 hypervisor stopped - 127.0.0.1:2203 +` + if code != 0 || stdout != want { + t.Errorf("code %d, stderr %q, stdout:\n%s", code, stderr, stdout) + } +} + +func TestRun_DownOfAStoppedLab(t *testing.T) { + code, stdout, stderr := runLab("down", "-run", savedLab(t)) + if code != 0 || stdout != "" || stderr != "" { + t.Errorf("code %d, stdout %q, stderr %q", code, stdout, stderr) + } +} + +func TestRun_SSHExecsSSHWithTheNodePort(t *testing.T) { + run := savedLab(t) + var gotPath string + var gotArgv []string + execve = func(path string, argv []string, env []string) error { + gotPath, gotArgv = path, argv + return nil + } + t.Cleanup(func() { execve = syscall.Exec }) + + code, _, stderr := runLab("ssh", "-run", run, "hv2", "ip", "-br", "a") + + if code != 0 { + t.Fatalf("code %d, stderr %q", code, stderr) + } + if filepath.Base(gotPath) != "ssh" { + t.Errorf("path = %q", gotPath) + } + want := []string{"ssh", + "-i", filepath.Join(run, "lab_ed25519"), + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", "2203", + "debian@127.0.0.1", + "ip", "-br", "a", + } + if strings.Join(gotArgv, " ") != strings.Join(want, " ") { + t.Errorf("\n got %q\nwant %q", gotArgv, want) + } +} + +func TestRun_SSHUnknownNode(t *testing.T) { + code, _, stderr := runLab("ssh", "-run", savedLab(t), "hv9") + if code != 1 || !strings.Contains(stderr, `node "hv9" is not in lab evpn-2hv`) { + t.Errorf("code %d, stderr %q", code, stderr) + } +} + +func TestRun_UpRefusesAnInvalidTopologyAndKeepsTheSavedOne(t *testing.T) { + run := savedLab(t) + before, _ := os.ReadFile(filepath.Join(run, "topology.yml")) + bad := filepath.Join(t.TempDir(), "bad.yml") + if err := os.WriteFile(bad, []byte("name: x\n"), 0o600); err != nil { + t.Fatal(err) + } + + code, _, stderr := runLab("up", "-run", run, bad) + + if code != 1 || !strings.Contains(stderr, "at least one node is required") { + t.Errorf("code %d, stderr %q", code, stderr) + } + if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) { + t.Error("the saved topology was replaced by an invalid one") + } +} + +func TestIsTerminal_DevNullAndPipesAreNotTerminals(t *testing.T) { + null, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer null.Close() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + for name, f := range map[string]*os.File{"/dev/null": null, "pipe": r} { + if isTerminal(f) { + t.Errorf("%s is detected as a terminal", name) + } + } +} + +func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) { + run := savedLab(t) + before, _ := os.ReadFile(filepath.Join(run, "topology.yml")) + proc := t.TempDir() + procDir = proc + t.Cleanup(func() { procDir = "/proc" }) + + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) + pid := strconv.Itoa(cmd.Process.Pid) + if err := os.MkdirAll(filepath.Join(proc, pid), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, pid, "cmdline"), []byte("qemu-system-x86_64\x00-name\x00hv1\x00"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(run, "hv1"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(run, "hv1", "qemu.pid"), []byte(pid+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + other := filepath.Join(t.TempDir(), "other.yml") + if err := os.WriteFile(other, bytes.ReplaceAll(before, []byte("evpn-2hv"), []byte("other")), 0o600); err != nil { + t.Fatal(err) + } + code, _, stderr := runLab("up", "-run", run, other) + + if code != 1 || !strings.Contains(stderr, "lab evpn-2hv is still running in "+run) { + t.Errorf("code %d, stderr %q", code, stderr) + } + if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) { + t.Error("the topology of a running lab was replaced") + } +} diff --git a/cmd/lab/terminal.go b/cmd/lab/terminal.go new file mode 100644 index 0000000..fa2cb06 --- /dev/null +++ b/cmd/lab/terminal.go @@ -0,0 +1,14 @@ +//go:build linux || darwin + +package main + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func isTerminal(f *os.File) bool { + _, err := unix.IoctlGetTermios(int(f.Fd()), ioctlReadTermios) + return err == nil +} diff --git a/cmd/lab/terminal_darwin.go b/cmd/lab/terminal_darwin.go new file mode 100644 index 0000000..4c493c7 --- /dev/null +++ b/cmd/lab/terminal_darwin.go @@ -0,0 +1,5 @@ +package main + +import "golang.org/x/sys/unix" + +const ioctlReadTermios = unix.TIOCGETA diff --git a/cmd/lab/terminal_linux.go b/cmd/lab/terminal_linux.go new file mode 100644 index 0000000..ba4230d --- /dev/null +++ b/cmd/lab/terminal_linux.go @@ -0,0 +1,5 @@ +package main + +import "golang.org/x/sys/unix" + +const ioctlReadTermios = unix.TCGETS diff --git a/cmd/lab/terminal_other.go b/cmd/lab/terminal_other.go new file mode 100644 index 0000000..a929525 --- /dev/null +++ b/cmd/lab/terminal_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package main + +import "os" + +func isTerminal(*os.File) bool { return false } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 7c0371d..c81cdd5 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,10 +13,12 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** à **E2** livrées — le cycle de vie du serveur qui portera le lab - (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan - (``lab plan``), puis la génération des arguments QEMU et des fichiers cloud-init de chaque VM - (``lab render``). Le lancement des VM viendra avec l'étape suivante, et cette page avec elle. + État actuel : étapes **E0** à **E2** livrées, **E3** en cours — le cycle de vie du serveur qui + portera le lab (``scripts/lab-host.sh``), la description de la topologie et le calcul de son + plan (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM + (``lab render``), puis leur lancement (``lab up`` / ``status`` / ``down`` / ``ssh``). Reste à + apprendre à ``lab-host.sh`` à préparer le serveur et à y déposer ``lab``, puis à valider le + tout sur le serveur loué. Le serveur de lab ----------------- @@ -359,6 +361,60 @@ renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'I Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué. +Lancement des VM +---------------- + +``lab`` s'exécute **sur le serveur de lab**. Il garde l'état du lab dans un répertoire +(``-run``, par défaut ``~/lab-run``) : ``status``, ``down`` et ``ssh`` n'ont donc pas besoin de la +topologie. + +.. code-block:: text + + lab up [-run dir] [-cache dir] [-timeout 20m] + lab status [-run dir] + lab down [-run dir] + lab ssh [-run dir] [commande…] + +``lab up`` + 1. refuse de continuer si un lab tourne déjà dans le répertoire ; + 2. télécharge chaque image dans le cache (``-cache``, par défaut ``~/.cache/two-lab``) et la + vérifie contre ``SHA512SUMS`` ; une image déjà présente et toujours conforme n'est pas + retéléchargée, la liste des sommes est relue à chaque fois ; + 3. génère une paire de clés SSH dans le répertoire du lab, si elle n'existe pas encore ; + 4. pour chaque nœud : fichiers de ``lab render``, disque **neuf** en overlay qcow2 sur + l'image (``qemu-img create -b``, 20 Gio annoncés), image ``cidata`` (``genisoimage``) ; + 5. démarre les QEMU, **switchs d'abord**, détachés (``-daemonize``) : ils survivent à la + session SSH qui les a lancés ; + 6. attend sur chaque nœud la fin de cloud-init (``cloud-init status --wait`` par SSH), + jusqu'au délai ``-timeout``. + + La topologie est copiée dans ``/topology.yml``. Un échec laisse les nœuds démarrés en + place : ``lab status``, puis ``lab down``. + +``lab status`` + Pour chaque nœud : rôle, état du processus QEMU, PID, port SSH sur la boucle locale. + +``lab down`` + Arrête chaque QEMU par ``SIGTERM``, puis ``SIGKILL`` au bout de 30 s. Les disques sont + conservés jusqu'au prochain ``up``, qui les recrée. + +``lab ssh`` + Ouvre un shell sur un nœud, ou y exécute une commande, avec la clé générée par ``up``. ``lab`` + cède la place à ``ssh``, dont le code de retour est donc celui de la commande. Un terminal + n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni + pseudo-terminal ni ``\r\n`` dans la sortie. + +Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un +processus vivant dont la ligne de commande (``/proc//cmdline``) contient ``-name ``. +Un PID réutilisé par un autre programme n'est donc jamais signalé. + +.. warning:: + + Le cache range une image sous son nom de fichier, et l'URL de Debian est ``latest`` : une + nouvelle publication remplace le fichier, et les overlays existants pointeraient sur une + base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ; + **ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur. + Facturation ----------- @@ -430,6 +486,15 @@ Sécurité production. * Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal partagé, capture d'écran) se régénère. +* **Clé SSH des VM** : générée par ``lab up`` sur le serveur, c'est la seule clé autorisée dans + les VM. Elle ne quitte jamais le serveur, n'ouvre que les VM du lab — qui n'écoutent qu'en + boucle locale — et disparaît avec lui. La clé publique du Mac n'est jamais envoyée aux VM. +* **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent + à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un + serveur auquel on s'est authentifié. +* **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification + protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian + (``SHA512SUMS.sign``) n'est pas encore vérifiée. Tests ----- diff --git a/internal/lab/machine/machine.go b/internal/lab/machine/machine.go new file mode 100644 index 0000000..1630ed7 --- /dev/null +++ b/internal/lab/machine/machine.go @@ -0,0 +1,269 @@ +package machine + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "text/tabwriter" + "time" + + "git.g3e.fr/syonad/two/internal/lab/provision" + "git.g3e.fr/syonad/two/internal/lab/render" + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const ( + User = "debian" + + sshUnreachable = 255 + cloudInitRecoverable = 2 +) + +type Lab struct { + Plan *topology.Plan + RunDir string + ProcDir string + Runner provision.Runner + Poll time.Duration + Stop time.Duration + Out io.Writer + + signal func(pid int, sig syscall.Signal) error +} + +func (l Lab) node(name string) (topology.NodePlan, error) { + for _, n := range l.Plan.Nodes { + if n.Name == name { + return n, nil + } + } + return topology.NodePlan{}, fmt.Errorf("node %q is not in lab %s", name, l.Plan.Name) +} + +func (l Lab) pidFile(name string) string { + return filepath.Join(l.RunDir, name, render.PIDFile) +} + +func (l Lab) PID(name string) (int, error) { + data, err := os.ReadFile(l.pidFile(name)) + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + if err != nil { + return 0, err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 0 { + return 0, fmt.Errorf("%s: not a pid: %q", l.pidFile(name), data) + } + if !l.runs(pid, name) { + return 0, nil + } + return pid, nil +} + +func (l Lab) runs(pid int, name string) bool { + if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) { + return false + } + cmdline, err := os.ReadFile(filepath.Join(l.ProcDir, strconv.Itoa(pid), "cmdline")) + if err != nil { + return false + } + return bytes.Contains(cmdline, []byte("\x00-name\x00"+name+"\x00")) +} + +func (l Lab) Running() ([]string, error) { + var running []string + for _, n := range l.Plan.Nodes { + pid, err := l.PID(n.Name) + if err != nil { + return nil, err + } + if pid != 0 { + running = append(running, n.Name) + } + } + return running, nil +} + +func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Duration) error { + running, err := l.Running() + if err != nil { + return err + } + if len(running) > 0 { + return fmt.Errorf("lab %s is already running (%s): 'lab down' first", l.Plan.Name, strings.Join(running, ", ")) + } + + nodes, err := provision.Prepare(ctx, l.Plan, provision.Options{RunDir: l.RunDir, Fetcher: fetcher, Runner: l.Runner}) + if err != nil { + return err + } + for _, n := range switchesFirst(l.Plan, nodes) { + if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := l.Runner.Run(ctx, render.QEMUBinary, append(n.QEMU, "-daemonize")...); err != nil { + return fmt.Errorf("node %s: %w", n.Name, err) + } + fmt.Fprintf(l.Out, "%s: started\n", n.Name) + } + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var errs []error + for _, n := range l.Plan.Nodes { + if err := l.waitReady(ctx, n); err != nil { + errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) + continue + } + fmt.Fprintf(l.Out, "%s: ready\n", n.Name) + } + return errors.Join(errs...) +} + +func switchesFirst(p *topology.Plan, nodes []render.Node) []render.Node { + var first, rest []render.Node + for i, n := range nodes { + if p.Nodes[i].Role == topology.RoleSwitch { + first = append(first, n) + } else { + rest = append(rest, n) + } + } + return append(first, rest...) +} + +func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error { + args := l.sshArgs(n, true, []string{"cloud-init", "status", "--wait"}) + for { + err := l.Runner.Run(ctx, "ssh", args...) + switch code := exitCode(err); { + case err == nil: + return nil + case code == cloudInitRecoverable: + fmt.Fprintf(l.Out, "%s: cloud-init finished with recoverable errors: %v\n", n.Name, err) + return nil + case code != sshUnreachable: + return fmt.Errorf("cloud-init failed: %w", err) + } + select { + case <-ctx.Done(): + return fmt.Errorf("not reachable over ssh: %w", ctx.Err()) + case <-time.After(l.Poll): + } + } +} + +func exitCode(err error) int { + var coded interface{ ExitCode() int } + if errors.As(err, &coded) { + return coded.ExitCode() + } + return -1 +} + +func (l Lab) sshArgs(n topology.NodePlan, batch bool, command []string) []string { + args := []string{ + "-i", filepath.Join(l.RunDir, provision.KeyFile), + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", strconv.Itoa(n.SSHPort), + } + if batch { + args = append(args, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5") + } + args = append(args, User+"@127.0.0.1") + return append(args, command...) +} + +func (l Lab) SSH(name string, terminal bool, command []string) ([]string, error) { + n, err := l.node(name) + if err != nil { + return nil, err + } + args := []string{"ssh"} + if terminal && len(command) > 0 { + args = append(args, "-t") + } + return append(args, l.sshArgs(n, false, command)...), nil +} + +func (l Lab) Status(w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "node\trole\tstate\tpid\tssh\n") + for _, n := range l.Plan.Nodes { + pid, err := l.PID(n.Name) + if err != nil { + return err + } + state, shown := "stopped", "-" + if pid != 0 { + state, shown = "running", strconv.Itoa(pid) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t127.0.0.1:%d\n", n.Name, n.Role, state, shown, n.SSHPort) + } + return tw.Flush() +} + +func (l Lab) Down(ctx context.Context) error { + var errs []error + for _, n := range l.Plan.Nodes { + pid, err := l.PID(n.Name) + if err != nil { + errs = append(errs, err) + continue + } + if pid == 0 { + continue + } + if err := l.stop(ctx, n.Name, pid); err != nil { + errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) + continue + } + if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + continue + } + fmt.Fprintf(l.Out, "%s: stopped\n", n.Name) + } + return errors.Join(errs...) +} + +func (l Lab) stop(ctx context.Context, name string, pid int) error { + if pid <= 0 { + return fmt.Errorf("refusing to signal pid %d", pid) + } + send := l.signal + if send == nil { + send = syscall.Kill + } + for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL} { + if err := send(pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + deadline := time.Now().Add(l.Stop) + for l.runs(pid, name) && time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(l.Poll): + } + } + if !l.runs(pid, name) { + return nil + } + } + return fmt.Errorf("pid %d still running after SIGKILL", pid) +} diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go new file mode 100644 index 0000000..22635d6 --- /dev/null +++ b/internal/lab/machine/machine_test.go @@ -0,0 +1,543 @@ +package machine + +import ( + "bytes" + "context" + "crypto/sha512" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "git.g3e.fr/syonad/two/internal/lab/provision" + "git.g3e.fr/syonad/two/internal/lab/topology" +) + +const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab" + +type exitErr int + +func (e exitErr) Error() string { return "exit status " + strconv.Itoa(int(e)) } +func (e exitErr) ExitCode() int { return int(e) } + +type fakeRunner struct { + mu sync.Mutex + calls [][]string + fail string + ssh map[string][]error + always map[string]error +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, append([]string{name}, args...)) + if name == f.fail { + return errors.New(name + " failed") + } + switch name { + case "ssh-keygen": + private := args[len(args)-1] + if err := os.WriteFile(private, []byte("private"), 0o600); err != nil { + return err + } + return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644) + case "ssh": + port := args[indexOf(args, "-p")+1] + answers := f.ssh[port] + if len(answers) == 0 { + return f.always[port] + } + f.ssh[port] = answers[1:] + return answers[0] + } + return nil +} + +func (f *fakeRunner) commands(name string) [][]string { + f.mu.Lock() + defer f.mu.Unlock() + var out [][]string + for _, c := range f.calls { + if c[0] == name { + out = append(out, c) + } + } + return out +} + +func indexOf(list []string, value string) int { + for i, v := range list { + if v == value { + return i + } + } + return -1 +} + +func mirror(t *testing.T) (*httptest.Server, string) { + t.Helper() + content := []byte("qcow2 image") + h := sha512.Sum512(content) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "SHA512SUMS") { + fmt.Fprintf(w, "%s deb.qcow2\n", hex.EncodeToString(h[:])) + return + } + w.Write(content) + })) + t.Cleanup(server.Close) + return server, server.URL +} + +func labPlan(t *testing.T, base, nodes string) *topology.Plan { + t.Helper() + doc := `name: evpn-2hv +images: + deb: + url: ` + base + `/deb.qcow2 + sums: ` + base + `/SHA512SUMS +segments: + underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } +nodes: +` + nodes + topo, err := topology.Parse([]byte(doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + p, err := topology.Compute(topo) + if err != nil { + t.Fatalf("Compute: %v", err) + } + return p +} + +const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } + sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } +` + +type fixture struct { + lab Lab + runner *fakeRunner + out *bytes.Buffer + fetcher provision.Fetcher +} + +func newFixture(t *testing.T, nodes string) *fixture { + t.Helper() + server, base := mirror(t) + root := t.TempDir() + runner := &fakeRunner{ssh: map[string][]error{}, always: map[string]error{}} + out := &bytes.Buffer{} + return &fixture{ + lab: Lab{ + Plan: labPlan(t, base, nodes), + RunDir: filepath.Join(root, "run"), + ProcDir: filepath.Join(root, "proc"), + Runner: runner, + Poll: time.Millisecond, + Stop: 500 * time.Millisecond, + Out: out, + }, + runner: runner, + out: out, + fetcher: provision.Fetcher{Client: server.Client(), CacheDir: filepath.Join(root, "cache")}, + } +} + +func (f *fixture) process(t *testing.T, node, script string, cmdline ...string) int { + t.Helper() + cmd := exec.Command("sh", "-c", script) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { cmd.Wait(); close(done) }() + t.Cleanup(func() { + cmd.Process.Kill() + <-done + }) + pid := cmd.Process.Pid + proc := filepath.Join(f.lab.ProcDir, strconv.Itoa(pid)) + if err := os.MkdirAll(proc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte(strings.Join(cmdline, "\x00")+"\x00"), 0o644); err != nil { + t.Fatal(err) + } + f.pidFile(t, node, strconv.Itoa(pid)+"\n") + return pid +} + +func (f *fixture) pidFile(t *testing.T, node, content string) { + t.Helper() + dir := filepath.Join(f.lab.RunDir, node) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "qemu.pid"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func qemu(node string) []string { + return []string{"qemu-system-x86_64", "-name", node, "-machine", "q35"} +} + +func alive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func waitDead(t *testing.T, pid int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for alive(pid) && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + } + if alive(pid) { + t.Errorf("pid %d is still alive", pid) + } +} + +func TestPID_NoPidFileMeansStopped(t *testing.T) { + f := newFixture(t, switchLast) + if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil { + t.Errorf("PID = %d, %v", pid, err) + } +} + +func TestPID_RefusesACorruptedPidFile(t *testing.T) { + for _, content := range []string{"", "abc", "0", "-12"} { + f := newFixture(t, switchLast) + f.pidFile(t, "hv1", content) + if _, err := f.lab.PID("hv1"); err == nil || !strings.Contains(err.Error(), "not a pid") { + t.Errorf("pid file %q: error = %v", content, err) + } + } +} + +func TestPID_RecognisesTheNodeProcess(t *testing.T) { + f := newFixture(t, switchLast) + want := f.process(t, "hv1", "sleep 30", qemu("hv1")...) + if pid, err := f.lab.PID("hv1"); pid != want || err != nil { + t.Errorf("PID = %d, %v, want %d", pid, err, want) + } +} + +func TestPID_IgnoresAPidReusedByAnotherProcess(t *testing.T) { + cases := map[string][]string{ + "another program": {"/usr/sbin/sshd", "-D"}, + "another node": qemu("hv10"), + "name as a value": {"qemu-system-x86_64", "-serial", "-name", "-name", "hv2"}, + } + for name, cmdline := range cases { + t.Run(name, func(t *testing.T) { + f := newFixture(t, switchLast) + f.process(t, "hv1", "sleep 30", cmdline...) + if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil { + t.Errorf("PID = %d, %v, want 0", pid, err) + } + }) + } +} + +func TestPID_IgnoresADeadProcess(t *testing.T) { + f := newFixture(t, switchLast) + pid := f.process(t, "hv1", "exit 0", qemu("hv1")...) + waitDead(t, pid) + if got, err := f.lab.PID("hv1"); got != 0 || err != nil { + t.Errorf("PID = %d, %v, want 0", got, err) + } +} + +func TestStatus(t *testing.T) { + f := newFixture(t, switchLast) + pid := strconv.Itoa(f.process(t, "hv1", "sleep 30", qemu("hv1")...)) + var out bytes.Buffer + if err := f.lab.Status(&out); err != nil { + t.Fatal(err) + } + w := len(pid) + if w < 3 { + w = 3 + } + want := fmt.Sprintf("node role state %-*s ssh\n", w, "pid") + + fmt.Sprintf("rr1 rr stopped %-*s 127.0.0.1:2200\n", w, "-") + + fmt.Sprintf("hv1 hypervisor running %-*s 127.0.0.1:2201\n", w, pid) + + fmt.Sprintf("sw1 switch stopped %-*s 127.0.0.1:2202\n", w, "-") + if out.String() != want { + t.Errorf("status:\n%s\nwant:\n%s", out.String(), want) + } +} + +func TestDown_StopsWithSIGTERM(t *testing.T) { + f := newFixture(t, switchLast) + pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...) + + if err := f.lab.Down(context.Background()); err != nil { + t.Fatalf("Down: %v", err) + } + + waitDead(t, pid) + if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) { + t.Errorf("pid file still there: %v", err) + } + if f.out.String() != "hv1: stopped\n" { + t.Errorf("output = %q", f.out.String()) + } +} + +func TestDown_FallsBackToSIGKILL(t *testing.T) { + f := newFixture(t, switchLast) + f.lab.Stop = 100 * time.Millisecond + pid := f.process(t, "hv1", `trap "" TERM; while :; do sleep 0.05; done`, qemu("hv1")...) + time.Sleep(50 * time.Millisecond) + + start := time.Now() + if err := f.lab.Down(context.Background()); err != nil { + t.Fatalf("Down: %v", err) + } + + waitDead(t, pid) + if elapsed := time.Since(start); elapsed < 100*time.Millisecond { + t.Errorf("SIGKILL after %v, want at least the 100ms grace period", elapsed) + } +} + +func TestDown_NeverSignalsAProcessThatIsNotTheNode(t *testing.T) { + f := newFixture(t, switchLast) + pid := f.process(t, "hv1", "sleep 30", "/usr/sbin/sshd", "-D") + + if err := f.lab.Down(context.Background()); err != nil { + t.Fatalf("Down: %v", err) + } + + time.Sleep(50 * time.Millisecond) + if !alive(pid) { + t.Error("a process that is not the node was killed") + } + if f.out.Len() != 0 { + t.Errorf("output = %q", f.out.String()) + } +} + +func TestDown_ReportsACorruptedPidFileAndStopsTheOthers(t *testing.T) { + f := newFixture(t, switchLast) + f.pidFile(t, "rr1", "garbage") + pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...) + + err := f.lab.Down(context.Background()) + + if err == nil || !strings.Contains(err.Error(), "not a pid") { + t.Errorf("error = %v", err) + } + waitDead(t, pid) +} + +func TestSSH_Arguments(t *testing.T) { + f := newFixture(t, switchLast) + key := filepath.Join(f.lab.RunDir, "lab_ed25519") + base := []string{ + "-i", key, + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", "2201", + "debian@127.0.0.1", + } + cases := map[string]struct { + terminal bool + command []string + want []string + }{ + "interactive shell": {true, nil, append([]string{"ssh"}, base...)}, + "command from a terminal": {true, []string{"top"}, append(append([]string{"ssh", "-t"}, base...), "top")}, + "command from a script": {false, []string{"ip", "-br", "a"}, append(append([]string{"ssh"}, base...), "ip", "-br", "a")}, + "shell from a script": {false, nil, append([]string{"ssh"}, base...)}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + got, err := f.lab.SSH("hv1", c.terminal, c.command) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("\n got %q\nwant %q", got, c.want) + } + }) + } +} + +func TestSSH_UnknownNode(t *testing.T) { + f := newFixture(t, switchLast) + if _, err := f.lab.SSH("hv9", true, nil); err == nil || !strings.Contains(err.Error(), `node "hv9" is not in lab evpn-2hv`) { + t.Errorf("error = %v", err) + } +} + +func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) { + f := newFixture(t, switchLast) + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + + var started []string + for _, c := range f.runner.commands("qemu-system-x86_64") { + if c[len(c)-1] != "-daemonize" { + t.Errorf("qemu not daemonized: %q", c) + } + started = append(started, c[indexOf(c, "-name")+1]) + } + if !reflect.DeepEqual(started, []string{"sw1", "rr1", "hv1"}) { + t.Errorf("start order = %v, want the switch first", started) + } + + ssh := f.runner.commands("ssh") + if len(ssh) != 3 { + t.Fatalf("%d ssh calls, want 3", len(ssh)) + } + want := []string{"ssh", + "-i", filepath.Join(f.lab.RunDir, "lab_ed25519"), + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", "2200", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=5", + "debian@127.0.0.1", + "cloud-init", "status", "--wait", + } + if !reflect.DeepEqual(ssh[0], want) { + t.Errorf("readiness check:\n got %q\nwant %q", ssh[0], want) + } + if f.out.String() != "sw1: started\nrr1: started\nhv1: started\nrr1: ready\nhv1: ready\nsw1: ready\n" { + t.Errorf("output = %q", f.out.String()) + } +} + +func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) { + f := newFixture(t, switchLast) + f.runner.ssh["2201"] = []error{exitErr(255), exitErr(255), nil} + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + if n := len(f.runner.commands("ssh")); n != 5 { + t.Errorf("%d ssh calls, want 5", n) + } +} + +func TestUp_ReadinessFailures(t *testing.T) { + cases := map[string]struct { + answers []error + want string + }{ + "cloud-init error": {[]error{exitErr(1)}, "node hv1: cloud-init failed: exit status 1"}, + "not an exit status": {[]error{errors.New("fork failed")}, "node hv1: cloud-init failed: fork failed"}, + "ssh never reachable": {nil, "node hv1: not reachable over ssh: context deadline exceeded"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + f := newFixture(t, switchLast) + f.lab.Poll = 10 * time.Millisecond + f.runner.ssh["2201"] = c.answers + f.runner.always["2201"] = exitErr(255) + + err := f.lab.Up(context.Background(), f.fetcher, 300*time.Millisecond) + + if err == nil || err.Error() != c.want { + t.Errorf("error = %v, want %q", err, c.want) + } + if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") { + t.Errorf("the other nodes were not waited for: %q", f.out.String()) + } + }) + } +} + +func TestUp_RecoverableCloudInitErrorsAreReportedNotFatal(t *testing.T) { + f := newFixture(t, switchLast) + f.runner.ssh["2201"] = []error{exitErr(2)} + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + if !strings.Contains(f.out.String(), "hv1: cloud-init finished with recoverable errors: exit status 2\nhv1: ready\n") { + t.Errorf("output = %q", f.out.String()) + } +} + +func TestUp_RefusesARunningLab(t *testing.T) { + f := newFixture(t, switchLast) + f.process(t, "hv1", "sleep 30", qemu("hv1")...) + + err := f.lab.Up(context.Background(), f.fetcher, time.Second) + + if err == nil || err.Error() != "lab evpn-2hv is already running (hv1): 'lab down' first" { + t.Errorf("error = %v", err) + } + if len(f.runner.calls) != 0 { + t.Errorf("commands ran: %q", f.runner.calls) + } +} + +func TestUp_RemovesAStalePidFileBeforeStarting(t *testing.T) { + f := newFixture(t, switchLast) + pid := f.process(t, "hv1", "exit 0", qemu("hv1")...) + waitDead(t, pid) + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) { + t.Errorf("stale pid file kept: %v", err) + } +} + +func TestUp_StopsAtTheFirstQEMUFailure(t *testing.T) { + f := newFixture(t, switchLast) + f.runner.fail = "qemu-system-x86_64" + + err := f.lab.Up(context.Background(), f.fetcher, time.Second) + + if err == nil || err.Error() != "node sw1: qemu-system-x86_64 failed" { + t.Errorf("error = %v", err) + } + if n := len(f.runner.commands("qemu-system-x86_64")); n != 1 { + t.Errorf("%d qemu starts, want 1", n) + } + if n := len(f.runner.commands("ssh")); n != 0 { + t.Errorf("%d ssh calls after a failed start", n) + } +} + +func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) { + f := newFixture(t, switchLast) + f.lab.signal = func(pid int, sig syscall.Signal) error { + t.Fatalf("signal %v sent to pid %d", sig, pid) + return nil + } + for _, pid := range []int{0, -1} { + if err := f.lab.stop(context.Background(), "hv1", pid); err == nil || err.Error() != fmt.Sprintf("refusing to signal pid %d", pid) { + t.Errorf("pid %d: error = %v", pid, err) + } + } +} From f28a8c22115e79cb13e47d2783025573aafb4114 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 16:31:14 +0200 Subject: [PATCH 13/25] =?UTF-8?q?f-50:=20lab:=20pr=C3=A9paration=20du=20se?= =?UTF-8?q?rveur,=20d=C3=A9p=C3=B4t=20de=20lab=20et=20validation=20sur=20S?= =?UTF-8?q?caleway=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/developpement/lab.rst | 120 ++++++++++++++++++++++++++++++--- scripts/lab-host.sh | 68 ++++++++++++++++++- scripts/lab-host_test.sh | 135 ++++++++++++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 13 deletions(-) mode change 100644 => 100755 scripts/lab-host.sh mode change 100644 => 100755 scripts/lab-host_test.sh diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index c81cdd5..8f56789 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,12 +13,12 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** à **E2** livrées, **E3** en cours — le cycle de vie du serveur qui - portera le lab (``scripts/lab-host.sh``), la description de la topologie et le calcul de son - plan (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM - (``lab render``), puis leur lancement (``lab up`` / ``status`` / ``down`` / ``ssh``). Reste à - apprendre à ``lab-host.sh`` à préparer le serveur et à y déposer ``lab``, puis à valider le - tout sur le serveur loué. + État actuel : étapes **E0** à **E3** livrées — le cycle de vie du serveur qui porte le lab + (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan + (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM + (``lab render``), puis leur lancement sur le serveur (``lab up`` / ``status`` / ``down`` / + ``ssh``). Les rôles — FRR sur le switch et le route reflector, two sur les hyperviseurs — + viendront avec l'étape suivante. Le serveur de lab ----------------- @@ -109,9 +109,15 @@ Commandes plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création et le prix ; ne crée rien - up crée le serveur de lab, attend la fin de son installation et son SSH + up crée le serveur de lab, attend la fin de son installation et son SSH, + puis le prépare (voir prepare) status liste les serveurs de lab du projet - ssh [commande] se connecte au serveur de lab + ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé + que si l'entrée standard en est un + prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie + /dev/kvm et la virtualisation imbriquée ; lancé aussi par up + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et + ~/ ; ensuite : ssh './lab up ' down supprime tous les serveurs de lab du projet et attend leur disparition session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive @@ -165,6 +171,21 @@ 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 à la charge de l'utilisateur. +Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécute sur le serveur +(voir `Lancement des VM`_) : + +.. code-block:: text + + scripts/lab-host.sh up + scripts/lab-host.sh push conf/lab/evpn-2hv.yml + scripts/lab-host.sh ssh './lab up evpn-2hv.yml' + scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1 + scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé + scripts/lab-host.sh down + +``push`` transfère par la connexion SSH du script (``cat`` côté serveur, fichier renommé une fois +complet) : mêmes options, même clé, même ``known_hosts`` que ``ssh``. + Topologie --------- @@ -404,6 +425,21 @@ topologie. n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni pseudo-terminal ni ``\r\n`` dans la sortie. + Comme ``ssh``, ``lab ssh`` recolle ses arguments par des espaces et les confie à un shell + distant — et depuis le Mac, il y en a **deux** : celui du serveur, puis celui de la VM. + Une commande qui contient elle-même des guillemets se passe en une seule chaîne : + + .. code-block:: text + + $ echo | scripts/lab-host.sh ssh './lab ssh hv1 sh -c "exit 42"'; echo "rc 42=$?" + rc 42=0 + $ echo | scripts/lab-host.sh ssh "./lab ssh hv1 'sh -c \"exit 42\"'"; echo "rc 42=$?" + rc 42=42 + + Dans le premier cas, la VM reçoit ``sh -c exit 42`` : ``exit`` sans argument, ``42`` en + ``$0``. Pour plus d'une commande, passer un script sur l'entrée standard : + ``scripts/lab-host.sh ssh "./lab ssh hv1 'sudo bash -s'" < script.sh``. + Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un processus vivant dont la ligne de commande (``/proc//cmdline``) contient ``-name ``. Un PID réutilisé par un autre programme n'est donc jamais signalé. @@ -415,6 +451,74 @@ Un PID réutilisé par un autre programme n'est donc jamais signalé. base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ; **ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur. +Une campagne réelle, de la création du serveur à la première commande sur une VM — sorties du +2026-10-04 : + +.. code-block:: text + + $ scripts/lab-host.sh up + == création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT) + … + == préparation du serveur : qemu, genisoimage, KVM imbriqué + … + qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y + == prêt : root@ + $ scripts/lab-host.sh push conf/lab/evpn-2hv.yml + == compilation de lab (linux/amd64) + == déposés sur le serveur : ~/lab, ~/evpn-2hv.yml — ensuite : lab-host.sh ssh './lab up evpn-2hv.yml' + $ scripts/lab-host.sh ssh './lab up evpn-2hv.yml' + sw1: started + rr1: started + hv1: started + hv2: started + sw1: ready + rr1: ready + hv1: ready + hv2: ready + $ scripts/lab-host.sh ssh './lab status' + node role state pid ssh + sw1 switch running 5158 127.0.0.1:2200 + rr1 rr running 5171 127.0.0.1:2201 + hv1 hypervisor running 5182 127.0.0.1:2202 + hv2 hypervisor running 5196 127.0.0.1:2203 + +``lab up`` a pris **49 secondes**, téléchargement et vérification de l'image (427 Mio) compris ; +l'essentiel du temps d'une campagne est la livraison du serveur (environ 25 minutes avec +``prepare``). + +Vérifié sur le serveur de lab +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, topologie +``evpn-2hv`` : + +.. list-table:: + :header-rows: 1 + :widths: 60 40 + + * - Vérification + - Résultat + * - ``ping -M do -s 8972`` de hv1 à hv2 à travers le switch + - passe + * - ``ping -M do -s 8973`` + - refusé : ``message too long, mtu=9000`` + * - sortie Internet de hv1 + - par ``10.250.0.1`` (le switch), HTTPS 200 ; aucune route IPv6 globale + * - hv1 vers un service TCP du serveur par ``mgmt0`` — sw1, témoin, y parvient (200) + - refusé + * - hv1 vers Internet par ``mgmt0``, route forcée via ``10.0.2.2`` — sw1 y parvient + - refusé + * - ``/dev/kvm`` et ``nested`` dans hv1 + - présent, ``Y`` + * - racine de hv1 (overlay de 20 Gio) + - 20 Go : ``growpart`` agrandit la partition au premier démarrage + * - code de retour à travers ``lab-host.sh ssh`` et ``lab ssh``, sans terminal + - propagé jusqu'au Mac + * - ``scripts/lab-host.sh ssh './lab ssh hv1'`` depuis un terminal + - shell interactif + * - ``lab down`` puis ``lab up`` d'une autre topologie + - conforme + Facturation ----------- diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh old mode 100644 new mode 100755 index 56015d7..412fd6a --- a/scripts/lab-host.sh +++ b/scripts/lab-host.sh @@ -15,6 +15,7 @@ HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}" HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}" STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}" SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CREATED_ID="" info () { echo "== ${1}" >&2; } @@ -29,7 +30,12 @@ usage: ${0##*/} [arguments] et le prix ; ne crée rien up crée le serveur de lab, attend la fin de son installation et son SSH status liste les serveurs de lab du projet - ssh [commande] se connecte au serveur de lab + ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé + que si l'entrée standard en est un + prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie + /dev/kvm et la virtualisation imbriquée ; lancé aussi par up + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et + ~/ ; ensuite : ssh './lab up ' down supprime tous les serveurs de lab du projet et attend leur disparition session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive @@ -270,6 +276,7 @@ cmd_up () { printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user" : > "${STATE_DIR}/known_hosts" wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'" + cmd_prepare || die "préparation du serveur échouée — il est toujours facturé : '${0##*/} down'" info "prêt : ${SSH_USER:-root}@${IP}" } @@ -283,9 +290,62 @@ cmd_status () { jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}" } -cmd_ssh () { +require_known_server () { [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord" - ssh_run -- "$@" +} + +cmd_ssh () { + require_known_server + if [[ $# -gt 0 && -t 0 ]]; then + ssh_run -t -- "$@" + else + ssh_run -- "$@" + fi +} + +prepare_script () { + cat <<'EOF' +set -eu +SUDO= +[ "$(id -u)" -eq 0 ] || SUDO=sudo +$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq +$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage +[ "$(id -u)" -eq 0 ] || $SUDO usermod -aG kvm "$(id -un)" +[ -c /dev/kvm ] || { echo "/dev/kvm absent" >&2; exit 1; } +NESTED=$(cat /sys/module/kvm_intel/parameters/nested 2>/dev/null || cat /sys/module/kvm_amd/parameters/nested 2>/dev/null || true) +case "${NESTED}" in + Y|1) ;; + *) echo "virtualisation imbriquée désactivée (nested=${NESTED:-absent})" >&2; exit 1 ;; +esac +echo "qemu $(qemu-system-x86_64 --version | head -n 1), nested=${NESTED}" +EOF +} + +cmd_prepare () { + require_known_server + info "préparation du serveur : qemu, genisoimage, KVM imbriqué" + prepare_script | ssh_run -- bash -s +} + +push_file () { + local SOURCE="${1}" TARGET="${2}" MODE="${3}" + ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}" +} + +cmd_push () { + local TOPOLOGY="${1:-}" + local NAME="${TOPOLOGY##*/}" + local BINARY="${STATE_DIR}/lab" + [[ $# -eq 1 && -n "${TOPOLOGY}" ]] || usage + [[ -f "${TOPOLOGY}" ]] || die "topologie introuvable : ${TOPOLOGY}" + [[ "${NAME}" =~ ^[A-Za-z0-9._-]+$ ]] || die "nom de topologie refusé : ${NAME} (lettres, chiffres, '.', '_', '-')" + require_known_server + info "compilation de lab (linux/amd64)" + (cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \ + || die "compilation de lab échouée" + push_file "${BINARY}" lab 755 || die "envoi de lab échoué" + push_file "${TOPOLOGY}" "${NAME}" 644 || die "envoi de ${NAME} échoué" + info "déposés sur le serveur : ~/lab, ~/${NAME} — ensuite : ${0##*/} ssh './lab up ${NAME}'" } delete_server () { @@ -385,6 +445,8 @@ main () { up) require_env; cmd_up ;; status) require_env; cmd_status ;; ssh) cmd_ssh "$@" ;; + prepare) cmd_prepare || die "préparation du serveur échouée" ;; + push) cmd_push "$@" ;; down) require_env; cmd_down ;; session) require_env; cmd_session "$@" ;; *) usage ;; diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh old mode 100644 new mode 100755 index e2a93ed..9db9776 --- a/scripts/lab-host_test.sh +++ b/scripts/lab-host_test.sh @@ -144,11 +144,28 @@ case "$*" in exit 255 fi exit 0 ;; + *"bash -s"*) + cat > "${FAKE_DIR}/prepare.sh" + exit "${FAKE_PREPARE_RC:-0}" ;; + *"cat > "*) + N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.') + cat > "${FAKE_DIR}/pushed.$(( N + 1 ))" + exit "${FAKE_PUSH_RC:-0}" ;; esac [ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}" exit "${FAKE_SSH_RC:-0}" SH - chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" + cat > "${DIR}/bin/go" <<'SH' +#!/usr/bin/env bash +printf '%s|%s|CGO_ENABLED=%s GOOS=%s GOARCH=%s\n' "${PWD}" "$*" "${CGO_ENABLED:-}" "${GOOS:-}" "${GOARCH:-}" >> "${FAKE_DIR}/go.log" +[ -n "${FAKE_GO_FAIL:-}" ] && exit 1 +while [ $# -gt 0 ]; do + [ "${1}" = "-o" ] && printf 'binaire-lab\n' > "${2}" + shift +done +exit 0 +SH + chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" "${DIR}/bin/go" } base_state () { @@ -200,7 +217,8 @@ lab_env () { [[ "${1}" == "exec" ]] && { EXEC="exec"; shift; } ${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \ - FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" \ + FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" FAKE_PREPARE_RC="${FAKE_PREPARE_RC:-0}" \ + FAKE_PUSH_RC="${FAKE_PUSH_RC:-0}" FAKE_GO_FAIL="${FAKE_GO_FAIL:-}" \ SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \ SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \ LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \ @@ -587,6 +605,119 @@ test_ssh_falls_back_to_agent_without_lab_key () { teardown } +known_server () { + mkdir -p "${WORK}/.cache/two-lab" + echo 203.0.113.7 > "${WORK}/.cache/two-lab/ip" + echo debian > "${WORK}/.cache/two-lab/user" + : > "${WORK}/.cache/two-lab/known_hosts" +} + +test_up_prepares_the_server_after_ssh () { + setup "up : prépare le serveur (qemu, genisoimage, KVM imbriqué) une fois SSH joignable" + mutate '.progress = [["ready","completed"]]' + run_lab up || fail "code de sortie $?" + [[ $(tail -n 1 "${WORK}/ssh.log") == *"debian@203.0.113.7 bash -s" ]] || fail "préparation non lancée en dernier : $(tail -n 1 "${WORK}/ssh.log")" + grep -q "apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage" "${WORK}/prepare.sh" || fail "paquets absents du script" + grep -q 'usermod -aG kvm' "${WORK}/prepare.sh" || fail "groupe kvm absent du script" + grep -q -- '-c /dev/kvm' "${WORK}/prepare.sh" || fail "contrôle de /dev/kvm absent" + grep -q 'kvm_intel/parameters/nested' "${WORK}/prepare.sh" || fail "contrôle de nested absent" + teardown +} + +test_up_reports_a_billed_server_when_preparation_fails () { + setup "up : préparation échouée signalée, serveur toujours facturé" + mutate '.progress = [["ready","completed"]]' + FAKE_PREPARE_RC=1 run_lab up && fail "up a réussi" + grep -q "préparation du serveur échouée — il est toujours facturé" "${WORK}/out.log" || fail "message absent" + grep -q "prêt :" "${WORK}/out.log" && fail "serveur annoncé prêt" + teardown +} + +test_session_deletes_server_when_preparation_fails () { + setup "session : serveur supprimé si la préparation échoue, commande jamais lancée" + mutate '.progress = [["ready","completed"]]' + FAKE_PREPARE_RC=1 run_lab session uname && fail "session a réussi" + grep -q " uname" "${WORK}/ssh.log" && fail "commande lancée malgré la préparation échouée" + [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" + teardown +} + +test_prepare_script_is_valid_shell () { + setup "prepare : le script distant est du shell valide" + known_server + run_lab prepare || fail "code de sortie $?" + bash -n "${WORK}/prepare.sh" || fail "bash -n refuse le script" + sh -n "${WORK}/prepare.sh" || fail "sh -n refuse le script" + teardown +} + +test_prepare_without_known_server_is_refused () { + setup "prepare : refusé sans serveur connu" + run_lab prepare && fail "prepare a réussi" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + teardown +} + +test_push_builds_for_linux_and_sends_binary_and_topology () { + setup "push : compile lab pour linux/amd64, envoie le binaire et la topologie" + known_server + printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml" + run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?" + local REPO + REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)" + [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \ + || fail "compilation : $(cat "${WORK}/go.log")" + grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent" + grep -q "debian@203.0.113.7 cat > 'evpn-2hv.yml.part' && chmod 644 'evpn-2hv.yml.part' && mv 'evpn-2hv.yml.part' 'evpn-2hv.yml'" "${WORK}/ssh.log" || fail "envoi de la topologie absent" + [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")" + [[ $(cat "${WORK}/pushed.2") == "name: evpn-2hv" ]] || fail "contenu de la topologie : $(cat "${WORK}/pushed.2")" + teardown +} + +test_push_stops_when_the_build_fails () { + setup "push : rien n'est envoyé si la compilation échoue" + known_server + printf 'name: x\n' > "${WORK}/x.yml" + FAKE_GO_FAIL=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + grep -q "compilation de lab échouée" "${WORK}/out.log" || fail "message absent" + teardown +} + +test_push_fails_when_a_transfer_fails () { + setup "push : un envoi échoué fait échouer push" + known_server + printf 'name: x\n' > "${WORK}/x.yml" + FAKE_PUSH_RC=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" + grep -q "envoi de lab échoué" "${WORK}/out.log" || fail "message absent" + teardown +} + +test_push_refusals () { + local CASE + for CASE in absent quote nothing; do + setup "push : refus (${CASE})" + known_server + case "${CASE}" in + absent) run_lab push "${WORK}/absent.yml" && fail "push a réussi" ;; + quote) printf 'x\n' > "${WORK}/a'b.yml"; run_lab push "${WORK}/a'b.yml" && fail "push a réussi" ;; + nothing) run_lab push && fail "push a réussi" ;; + esac + [[ -s "${WORK}/go.log" ]] && fail "compilation lancée" + [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" + teardown + done +} + +test_ssh_without_terminal_does_not_ask_for_one () { + setup "ssh : pas de -t quand l'entrée n'est pas un terminal" + known_server + run_lab ssh './lab status' < /dev/null || fail "code de sortie $?" + [[ $(cat "${WORK}/ssh.log") == *"debian@203.0.113.7 ./lab status" ]] || fail "commande : $(cat "${WORK}/ssh.log")" + grep -q -- " -t " "${WORK}/ssh.log" && fail "-t demandé sans terminal" + teardown +} + run_lab_without_env_credentials () { env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \ From 0c36ed8977ed0a8e167f732ce8c0c592c125685f Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 16:34:27 +0200 Subject: [PATCH 14/25] f-50: deploy: exit 0 when the deployment succeeds Signed-off-by: GnomeZworc --- scripts/deploy.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index d6b1828..b654197 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -387,5 +387,6 @@ main () { switch_binaries "${FLAGS_dryrun}" "${FLAGS_profile}" "${ASSETS}" } -[[ "${BASH_SOURCE[0]}" == "${0}" ]] && (main "$@" || exit 1) -[[ "${BASH_SOURCE[0]}" == "" ]] && (main "$@" || exit 1) +if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then + (main "$@" || exit 1) +fi From 8d1691ae94c52a9e6a2c80a2f60bc943e17fa894 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:17:02 +0200 Subject: [PATCH 15/25] =?UTF-8?q?f-50:=20lab:=20r=C3=B4les=20des=20n=C5=93?= =?UTF-8?q?uds,=20adresses=20du=20lien,=20loopback=20et=20installation=20d?= =?UTF-8?q?e=20FRR=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main.go | 7 +- cmd/lab/main_test.go | 57 ++++++- conf/lab/evpn-2hv.yml | 19 ++- conf/lab/frr/hv1.conf | 19 +++ conf/lab/frr/hv2.conf | 19 +++ conf/lab/frr/rr1.conf | 43 ++++++ conf/lab/frr/sw1.conf | 28 ++++ docs/developpement/lab.rst | 91 +++++++++-- internal/lab/provision/provision.go | 21 ++- internal/lab/provision/provision_test.go | 70 +++++++++ internal/lab/render/frrouting.gpg | Bin 0 -> 16112 bytes internal/lab/render/render.go | 8 +- internal/lab/render/roles_test.go | 188 +++++++++++++++++++++++ internal/lab/render/seed.go | 89 +++++++++-- internal/lab/topology/format.go | 46 ++++++ internal/lab/topology/plan.go | 32 +++- internal/lab/topology/roles_test.go | 163 ++++++++++++++++++++ internal/lab/topology/topology.go | 27 +++- internal/lab/topology/validate.go | 52 +++++++ scripts/lab-host.sh | 18 ++- scripts/lab-host_test.sh | 23 ++- 21 files changed, 962 insertions(+), 58 deletions(-) create mode 100644 conf/lab/frr/hv1.conf create mode 100644 conf/lab/frr/hv2.conf create mode 100644 conf/lab/frr/rr1.conf create mode 100644 conf/lab/frr/sw1.conf create mode 100644 internal/lab/render/frrouting.gpg create mode 100644 internal/lab/render/roles_test.go create mode 100644 internal/lab/topology/roles_test.go diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 3dc6425..df86e39 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -130,7 +130,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 } - nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized}) + frr, err := provision.ReadFRR(p) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr}) if err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index e8972b8..257211c 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -33,8 +33,9 @@ func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) { } 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", + "gateway 192.168.14.1", + "hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2", + "rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf", } { if !strings.Contains(stdout, want) { t.Errorf("output does not contain %q:\n%s", want, stdout) @@ -299,3 +300,55 @@ func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) { t.Error("the topology of a running lab was replaced") } } + +func TestRun_RenderShipsTheExampleFRRConfigs(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "lab.pub") + if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "run") + code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out) + if code != 0 { + t.Fatalf("code %d, stderr %s", code, stderr) + } + for node, want := range map[string]string{ + "sw1": "router bgp 65100", + "rr1": "bgp listen range 192.168.14.0/24 peer-group fabric", + "hv1": "bgp router-id 192.168.14.11", + "hv2": "bgp router-id 192.168.14.12", + } { + data, err := os.ReadFile(filepath.Join(out, node, "user-data")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), want) { + t.Errorf("%s user-data does not carry %q", node, want) + } + } +} + +func TestRun_RenderReportsAMissingFRRConfig(t *testing.T) { + dir := t.TempDir() + key := filepath.Join(dir, "lab.pub") + if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O x\n"), 0o600); err != nil { + t.Fatal(err) + } + topo := filepath.Join(dir, "lab.yml") + doc := `name: x +images: + deb: { url: https://example.invalid/deb.qcow2, sums: https://example.invalid/SHA512SUMS } +segments: + underlay: { switch: sw1, cidr: 10.1.0.0/24 } +nodes: + sw1: { role: switch, image: deb, cpus: 1, memory: 512, frr: frr/absent.conf } + hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay] } +` + if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + code, _, stderr := runLab("render", "-key", key, topo, filepath.Join(dir, "run")) + if code != 1 || !strings.Contains(stderr, "node sw1: ") || !strings.Contains(stderr, filepath.Join(dir, "frr", "absent.conf")) { + t.Errorf("code %d, stderr %q", code, stderr) + } +} diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index 3d389f6..b60ecb3 100644 --- a/conf/lab/evpn-2hv.yml +++ b/conf/lab/evpn-2hv.yml @@ -8,11 +8,20 @@ images: segments: underlay: switch: sw1 - cidr: 10.250.0.0/24 + cidr: 192.168.14.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] } + sw1: + { role: switch, image: debian12, cpus: 2, memory: 1024, + secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf } + rr1: + { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay], + addresses: { underlay: 192.168.14.2 }, secondary: { underlay: [169.254.0.3/28] }, + loopback: 10.255.255.1/32, frr: frr/rr1.conf } + hv1: + { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], + addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf } + hv2: + { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], + addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf } diff --git a/conf/lab/frr/hv1.conf b/conf/lab/frr/hv1.conf new file mode 100644 index 0000000..ecf9e12 --- /dev/null +++ b/conf/lab/frr/hv1.conf @@ -0,0 +1,19 @@ +frr defaults traditional +hostname hv1 +log syslog informational +! +router bgp 64600 + bgp router-id 192.168.14.11 + no bgp default ipv4-unicast + neighbor fabric peer-group + neighbor fabric remote-as 64600 + neighbor fabric capability extended-nexthop + neighbor 10.255.255.1 peer-group fabric + ! + address-family l2vpn evpn + neighbor fabric activate + advertise-all-vni + exit-address-family + ! +exit +! diff --git a/conf/lab/frr/hv2.conf b/conf/lab/frr/hv2.conf new file mode 100644 index 0000000..5d0e0d9 --- /dev/null +++ b/conf/lab/frr/hv2.conf @@ -0,0 +1,19 @@ +frr defaults traditional +hostname hv2 +log syslog informational +! +router bgp 64600 + bgp router-id 192.168.14.12 + no bgp default ipv4-unicast + neighbor fabric peer-group + neighbor fabric remote-as 64600 + neighbor fabric capability extended-nexthop + neighbor 10.255.255.1 peer-group fabric + ! + address-family l2vpn evpn + neighbor fabric activate + advertise-all-vni + exit-address-family + ! +exit +! diff --git a/conf/lab/frr/rr1.conf b/conf/lab/frr/rr1.conf new file mode 100644 index 0000000..44081b1 --- /dev/null +++ b/conf/lab/frr/rr1.conf @@ -0,0 +1,43 @@ +frr defaults traditional +hostname rr1 +log syslog informational +! +ip prefix-list RR-LOOPBACK-OUT seq 10 permit 10.255.255.1/32 +! +route-map NO-IN deny 999 + description deny +exit +! +router bgp 65000 + no bgp default ipv4-unicast + bgp router-id 10.255.255.1 + bgp cluster-id 10.255.255.1 + neighbor CLUSTER peer-group + neighbor CLUSTER remote-as 65100 + neighbor CLUSTER bfd + neighbor 169.254.0.1 peer-group CLUSTER + neighbor 169.254.0.1 description router-1 + neighbor 169.254.0.2 peer-group CLUSTER + neighbor 169.254.0.2 description router-2 + neighbor fabric peer-group + neighbor fabric remote-as 64600 + neighbor fabric local-as 64600 no-prepend replace-as + neighbor fabric capability extended-nexthop + neighbor fabric update-source 10.255.255.1 + bgp listen range 192.168.14.0/24 peer-group fabric + bgp listen limit 200 + ! + address-family ipv4 unicast + network 10.255.255.1/32 + neighbor CLUSTER activate + neighbor CLUSTER prefix-list RR-LOOPBACK-OUT out + neighbor CLUSTER route-map NO-IN in + exit-address-family + ! + address-family l2vpn evpn + neighbor fabric activate + neighbor fabric route-reflector-client + exit-address-family + ! +exit +! diff --git a/conf/lab/frr/sw1.conf b/conf/lab/frr/sw1.conf new file mode 100644 index 0000000..55df925 --- /dev/null +++ b/conf/lab/frr/sw1.conf @@ -0,0 +1,28 @@ +frr defaults traditional +hostname sw1 +log syslog informational +! +ip prefix-list RR-LOOPBACK-IN seq 10 permit 10.255.255.1/32 +! +route-map RR-IN permit 10 + match ip address prefix-list RR-LOOPBACK-IN +exit +! +route-map NO-OUT deny 999 +exit +! +router bgp 65100 + no bgp default ipv4-unicast + bgp router-id 169.254.0.1 + neighbor 169.254.0.3 remote-as 65000 + neighbor 169.254.0.3 description rr1 + neighbor 169.254.0.3 bfd + ! + address-family ipv4 unicast + neighbor 169.254.0.3 activate + neighbor 169.254.0.3 route-map RR-IN in + neighbor 169.254.0.3 route-map NO-OUT out + exit-address-family + ! +exit +! diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 8f56789..b151dbd 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -116,8 +116,9 @@ Commandes que si l'entrée standard en est un prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie /dev/kvm et la virtualisation imbriquée ; lancé aussi par up - push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et - ~/ ; ensuite : ssh './lab up ' + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le + répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle + référence) ; ensuite : ssh './lab up topology/' down supprime tous les serveurs de lab du projet et attend leur disparition session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive @@ -178,13 +179,15 @@ Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécu scripts/lab-host.sh up scripts/lab-host.sh push conf/lab/evpn-2hv.yml - scripts/lab-host.sh ssh './lab up evpn-2hv.yml' + scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml' scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1 scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé scripts/lab-host.sh down -``push`` transfère par la connexion SSH du script (``cat`` côté serveur, fichier renommé une fois -complet) : mêmes options, même clé, même ``known_hosts`` que ``ssh``. +``push`` transfère par la connexion SSH du script — mêmes options, même clé, même +``known_hosts`` que ``ssh`` : le binaire par ``cat``, le répertoire de la topologie par ``tar`` +(sans les métadonnées macOS), chacun renommé une fois complet. Tout le répertoire part, pour que +les fichiers que la topologie référence (``frr/*.conf``) arrivent avec elle. Topologie --------- @@ -212,9 +215,23 @@ Ce que le fichier déclare : ``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 + l'adresse d'un nœud sur un segment (``addresses: {underlay: 192.168.14.50}``). Un switch ne déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``. + Champs de rôle, facultatifs : + + * ``secondary`` — des adresses supplémentaires par segment, avec leur longueur de préfixe + (``secondary: {underlay: [169.254.0.3/28]}``), posées sur la même interface que l'adresse + principale : même L2, même MAC. Elles doivent être **hors** du CIDR du segment, pour ne + jamais croiser l'attribution automatique. Sur un switch, elles vont sur le bridge du + segment ; + * ``loopback`` — une adresse sur une interface ``dummy`` nommée ``lo1`` + (``loopback: 10.255.255.1/32``) ; + * ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé + au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_). + + ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. + Ce que l'outil en déduit, de façon déterministe — même fichier, même plan : .. list-table:: @@ -257,11 +274,18 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe 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 + 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 + hv2 - - hv2.conf + + segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1 + node interface address mac udp switch port mac udp + rr1 underlay 192.168.14.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001 + hv1 underlay 192.168.14.11/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003 + hv2 underlay 192.168.14.12/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 : @@ -274,8 +298,49 @@ champs inconnus et les clés en double sont refusés aussi : 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). +Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs +de l'exemple sont **ceux de la production** (décision du 2026-10-04, #50) : les fichiers de +``conf/lab/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont +**fixées** par ``addresses`` — le route reflector en ``.2``, les hyperviseurs à partir de ``.11`` — +pour que le modèle se lise sans le plan et ne dépende pas de l'ordre de déclaration : le +``frr.conf`` d'un hyperviseur, écrit à la main, porte son adresse en ``router-id``. Seul le switch +n'en déclare pas : il porte toujours la passerelle, la première adresse du segment. + +Rôles +~~~~~ + +Les configurations FRR du lab vivent dans ``conf/lab/frr/``, une par nœud, **écrites à la main** : +ce sont les mêmes fichiers que la documentation de déploiement inclut, pour que le lab qualifie +exactement ce qu'elle prescrit. Celle du route reflector : + +.. literalinclude:: ../../conf/lab/frr/rr1.conf + :language: text + +Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les +paquets recommandés), active ``bgpd`` — et ``bfdd`` sur le switch et le route reflector —, puis +dépose le ``frr.conf`` du nœud et redémarre FRR. La mise à jour des index de paquets est réessayée +pendant cinq minutes : un nœud peut démarrer avant que le switch, par lequel il sort, n'ait posé +son NAT. + +La **clé du dépôt FRR** n'est pas téléchargée au démarrage : elle est enregistrée dans ``lab`` +(``internal/lab/render/frrouting.gpg``) et déposée par cloud-init. Elle a été récupérée le +2026-10-04 sur ``deb.frrouting.org`` ; les empreintes de ses clés primaires sont publiées sous la +même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` : + +.. code-block:: text + + 3D99 68AC 9AE7 BE11 6928 8DDB 1FD5 8398 95F5 7FDA David Lamparter + 4A56 C773 8BB3 F815 95A8 05D2 A832 7699 08F1 3ED1 FRRouting Debian Repository + A90F C36D 9429 4097 98E9 C2D8 74DE ED43 AB19 4DBF Jafar Al-Gharaibeh + +Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier +après avoir vérifié les nouvelles empreintes. + +.. note:: + + La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite + pour l'essai du 2026-10-04, elle se contente d'établir la session avec le route reflector et de + n'accepter que sa loopback. Elle sera remplacée par la configuration réelle des routeurs. Rendu des VM ------------ diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go index 98c1b2f..b1ee4d0 100644 --- a/internal/lab/provision/provision.go +++ b/internal/lab/provision/provision.go @@ -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) { diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go index b05d693..f36e514 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -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) +} diff --git a/internal/lab/render/frrouting.gpg b/internal/lab/render/frrouting.gpg new file mode 100644 index 0000000000000000000000000000000000000000..c38f82b554920a929fc607fab1dfab32bd278c9c GIT binary patch literal 16112 zcmeI2Q*Dac}aniBPj%_=q*IIjxz0de@#<^d&bJV<< zqw1~qshVj3I8d#bf_7j;prH>1d_nYht$^bUPK%tTwoObKg~P2_)seHHGDf-+Zgpg$ z6M{O(l|7F;Q7^~+MiucT_@E~#@teCIs>C9c8cH;W%I*FfKcLX9SKCs@N^#xsO!|E9 z956(#;>P!jZg2H+Nq*jPx4cCo`$}8kD18VpMx$>A8Cl47TBWLKcRIp)oB7I4Jyoe` zrXW)z{VB%}j#YF0RylX)`kal~2)d47NE>VPNg`UWZpR+}ZgcsK7;tip>SxUYm2JO( zCFV4h(K57g4xpS-BwA^?G(l*njmbo+2~m62_wEbL=wR7)@VqSE&xq+;@9zZKKSz2I z3>-CSKVX{IHyNpDkvoA?3!FXji?0)q5%*c0n8Ahzuf6Gmf&)C3I;E5y_ua}IzNj5Z z+b~fAl@FQkI{DVh<3c9Z<~mE%Nh_0p5#iXHDT{;)Mm{i!>FN{5d7{WGup@4zTpm-4 zk#Z5zv=A5I=zL>y@_K6AD2Pm$G7F3deV3a^0)U-95oL{Pk`J1J*du@iOZm%t>`G;6 z71Z6iF3<#vS@=m6i(@DHl6N;Kui=D?&4ahi#`Z56PB==o|5DM=^sf#gu`@r2n(sXj z>GDy@!p+1hT!>a6H=;W9m8P}ALZakE*gnS{pI;-Gcl6wXd3Rmk(*dZ!?P4V{(!)@d zr#{FpXPTnkiU1)20|7VEh#I(97!%4E*nBf^a58Zq zVrHecHF4q(2gre<07C+Cq7#AgrI}ZzA9f*GP)4s2ECi+|-}|nD0scrW01hx13=|{; z02~bx3K|9giVgq)2H*n&F#Ld^p%DKLmc>W-RH0iVB%MI!JkY_BaOtZTpQvQl^>=>& zY2Cd!S_rGEF{jHsnzwN`{l!S2IEL#di=gHu7_hq3`R))ze>RzE9GbnLQ&-)!1|HQH&01S^FoA;|RG4J)5?M#PvSaijt zVQnBuJe)NH<#A~K^)FI*t2CNvE5ML*CL+WFI&pEa4#?g2Ra#6SyU~m>n)R+*En|$F4@$Z z8F`o1blYoylTV4pVwWst5+Lm2Y8Q7>h8iri)xM%1C%tsu3_B>*EdkRs+?%x%wCRjU z24#7h(j*st*tnU_&nQ?x)jP|O>rD8-KLB$cO**TAoRZ5#l3E#LZ01bj6%)H0I`GA) zrW<{urKg;+DV-Hv3$G1C+R13EZke3QpwznZmbNLnU22{nzaDX>>)TLEMKBS%S3P|3 zR#6%~_AZqTd#st49||y~vPF#YW!ixMRHLomIn_P^=F~{B)6)dG=#|V^GFz!l&h)7^ zrq2YYu!7Tjl?dy&f)1`X%QJ<(NBTp=ktlWrw^fzhM|RFJwy zjM#rN@(hVC{9ST)*1>1+#8Ut668aNB}}E zCXgZmQG*5o<9jR4AQvpPFyZ1zc6ybUzgC7$EQJ9&Qjz!?t;Ov27LED717kIdZxXQA0D9Y921*4;&XG%)Sb03WT*wg-Mw zSw`K}Oh-Qp0$b*>Np?xbl`(JPr|0v^|E$WbKT7ZKll1_IPibeJJ8b_EEWqgQ zSRWvTeq*#YGJ-R&^F{jzgf6+ZpfsI^j>nBPVkzef8ay_rUzI|4Rr$;`@1>Q)@I|lK z!(FR(nV=ZSh?>;$lFP*xg0ZYp>NFFAG=NezLDU!Hb^)K>_8+Y9Mftgpp2|twXBZom zCa7+oIMB!l5Y+tj!%gMqQRs%d%^}2e8 zfP(Ysvy07z^>c~>wG$?Cq3tXBst)mTR2*FiiQ2fX6U{y1H3~5!D=WqLFUxi>2Dkh} z4{Z=r5d~CWVGGcJs5au!77=&LWQ~_^*w{cdgeK}`&kF)ZM@yNAKy8{~BN&yI`s(># z?>#-fL{Vjt@gfZO+PEkkd|eh&u`Sx|!F*({bYmZub;eAo*J*86q-RgdKZ+iw;PqV` zTJ{)x5l@iInN9DcxT_y&wV_L~|Z z+1Cv*3y4O|GFx*y0hTf-+IZ|RQ0%ch-@xz3Znb_7(^x5Uc1-PU1n%$qY&Sn;E8N7@ z>I>V~Rk8uhSFmpdk;EQL$o`gu`8$2?f+dAgo5s@f!AQfWa_S;xr*I&xd^aOt`pFOz zKAVYU1te-nMxkc?{#L`1Lmfi_7EfYDu|Q_U42jM5-2%V9toCNydC{K$wH_F=DZJpn zjHR|6DGq=|PsxIhqju`Z+KW%H4%8O#&TUIEO){5-xY(?J+17Ee^dv)J-NzB(+ENG3 z2voaXVS{Paw#mhlz8~xes{oX9M9whRvtQrGJ8e|*t|-%SZn}-ykI9<&LKbyuV2CEv zLtfS04W@ZzQCq78!90R+hhP5+mr6xzgfObgEA>IP&D#GyGdzL^ zCob~jDgSt{${~%TXs6fRug`;CZ(~EAi_bt?HnHtuBDH77{b5?}t@%ZFPN*9-aeaD* zDnX9wFquj)z;ClcYZJC5Dl3AUJvwoV=qyX44=+LiV+zu}MUFR;LGul*ew;H}A$8 z+jaX!oD9OEPv8if4UBcOJj0O$27jy;Vjl)avcU!k=RCi01<|BcQ_>bfrBZXN>I+Ob z4ZUECf;%{p%)5<}lx}{{*PobL3uS`XSP1S-bx(c!w#)T_Pfx{{{JH8CmD#GuTAHa= zUR>lGv_^_+Mi>2PUdr48I<0MI?<(L>!o|Q%S^5JUQ~3w>+gCd&rdBf{l+GAaLK01E zTiYm4LU2{zy$q0CC{HJWCh#Rx!)UIElk4GBlAQvm&mmrx(Q%0%=m$rmw~*&%#L^w@ z;TL994Z$@SG0zad8`S9N-u2{ z-j^w2QZ$xiY^)RGgW(;c?FHgghAnf(k8|AG3ezjh!UNbAqr!c3%+AoJR=BT&{K)C&7qGBTZ`VX_FJzYGm!#{rCrv{Y z-0|bjNgDg*tT97zIuf(F6UoJy z_M0lSgSAP2Q^jUB_%Bh?{GX*~;QZTJI3%#cdA1No$=_Qo2X)>O4R+DW8*&;%PFLjD z$y!s|gCn-kY}*GOr`^DhmNXR~I^)?Tis{U|ZXg~yWx!K%u=rd)gCmj7MODdTTd^Nc z2$BqCXZOh(rZXFhAVG+FbIBH^_)Dyp-@@~Oy6ep^F%1IUYHjXctG94GJpnF5&ybtB zV!Xje{N8?sLQcgL?sHwHz_aR@Z?qYq2Kt^GP(;b4?gnMv-i{%0rKBdqS_bu;%1BH> z0PFIqYV%BZNkq9&$VQHNX<+{ve=yvsVjjUO?;Irhn!HKj4G`jQ>PDtvE_=63lHl%i z9`3pI8kANr6Wl^!Q*DuR(1nTc!TQuBQl14_E9!ywJ=E>b;z@!1xC|5KMx$H&vv4Ka zNKzUIvnuh=*3RX&vk0^4NgKiAMNLvgp#(HP?3@M^&6xa1{u|69HUw3GPREd z^;v&jB)gCm!>+M52Dm?+THZIAo+!MciO% z`Lmv>$RK0KZ`y=av72-P&scC6+)`Ef4$E;aV;w6$2>NIYFacm%kx*6ZNX@vj7If)0 z1A~821)I2{qMfsog{>K(=${xtsA%%d&e6ij&cU6K*Vx2G(AvV$$&udF!QrnkdOHWR z|BElAREHcR8=laTOTlJKnO)K#ulZ-d0DmOefA9q~6y$&5i@$?oZ4f>kD>l@-6H9l? zx(x*L_0zoR%$d4Sb$s+xa?=8{v}BDitBqncI5LY7eS^g(2(m{6l{m5?3$@5CY(;v? zVd;KGi8T1x>iIP%cg%@$1;IFO7y0kgm|VgZsOmOO2Z~C^O}0h`rRTbzN+-G9T4H>D zr;-HmZv=C+7m)4Fe9rEH4bvhD98(ymC7+iLZkxj9@hM_H;3a8_y!|T9do`}kjGGc# zt`N*72IiA@f@L!HQ>hVTrK?6AJ`AEp%|&UPE`L&TGAMp--#J*TUGahO_1X=Y!`mr9 zAvMilWfoV&Z{AH!iis<(*h;xS`aU^JV)P)5{Ii?53~Bvl`TS*-X16t_-olMyQ@$pN zsq-@*9&}shCoHi1jC?&xI(1qpoJ9gg0QeDEC;WNm@{(_r#=wCYTzM5O@Tw%iz^MHy zSZ)J}C5|A|tRJ1AQWwEJY4j{V1cIyzQ?J3DH>Sy+lSd5XQr?h(1n3}$Dyi9KD^Qa* zQa(GagdRQa{G&auLM*(!4S^@da*WzeU%V>1s4c4Ql8(@$W3Y?#qONg0O>s^~c{q5k z`x+DenQqdz*{SoiTZ-A=>c>FuR^w1|WiDL~&c>f9Qx(QLszOzbIG+aJ0Vw%hhwm&S(NS6&Ph z+)&)!5bfJJmt zfJVx&G?Z(i%EolcVPCPw3U+jVzPlYNCb974kqivpLd|Fi=$YVEhjf!}`6_Sr7TC4c z>b``;*enwRe@hynn^Bxyzv~)Rvhz=IejqK0qe0;W)C^gmxHsUDWT5 z!i7VM#>L>KF=}92Fg5`dz$Mbt0OQ`4$_$0ZF`u>Z9i?AFn`hCej-?e@)>;u)HN(DA z4j{Tx9=kpzm}BAIayVN2=`kXnJjD?tx1WaE4{ffale1HmS_VVSNtKK{A}NOm``LP> zTXc_DQk_8f%lFjl*Z5xZ^#NQ?@ZH<)86rXnGy%1`m&O)m%0ZD}E)fS4rbd~^bYf9B zQ=Jk^gmf0#d8%ag9FKT1IAo|7HiptN7qDlbfnxr3x~9f}Vn<*jK@N22lkw+xzDf=L zqSJWHPh0H~-K$NPN}Z64m+_E?j`O& z;w7=si9m$mZOQHO@A0m9WG{R#c+(SeY>YTOOIdpbU0x*1Ln0o} zGY*mK==*I@?p!UM-{sT>(@w|}h3%_&;L4NiA>hSFmH3V@yuto5IH3RLtN%e@erL;X zz5@K_tG|V$_docmWWtzykQ^Q5SmJcNV6+*3z_V`pw<`U<^oJ$-`y?k3;e&NZ_^q4e zBhgM4xh0X~nEQ6h3d#r?1S&!^6_vx+tb19*h9p>>m3E)U;u(X#yZ)JuS~TjGw? z8?+FoMr-E%Pss>r#atfQ&`jNXjiP=>S}7*Kn6fWzWW(ATj@lfj&2`sPDSQhc?n=SyNOi< zwmLFup7>!;57aF3X8B+I)b*uWgopa{sy(~Z`HIu*WsB*tXqT8P%GboUy}HYbsU4Zh z1*2|BA7&enMbJrh-MOH5X45hA&G+7IPnx*MEXxZnVVy=Q>La#0uALRk9N@f@U4E|& zHcrO>wbQ+B(QxYAz49hz@)AR{x)+HC&8B6?2vAFt_(X-~K+aoBgYdy_OetEJiW6Nt zSG8Gb9s|tXmjI0Rctoq~z-+6&w&9Y2b8n8R)6^$fZNNi5sNbd~ZYGw&r!t}4W_jsf z*H-_+Ts{c|yEvBLBbPFw2=BD9Et{jdCZx5K(#~Y&<&x~*N58czh16M&P&}#~VX1kv z6`inL@W{RLf#P9qjoaz%9b)^(SkHiu?#P5GMiBu&E1-3(!{D9^wSj|mx#K- zHu5RjFcT!Fx;ZK(aUh53-55$BABIZ3rB4j(20$~BVV7<-p%<@IFrMnDSUgxORU20INW4gbPtago{Uu--RZ9QL3iMPm@AU;byo z$uD`HfedolhB4%$)*iJqG?6>*v$^Pv-8M(35Rx}WsGDCvHhJe2X9 zM~!tU5?B3*+mr_mrUhtQ036Rb9JWRRNat8)K&SinY7VmI<{EEcmOgvDSDXyw8hE%k-yu|_%QfE+6(tk zOSg-5*9a6F_@tnQt>#!O-6a<1rNjOv;N4ZS2HX8&0nmt>iDf>sD zFhXW9=0@AV-0kXwiQl&}GT5_E%_!<^-mbbm1`(7T_+}#8aI%%5{DHmDZbr@!SlTKu9>&S&bs0ke`i(T>8YG^ND9>fxg}H z(A`E`9cL(bAVpllfbrANyas>VDWuaw9Dyuvd}B8gMeRDL9+wB2I!DS7BHSdUs=`uu z$5+qYO%c~Ac3tf1`^ppI0Q&F28JRqFZtM4ph89HVFYoA`xbV;&j?%2oz>Kp}1f?d_ zr;o=jm72~NUz3Ap*O3{j^!XlHl3{o??Z-yVf7+WDS3ACp+WcjUS)>e14IBuCtm!1o z4IB(C3{A`lDXk5hZH>&o85mO$@;cekTmBIuXl7$zVNGvjXY(&p4E^WOy$qq>CW%V$ zd+KrDveV|dNClQ`_wV-eJH-GvAb$_t|2tFsckmA$gioiAp98X{rW5F%b%=Vy8+V3i zTR(*5p@@V1ye0NfI|)p>RDyW3O^)+4t(7uiR3o7dlgoHk!7eZ2UqPB{+|6H}sWEtP z^;;%fZxRd2UlVi}WLdne5u*ZhH_=zhB`=){k^z;|5{ddu-i%-L=EtzAk!ha{<+Ps{ zTpbLxIEL*WN8qzO^Sh#+PKEm)RB9D*KjlDnp~bjgIH%`^6Fg-E>GOOR=V!--f*Rwe z*Ds#ovVUL}VssMliI;b47dhXCrGDZ{CNqzji&!cI_wIK;F~yH< zXV=GV=yYd)pX4wj);nZrZs==VK>si_+XTh&9snx=K{B4~~r>hTkcnk6GC zTvirCdUO%Rd=O4Qz*iyGWiqZJ-|4qHShMZy=Ij`%=S_k5`b?W>jq5rNnZF|QN#eGk zjSDb5`^;y(9V^N1<1iAYm^r?Qzn8CK^>c^O6-o2yd?D~6TQ}}AW~R4M+`YN# zqn+jOXle@FN)oU2$liP2Y_agM-moPEwr1r|Q~Z}I-J+h}(sWpK&6=JN&>_&u5zoMzWI(lJV%EZ%fnL!GRFjBZp$?OUBqAGzn z6e`{eJ+Y#~n*nTD;$MPn+wZR^RjMqq>n4mIzRnhsz%}X8`8j|jmu!Tx7%6LJ8f3_x zL#O*I%TUh2u4EvH2jV`2^iXvxf-;0Xi1CxLso$({{3V>=ffa%q9iKw08qwG!`C-Wolvx|9)?1D&{W_Q>ke?toGUysLcNIuZtL!HyC8=J5v z;60id#OqOhX~nk7VzLKpn7HrHDCv=i@cqZs5Bkrg!~Z5Qe@^}W7Ls^wgbyk=m-{Hj zq}F1v3)n|S*{67y7sJr!+~GF+KLrBLAhRW7HGe6@}zl&XeJ_p6fh*v!_=z^lv2!Bu<4@SYLst3 zAzNEbX*Eiz8CaIkRSVZ@Y3a&xm0Qz@S7B?-elqPE_xA7CfTmM%||T-{!f2?Wjj1|4yB{#YCLF84#D1780}&92cLwAz}r z;C9}r(pG36^+OH^}9P?wu-p}=3yVbsH0=u2osz!z&2xztQO_y*8|plJJ(-*~DAe+J~R}VM=Q$rrs|U zLN9tUcHQOlI*<^JBlwt_bnf>bvN_>Yb^zBJHx}0V+J4((F~$3+R`uc+$Iy+0mXq=> zMO?_Egj0%xxYCPwmtkwu=pru<7I?}Qg3Xd12U784up(FyI77ilzD#wdB2Tj;>Qww{(-tx+T$P}5)3E@lh%vs@>P``8+ji~8=!I84yO2yz=$3CPIr-RU7SWV`z~45PRCWf* z6*ij-d{z92+Mimhe6xswXN8}YfV97COvBb3OvklO42=IOorabqOw9fGRnYsE*LwfP z^ie^`N7A8#3vABTu8yc4u)#PHo)%6KQNpAkvnQJ#jW`5(7s3*!!Ly-4&SnY`hQ1WT z1x^>iPHejjObYQ0)D)!1!<9Sw@haOcw2|Xd?!`^4H(&!I4B~Mas2>17mIT5VgBNen z?xPPosA}^l|Kmq)`gMi>0Y7K(Qga`pDrC>bp!EV(qZb^io~|wiOcKjzoosxT3|Hg0 z6L$eLI#m%MspQKc!|6LAB|N@yeuQdek4AXSAy$|1IhZ3Ss7##<5pAkre2WYF%eOi> zvutC8zz`8#yw8FGw;5i16DAUubCxjn3#@7&sGFHO;0MbSjNA(C1%kiq4aI+BZ~kXX z^VdIuj*-f;8Ah`F-jGjUC;WuGhu!a5V1Pf;>TgQ}0*3a7r2+fn8v)S2{}KEhTt|!W zj<*YO97bRmwyYlU8N4XRM;-lY-!yr45t+mM-6KFK386$AlRAs>OQ;u|-ke{B5D$;! z=X7V)JJ4Y5(gml4&R#K!0m2-x?|MAF!N{=0y*!`ed8oUo1HyQ#Xv8(HC;~Lq!cA&g zknLuqCm4@+PYw0HQ@os4JY) zrR%X*483npfY2#EuL1?(=5R?Cb&K749` z?FB-8&bp+uJ*HfEh3ggv(VQ1KD)-MjV$MgmO&L!4GL*;>^cuosApKI|hw`eYsqm-? zAdeL*;MTsq>1sT^4}md&Q%PrbM9p* z(ZNy$Y1jtbkCh`LoS1b7+;gYFrC6h16*EC91jr6`Fx@Yj&^I8Zj%$Wg*t}aeKzR89 zZN&X9BdK!bUO^m^tr6~d$O$>k-j}jF=t*?G9s_hzLQ;GnSDzq?y7~&ZW+P5X^w;8r5RRS_4g$LNc5hc*8QO|$)mJ@YCFo6#|iq6 zl@i-aU}6>75=}_=4h!A(w(Vn+*nE}1Z^c#EpRAn>pG}k`zGI6s%D@8lvw!CF^ueHM zkgM38DH=W}zGh89LWW&kyHYbe7kL$FMEe=d8cxp#aAYw%S;qx`DU3|YN1BC?Vgtct z&alS6l`r@SHw}(Rp*oL=Bz{$zH>;8!Z}2ivMFAc>4fSh)(a(vGjsR17R9R9z5(wKz z@{J-5gC$sURvRv9b6&FB4@zy7caX+mg@>gzHFU_uriJgY8_Tt+;%U~|k4z&9jMV$s(a?#=AV|jdpxFoy46xo?Qc(LB!Fb zt%m(Ye80rVM{V^Nw2h6G%*xvWnm7Be6+Slhbpb+BkOKY!p#)1_rB)1oDN*=;EHeK$ zf%(hQ{7ZGp91wxp-)on)yxT6a5YN{xc`NVbT$+d>4uMj!FuB46dop>6h}t)k2BHIH zh>Sr5Pl)Zbbn3B%4IKm<=WVXt{2WVLX4lTdm6Z7?Qen8dFrf}P5xIv`;>qadb>p?U zT^O~ z80Omf!IV-8IqNAtel*8&7jJdw2p(}*;jqWyfKtVgl8XGzSe;xk&x-=EpFvb1tz

FmpBWJH_)EkUgb^EF8h$>Y$XZPeB}$4 z-vI%Q1>iZzfVRpz+wdGQ#)IQjib#frHS4txgKPB`{?#l1DzQg}pj4(_W)>f4FM4!O zjXbK1it_3>Uq;|HrRsAnIVpMIQjnc(CA&Wle26x{V!IdTEitIp%WS-9?%b>ve@m9} zp$l%yqynjh?n{f&*h0R8aG^{r6Ia#r2cU^H2;5OqrijvYMK&@7%ZhrX+8_@$Ag0J1= z*T<5QQ1ndj!E>^LWVdO*n{tV85S`aWpZV2WOEH9v%VD%|+tGdxxwo;9iL*($#%>@6 zJKhCa_&l%x$iD8cJ(LvL_)B*)D_u>`UP}=xmjlyH!3<(>XlF^%%*40_$d7!4QPu)Y zuBXB?8albQ#Wwwx$V_=fM__=##cZU;p%Ke2*6yV;@S7=PA?Fk8+MxhkEdYtYHvQ<3 zm3otd@|>VKVx{<3Iyaj$l569WlB|OMu<2FnslUz2h$rGMn5E&n%QVf}7)z zxR`J8ffOdmI;?(DaX%x6SL0@7t+nfVF0GoM#}+kWRZAxC+=Ij0$EGN3_yq zMH+NH44whePkU+C5oN2-Vb)IKY#Em~HPXKB%R;<;I7CC5Z!$x(g%x(7h&y zi1SXb@TeqeH}f11@%CI#tAQeT1n}hO;fxx=qK4Z^qb%3cV0X?u#l zY70?o4%Q=~WrdKe>x%u7)L*D8eAhXn1uRHRxoU|A$0yDOjzgxpag8c6>{>%AFsR&8 zUJ!{UfUHzvNf`1ztn*w1AkO-C%yg!hz0s3KQKHZmPN9)bYSXa{UoeVZNnkT53;Ddp zOC&u0mxaOqBqRZ$|5_OQ?MVJ}VbG7<%myTIZDI_Bzq`+=tWiwpi}HuP2?+gTVet1! zqdFo`hXZf^hlbY!rg+$vSMDQUt6(T!Dg-GwE|5(5=~*nvw@R}#1~kPKrUiKmREM$V z4v691b>;i<5Pdh$6m|)|MygvdcUZR$bV+G|^Xq0jk=_-lBPFtFj1bGH%(&!)+?LVJ z`-_)9j09GUS?zADSDlQrO5zVVmHu21Lrwl7#U&s^jGR5U5Ll(>5;mI~hML8a69`5w z1csKN2uyXrKCZOI>eNsa7~S~(y3F^U`C559(oz=w#-W5m&QxZ1NR^uK9yy3PA{>gI zEo8ei|NGQ9STnJbP46O z<_jw--acb=ftUFp%avj+2ZMyh^8DupcsHno$S%0>j3G}rXubKw)Fs}G;))J(IWWLO z;t1Fy!t3N~BJa414@&N2;wyL#2F>I$=6{l{0gxx(W77IIv+~x~^q?`72pm>7TwO-fPsZDou%RIU^{earlwPDV0dU{A9-2z5woQMRE<_Pmz{~~f~e6E zl|k!<%YpG9qM4!2z)U4IG&tH9QB`qb4@GGMx6G#UiHY2?3Cv@mXJD$JQ%$_fSA1G& z7a)>Z`H7}s0Un#63DkOcXXwrdY!2F1)oYb$aR?&A9Y+IoZ>;p6kf?iA6N@ z1e;BvP{+9Qp;F>6o^Yc>0y6mE8pj5!{OaBZUXq9c&(zb^DdQ~$(#mU61j9%bM)(bz z;54$B`_(2|>mhL`kDXoNUg;aY*X@$S;ieY_!aM*3Q)OK&6IlW!s?#!anK ztTaq8vsE2EvJGgng4BpF@OK#;)(=-#fs42vRF{f;X?nzk_HOVA@n~~Y;(v(xAEN$; tsQ)4Ae~9`YqW*`d{~_vsi25I*{)ec)YyCsi{}A;*MEws@|Nj&9{{amGE{p&G literal 0 HcmV?d00001 diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go index 49b3089..6a11f0d 100644 --- a/internal/lab/render/render.go +++ b/internal/lab/render/render.go @@ -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 } diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go new file mode 100644 index 0000000..8ebb715 --- /dev/null +++ b/internal/lab/render/roles_test.go @@ -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) + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go index c959d3c..6fd41e9 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -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) } diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go index e501bff..1940d8c 100644 --- a/internal/lab/topology/format.go +++ b/internal/lab/topology/format.go @@ -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 +} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 0fd0ee6..c7a8147 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -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 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go new file mode 100644 index 0000000..bbd4baf --- /dev/null +++ b/internal/lab/topology/roles_test.go @@ -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()) + } +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go index bd184d7..8b3b35a 100644 --- a/internal/lab/topology/topology.go +++ b/internal/lab/topology/topology.go @@ -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 diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 6a7d53e..d51914b 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -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..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 { diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh index 412fd6a..a0d1f7b 100755 --- a/scripts/lab-host.sh +++ b/scripts/lab-host.sh @@ -28,14 +28,16 @@ usage: ${0##*/} [arguments] plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création et le prix ; ne crée rien - up crée le serveur de lab, attend la fin de son installation et son SSH + up crée le serveur de lab, attend la fin de son installation et son SSH, + puis le prépare (voir prepare) status liste les serveurs de lab du projet ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé que si l'entrée standard en est un prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie /dev/kvm et la virtualisation imbriquée ; lancé aussi par up - push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et - ~/ ; ensuite : ssh './lab up ' + push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le + répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle + référence) ; ensuite : ssh './lab up topology/' down supprime tous les serveurs de lab du projet et attend leur disparition session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive @@ -332,6 +334,12 @@ push_file () { ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}" } +push_dir () { + local SOURCE="${1}" + COPYFILE_DISABLE=1 tar --no-xattrs -C "${SOURCE}" -cf - . \ + | ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part -xf - && rm -rf topology && mv topology.part topology" +} + cmd_push () { local TOPOLOGY="${1:-}" local NAME="${TOPOLOGY##*/}" @@ -344,8 +352,8 @@ cmd_push () { (cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \ || die "compilation de lab échouée" push_file "${BINARY}" lab 755 || die "envoi de lab échoué" - push_file "${TOPOLOGY}" "${NAME}" 644 || die "envoi de ${NAME} échoué" - info "déposés sur le serveur : ~/lab, ~/${NAME} — ensuite : ${0##*/} ssh './lab up ${NAME}'" + push_dir "$(dirname "${TOPOLOGY}")" || die "envoi du répertoire de ${NAME} échoué" + info "déposés sur le serveur : ~/lab, ~/topology/ — ensuite : ${0##*/} ssh './lab up topology/${NAME}'" } delete_server () { diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh index 9db9776..1e82388 100755 --- a/scripts/lab-host_test.sh +++ b/scripts/lab-host_test.sh @@ -147,7 +147,7 @@ case "$*" in *"bash -s"*) cat > "${FAKE_DIR}/prepare.sh" exit "${FAKE_PREPARE_RC:-0}" ;; - *"cat > "*) + *"cat > "*|*"-xf -"*) N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.') cat > "${FAKE_DIR}/pushed.$(( N + 1 ))" exit "${FAKE_PUSH_RC:-0}" ;; @@ -659,18 +659,27 @@ test_prepare_without_known_server_is_refused () { } test_push_builds_for_linux_and_sends_binary_and_topology () { - setup "push : compile lab pour linux/amd64, envoie le binaire et la topologie" + setup "push : compile lab pour linux/amd64, envoie le binaire et le répertoire de la topologie" known_server - printf 'name: evpn-2hv\n' > "${WORK}/evpn-2hv.yml" - run_lab push "${WORK}/evpn-2hv.yml" || fail "code de sortie $?" - local REPO + mkdir -p "${WORK}/conf/frr" + printf 'name: evpn-2hv\n' > "${WORK}/conf/evpn-2hv.yml" + printf 'hostname rr1\n' > "${WORK}/conf/frr/rr1.conf" + xattr -w com.apple.test lab "${WORK}/conf/evpn-2hv.yml" 2>/dev/null || true + run_lab push "${WORK}/conf/evpn-2hv.yml" || fail "code de sortie $?" + local REPO LISTING REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)" [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \ || fail "compilation : $(cat "${WORK}/go.log")" grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent" - grep -q "debian@203.0.113.7 cat > 'evpn-2hv.yml.part' && chmod 644 'evpn-2hv.yml.part' && mv 'evpn-2hv.yml.part' 'evpn-2hv.yml'" "${WORK}/ssh.log" || fail "envoi de la topologie absent" + grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \ + || fail "envoi du répertoire absent" [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")" - [[ $(cat "${WORK}/pushed.2") == "name: evpn-2hv" ]] || fail "contenu de la topologie : $(cat "${WORK}/pushed.2")" + LISTING=$(tar -tf "${WORK}/pushed.2" | sed 's|^\./||' | grep -v '/$' | grep -v '^$' | sort | tr '\n' ' ') + [[ "${LISTING}" == "evpn-2hv.yml frr/rr1.conf " ]] || fail "contenu de l'archive : ${LISTING}" + grep -aq 'LIBARCHIVE.xattr' "${WORK}/pushed.2" && fail "attributs étendus macOS dans l'archive" + mkdir "${WORK}/x" && tar -C "${WORK}/x" -xf "${WORK}/pushed.2" + [[ $(cat "${WORK}/x/frr/rr1.conf") == "hostname rr1" ]] || fail "frr/rr1.conf altéré" + grep -q "ssh './lab up topology/evpn-2hv.yml'" "${WORK}/out.log" || fail "consigne finale absente" teardown } From f424d1521389a38094661bf02ecccf9f1db89842 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:36:15 +0200 Subject: [PATCH 16/25] =?UTF-8?q?f-50:=20lab:=20d=C3=A9ploiement=20de=20tw?= =?UTF-8?q?o=20sur=20les=20hyperviseurs=20et=20provisionnement=20en=20un?= =?UTF-8?q?=20script=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main_test.go | 2 +- conf/lab/evpn-2hv.yml | 4 +- docs/developpement/lab.rst | 50 +++++++++-- internal/lab/machine/machine.go | 24 +++++ internal/lab/machine/machine_test.go | 61 +++++++++++-- internal/lab/provision/provision_test.go | 4 +- internal/lab/render/render_test.go | 13 +-- internal/lab/render/roles_test.go | 106 +++++++++++++++++++---- internal/lab/render/seed.go | 47 +++++++--- internal/lab/topology/format.go | 6 +- internal/lab/topology/plan.go | 2 + internal/lab/topology/plan_test.go | 13 ++- internal/lab/topology/roles_test.go | 71 +++++++++++++-- internal/lab/topology/topology.go | 3 + internal/lab/topology/validate.go | 9 ++ scripts/embed.go | 9 ++ 16 files changed, 360 insertions(+), 64 deletions(-) create mode 100644 scripts/embed.go diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index 257211c..fe17d78 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -342,7 +342,7 @@ segments: underlay: { switch: sw1, cidr: 10.1.0.0/24 } nodes: sw1: { role: switch, image: deb, cpus: 1, memory: 512, frr: frr/absent.conf } - hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay], release: 0.2.0rc002 } ` if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil { t.Fatal(err) diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index b60ecb3..a3ebfa5 100644 --- a/conf/lab/evpn-2hv.yml +++ b/conf/lab/evpn-2hv.yml @@ -21,7 +21,7 @@ nodes: loopback: 10.255.255.1/32, frr: frr/rr1.conf } hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf } + addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc002 } hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf } + addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc002 } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index b151dbd..3282898 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -229,6 +229,9 @@ Ce que le fichier déclare : (``loopback: 10.255.255.1/32``) ; * ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_). + * ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de + two que ``deploy.sh`` installe (``release: 0.2.0rc002``). Sans lui, ``deploy.sh`` prendrait la + dernière release, et le lab ne serait plus reproductible. ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. @@ -275,11 +278,11 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203 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 - hv2 - - hv2.conf + name loopback secondary frr release + 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 0.2.0rc002 + hv2 - - hv2.conf 0.2.0rc002 segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1 node interface address mac udp switch port mac udp @@ -336,6 +339,39 @@ même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` : Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier après avoir vérifié les nouvelles empreintes. +**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**, +embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans +``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` : + +.. code-block:: text + + deploy.sh --noup_script -i -u -t + +``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab +teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut — +celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que +``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh`` +télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur +réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final. + +**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script +``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un +FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc +``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle +que lui, et la première étape en échec met cloud-init en erreur. + +**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif +sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que +cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de +secours, et la VM redémarrée ne rejoue pas ``runcmd``. + +.. warning:: + + **Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en + tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est + exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le + lab : ``lab down`` puis ``lab up``. + .. note:: La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite @@ -661,6 +697,10 @@ Sécurité * **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un serveur auquel on s'est authentifié. +* **Code exécuté sans épinglage par** ``deploy.sh`` : la bibliothèque ``shflags`` est récupérée + par ``curl`` sur la branche ``main`` d'un autre dépôt (``H6N/tools``) et exécutée par ``eval``, + sans vérification d'intégrité — dans le lab comme en production. Les artefacts de la release + sont, eux, vérifiés contre ``SHA256SUMS``. * **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian (``SHA512SUMS.sign``) n'est pas encore vérifiée. diff --git a/internal/lab/machine/machine.go b/internal/lab/machine/machine.go index 1630ed7..29e1312 100644 --- a/internal/lab/machine/machine.go +++ b/internal/lab/machine/machine.go @@ -125,6 +125,10 @@ func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Dur errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) continue } + if err := l.checkServices(ctx, n); err != nil { + errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) + continue + } fmt.Fprintf(l.Out, "%s: ready\n", n.Name) } return errors.Join(errs...) @@ -163,6 +167,26 @@ func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error { } } +func services(n topology.NodePlan) []string { + var units []string + if n.Role == topology.RoleHypervisor { + units = append(units, "agent.service") + } + if n.FRR != "" { + units = append(units, "frr.service") + } + return units +} + +func (l Lab) checkServices(ctx context.Context, n topology.NodePlan) error { + for _, unit := range services(n) { + if err := l.Runner.Run(ctx, "ssh", l.sshArgs(n, true, []string{"systemctl", "is-active", "--quiet", unit})...); err != nil { + return fmt.Errorf("%s is not active: %w", unit, err) + } + } + return nil +} + func exitCode(err error) int { var coded interface{ ExitCode() int } if errors.As(err, &coded) { diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go index 22635d6..d3bc49a 100644 --- a/internal/lab/machine/machine_test.go +++ b/internal/lab/machine/machine_test.go @@ -34,9 +34,10 @@ func (e exitErr) ExitCode() int { return int(e) } type fakeRunner struct { mu sync.Mutex calls [][]string - fail string - ssh map[string][]error - always map[string]error + fail string + ssh map[string][]error + always map[string]error + inactive string } func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { @@ -55,6 +56,9 @@ func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644) case "ssh": port := args[indexOf(args, "-p")+1] + if args[len(args)-1] == f.inactive { + return exitErr(3) + } answers := f.ssh[port] if len(answers) == 0 { return f.always[port] @@ -124,7 +128,7 @@ nodes: } const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } ` @@ -409,8 +413,8 @@ func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) { } ssh := f.runner.commands("ssh") - if len(ssh) != 3 { - t.Fatalf("%d ssh calls, want 3", len(ssh)) + if len(ssh) != 4 { + t.Fatalf("%d ssh calls, want 4", len(ssh)) } want := []string{"ssh", "-i", filepath.Join(f.lab.RunDir, "lab_ed25519"), @@ -440,8 +444,8 @@ func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) { if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { t.Fatalf("Up: %v", err) } - if n := len(f.runner.commands("ssh")); n != 5 { - t.Errorf("%d ssh calls, want 5", n) + if n := len(f.runner.commands("ssh")); n != 6 { + t.Errorf("%d ssh calls, want 6", n) } } @@ -541,3 +545,44 @@ func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) { } } } + +func TestUp_ChecksTheServicesOfEachRole(t *testing.T) { + f := newFixture(t, switchLast) + conf := filepath.Join(t.TempDir(), "sw1.conf") + if err := os.WriteFile(conf, []byte("hostname sw1\n"), 0o600); err != nil { + t.Fatal(err) + } + f.lab.Plan.Nodes[2].FRR = conf + + if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { + t.Fatalf("Up: %v", err) + } + + var checks []string + for _, c := range f.runner.commands("ssh") { + if c[len(c)-4] == "systemctl" { + checks = append(checks, c[indexOf(c, "-p")+1]+" "+strings.Join(c[len(c)-4:], " ")) + } + } + want := []string{ + "2201 systemctl is-active --quiet agent.service", + "2202 systemctl is-active --quiet frr.service", + } + if !reflect.DeepEqual(checks, want) { + t.Errorf("checks = %q, want %q", checks, want) + } +} + +func TestUp_ReportsAnInactiveServiceAndWaitsForTheOthers(t *testing.T) { + f := newFixture(t, switchLast) + f.runner.inactive = "agent.service" + + err := f.lab.Up(context.Background(), f.fetcher, time.Second) + + if err == nil || err.Error() != "node hv1: agent.service is not active: exit status 3" { + t.Errorf("error = %v", err) + } + if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") || strings.Contains(f.out.String(), "hv1: ready") { + t.Errorf("output = %q", f.out.String()) + } +} diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go index f36e514..668a0f8 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -60,8 +60,8 @@ segments: 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] } + hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` topo, err := topology.Parse([]byte(doc)) if err != nil { diff --git a/internal/lab/render/render_test.go b/internal/lab/render/render_test.go index f270d13..78bb186 100644 --- a/internal/lab/render/render_test.go +++ b/internal/lab/render/render_test.go @@ -22,8 +22,8 @@ segments: 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] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` const twoSegments = `name: two-seg @@ -36,7 +36,7 @@ segments: 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] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } ` func plan(t *testing.T, doc string) *topology.Plan { @@ -317,7 +317,7 @@ func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) { func TestUserData_Hypervisor(t *testing.T) { cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) - if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 || len(cfg.Packages) != 0 { + if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.Packages) != 0 || !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { t.Errorf("hv1 user-data = %+v", cfg) } if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) { @@ -376,9 +376,12 @@ nft -f /etc/lab-switch.nft t.Errorf("unit misses %q", want) } } - if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) { + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { t.Errorf("runcmd = %v", cfg.Runcmd) } + if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-switch.service\n" { + t.Errorf("lab-provision:\n%s", got) + } } func TestUserData_SwitchWithTwoSegments(t *testing.T) { diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go index 8ebb715..001677b 100644 --- a/internal/lab/render/roles_test.go +++ b/internal/lab/render/roles_test.go @@ -1,9 +1,11 @@ package render import ( + "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" + "os" "reflect" "strings" "testing" @@ -19,8 +21,8 @@ segments: 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] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf, release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` var frrConfigs = map[string]string{ @@ -95,9 +97,9 @@ func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) { 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) + want = "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-node.service\n/usr/local/sbin/lab-frr\n" + if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != want { + t.Errorf("lab-provision:\n%s\nwant:\n%s", got, want) } } @@ -141,22 +143,96 @@ func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) { } } -func TestRoles_FRRInstallRunsLast(t *testing.T) { +func TestRoles_ProvisioningIsOneScriptThatStopsAtTheFirstFailure(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"}, + for name, steps := range map[string]string{ + "sw1": "systemctl daemon-reload\nsystemctl enable --now lab-switch.service\n/usr/local/sbin/lab-frr\n", + "hv1": "/usr/local/sbin/lab-deploy\n/usr/local/sbin/lab-frr\n", + "hv2": "/usr/local/sbin/lab-deploy\n", } { - if got := runcmd(t, nodeNamed(t, nodes, name)); !reflect.DeepEqual(got, want) { - t.Errorf("%s runcmd = %q, want %q", name, got, want) + cfg := user(t, nodeNamed(t, nodes, name)) + if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { + t.Errorf("%s runcmd = %q", name, cfg.Runcmd) + } + f := fileAt(t, cfg, "/usr/local/sbin/lab-provision") + if f.Content != "#!/bin/sh\nset -eu\n"+steps || f.Permissions != "0755" { + t.Errorf("%s lab-provision (%s):\n%s", name, f.Permissions, f.Content) } } } -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_HypervisorDeploysTheReleaseThroughTheRepositoryScripts(t *testing.T) { + cfg := user(t, nodeNamed(t, renderRoles(t), "hv1")) + + f := fileAt(t, cfg, "/usr/local/sbin/lab-deploy") + want := `#!/bin/sh +set -eu +n=0 +until curl -fsS -o /dev/null https://git.g3e.fr/; do + n=$((n + 1)) + [ "$n" -lt 30 ] || exit 1 + sleep 10 +done +cd /opt/two/scripts +bash ./deploy.sh --noup_script -i -u underlay -t 0.2.0rc002 +` + if f.Content != want || f.Permissions != "0755" { + t.Errorf("lab-deploy (%s):\n%s\nwant:\n%s", f.Permissions, f.Content, want) + } + + for path, source := range map[string]string{ + "/opt/two/scripts/deploy.sh": "../../../scripts/deploy.sh", + "/opt/two/scripts/bootstrap_kvm.sh": "../../../scripts/bootstrap_kvm.sh", + } { + f := fileAt(t, cfg, path) + got, err := base64.StdEncoding.DecodeString(f.Content) + if err != nil { + t.Fatal(err) + } + repo, err := os.ReadFile(source) + if err != nil { + t.Fatal(err) + } + if f.Encoding != "b64" || f.Permissions != "0755" || !bytes.Equal(got, repo) { + t.Errorf("%s: encoding %q, permissions %q, identical to %s: %v", path, f.Encoding, f.Permissions, source, bytes.Equal(got, repo)) + } + } +} + +func TestRoles_UplinkIsTheInterfaceCarryingTheDefaultRoute(t *testing.T) { + hv := nodeNamed(t, renderAll(t, twoSegments), "hv") + var withDefault []string + for name, e := range network(t, hv).Ethernets { + for _, r := range e.Routes { + if r.To == "0.0.0.0/0" { + withDefault = append(withDefault, name) + } + } + } + if !reflect.DeepEqual(withDefault, []string{"red"}) { + t.Fatalf("default route on %v, want [red]", withDefault) + } + if got := fileAt(t, user(t, hv), "/usr/local/sbin/lab-deploy").Content; !strings.HasSuffix(got, "bash ./deploy.sh --noup_script -i -u red -t 0.2.0rc002\n") { + t.Errorf("lab-deploy:\n%s", got) + } +} + +func TestRoles_OnlyHypervisorsDeployTwo(t *testing.T) { + nodes := renderRoles(t) + for _, name := range []string{"sw1", "rr1"} { + for _, f := range user(t, nodeNamed(t, nodes, name)).WriteFiles { + if strings.HasPrefix(f.Path, "/opt/two/") || f.Path == "/usr/local/sbin/lab-deploy" { + t.Errorf("%s receives %s", name, f.Path) + } + } + } +} + +func TestRoles_NodeWithoutRoleFieldsGetsNoFRR(t *testing.T) { + for _, f := range user(t, nodeNamed(t, renderRoles(t), "hv2")).WriteFiles { + if strings.Contains(f.Path, "frr") { + t.Errorf("hv2 receives %s", f.Path) + } } } diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go index 6fd41e9..804ed45 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -9,6 +9,7 @@ import ( "go.yaml.in/yaml/v3" "git.g3e.fr/syonad/two/internal/lab/topology" + "git.g3e.fr/syonad/two/scripts" ) const ( @@ -25,6 +26,11 @@ const ( FRRSuite = "frr-stable" FRRRepo = "https://deb.frrouting.org/frr" FRRPackages = "frr frr-pythontools" + + ProvisionScript = "/usr/local/sbin/lab-provision" + DeployScript = "/usr/local/sbin/lab-deploy" + TwoScriptsDir = "/opt/two/scripts" + TwoGitServer = "https://git.g3e.fr/" ) //go:embed frrouting.gpg @@ -91,6 +97,7 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) DisableRoot: true, SSHAuthorizedKeys: o.AuthorizedKeys, } + var steps []string switch { case n.Role == topology.RoleSwitch: cfg.Packages = []string{"nftables"} @@ -99,19 +106,21 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) {Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)}, {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"}, - } + steps = append(steps, "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"}, - } + steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service") + } + if n.Role == topology.RoleHypervisor { + cfg.WriteFiles = append(cfg.WriteFiles, + writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)}, + writeFile{Path: TwoScriptsDir + "/bootstrap_kvm.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.BootstrapKVM)}, + writeFile{Path: DeployScript, Permissions: "0755", Content: deployScript(p, n)}, + ) + steps = append(steps, DeployScript) } if n.FRR != "" { cfg.WriteFiles = append(cfg.WriteFiles, @@ -119,7 +128,13 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) 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}) + steps = append(steps, FRRScript) + } + if len(steps) > 0 { + cfg.WriteFiles = append(cfg.WriteFiles, + writeFile{Path: ProvisionScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + strings.Join(steps, "\n") + "\n"}, + ) + cfg.Runcmd = [][]string{{ProvisionScript}} } body, err := yaml.Marshal(cfg) if err != nil { @@ -128,6 +143,18 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) return append([]byte("#cloud-config\n"), body...), nil } +func retry(command string) string { + return "n=0\nuntil " + command + "; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n" +} + +func deployScript(p *topology.Plan, n topology.NodePlan) string { + uplink := nodeCables(p, n.Name)[0].NodeInterface + return "#!/bin/sh\nset -eu\n" + + retry("curl -fsS -o /dev/null "+TwoGitServer) + + "cd " + TwoScriptsDir + "\n" + + fmt.Sprintf("bash ./deploy.sh --noup_script -i -u %s -t %s\n", uplink, n.Release) +} + func frrDaemons(n topology.NodePlan) []string { if n.Role == topology.RoleHypervisor { return []string{"bgpd"} @@ -139,7 +166,7 @@ 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") + b.WriteString(retry("apt-get update -qq --error-on=any")) 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) diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go index 1940d8c..567933a 100644 --- a/internal/lab/topology/format.go +++ b/internal/lab/topology/format.go @@ -22,15 +22,15 @@ func (p *Plan) Write(w io.Writer) error { var extras []NodePlan for _, n := range p.Nodes { - if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" { + if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" { extras = append(extras, n) } } if len(extras) > 0 { fmt.Fprintf(tw, "\nroles\n") - fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\n") + fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\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))) + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release)) } } diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index c7a8147..83487ba 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -43,6 +43,7 @@ type NodePlan struct { Secondary map[string][]netip.Prefix Loopback netip.Prefix FRR string + Release string } type Cable struct { @@ -76,6 +77,7 @@ func Compute(t *Topology) (*Plan, error) { Memory: n.Memory, SSHPort: SSHBasePort + i, FRR: n.FRR, + Release: n.Release, } for segment, raws := range n.Secondary { for _, raw := range raws { diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 1621621..4f23fef 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -55,8 +55,8 @@ segments: 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] } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } + hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } ` func TestCompute_TwoHypervisorsPlan(t *testing.T) { @@ -165,7 +165,7 @@ segments: 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] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] } `) got := make([]string, 0, len(p.Cables)) @@ -193,7 +193,7 @@ 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] } + n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a], release: 0.2.0rc002 } `) macs := map[string]bool{} ports := map[int]bool{} @@ -314,6 +314,11 @@ nodes hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202 hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 +roles + name loopback secondary frr release + hv1 - - - 0.2.0rc002 + hv2 - - - 0.2.0rc002 + 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 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go index bbd4baf..97ee53c 100644 --- a/internal/lab/topology/roles_test.go +++ b/internal/lab/topology/roles_test.go @@ -15,7 +15,7 @@ segments: 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 } + hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf, release: 0.2.0rc002 } ` func TestCompute_CarriesTheRoleFields(t *testing.T) { @@ -99,7 +99,7 @@ segments: 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] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } ` + c.node + ` ` requireContains(t, validationError(t, doc), c.want) @@ -115,7 +115,7 @@ segments: 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] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } ` msg := validationError(t, doc) requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to") @@ -130,7 +130,7 @@ 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] } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1], release: 0.2.0rc002 } ` requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface") } @@ -142,10 +142,10 @@ func TestWrite_ShowsTheRoles(t *testing.T) { } 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 + name loopback secondary frr release + 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 0.2.0rc002 ` if !bytes.Contains(buf.Bytes(), []byte(want)) { t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want) @@ -154,10 +154,63 @@ roles func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) { var buf bytes.Buffer - if err := compute(t, twoHypervisors).Write(&buf); err != nil { + doc := header + ` +segments: + underlay: { switch: sw1, cidr: 10.250.0.0/24 } +nodes: + sw1: { role: switch, image: deb, cpus: 1, memory: 512 } + rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [underlay] } +` + if err := compute(t, doc).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()) } } + +func TestValidate_ReleaseRejections(t *testing.T) { + cases := map[string]struct { + node string + want string + }{ + "hypervisor without release": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red] }`, + "node hv: a hypervisor needs the release of two to deploy (release: )", + }, + "release with shell characters": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: "0.2.0; reboot" }`, + `node hv: release "0.2.0; reboot" must match`, + }, + "release on a route reflector": { + `hv: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }`, + "node hv: release is only for hypervisors", + }, + "release on a switch": { + `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 } + sw2: { role: switch, image: deb, cpus: 1, memory: 512, release: 0.2.0rc002 }`, + "node sw2: release is only for hypervisors", + }, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + ` + c.node + ` +` + requireContains(t, validationError(t, doc), c.want) + }) + } +} + +func TestCompute_CarriesTheRelease(t *testing.T) { + if got := nodeOf(t, compute(t, withRoles), "hv1").Release; got != "0.2.0rc002" { + t.Errorf("hv1 release = %q", got) + } + if got := nodeOf(t, compute(t, withRoles), "rr1").Release; got != "" { + t.Errorf("rr1 release = %q", got) + } +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go index 8b3b35a..72c532a 100644 --- a/internal/lab/topology/topology.go +++ b/internal/lab/topology/topology.go @@ -49,6 +49,7 @@ type Node struct { Secondary map[string][]string Loopback string FRR string + Release string } type fileImage struct { @@ -72,6 +73,7 @@ type fileNode struct { Secondary map[string][]string `yaml:"secondary"` Loopback string `yaml:"loopback"` FRR string `yaml:"frr"` + Release string `yaml:"release"` } type file struct { @@ -141,6 +143,7 @@ func Parse(data []byte) (*Topology, error) { Secondary: n.Secondary, Loopback: n.Loopback, FRR: n.FRR, + Release: n.Release, }) } return t, nil diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index d51914b..2128c68 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -23,6 +23,7 @@ const ( var ( namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`) + releasePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) ) func (t *Topology) Validate() error { @@ -206,6 +207,14 @@ func validateExtras(n Node, segments map[string]Segment, add func(string, ...any } } } + switch { + case n.Role == RoleHypervisor && n.Release == "": + add("node %s: a hypervisor needs the release of two to deploy (release: )", n.Name) + case n.Role == RoleHypervisor && !releasePattern.MatchString(n.Release): + add("node %s: release %q must match %s", n.Name, n.Release, releasePattern) + case n.Role != RoleHypervisor && n.Release != "": + add("node %s: release is only for hypervisors", n.Name) + } if n.Loopback != "" { prefix, err := netip.ParsePrefix(n.Loopback) switch { diff --git a/scripts/embed.go b/scripts/embed.go new file mode 100644 index 0000000..481e8e5 --- /dev/null +++ b/scripts/embed.go @@ -0,0 +1,9 @@ +package scripts + +import _ "embed" + +//go:embed deploy.sh +var Deploy []byte + +//go:embed bootstrap_kvm.sh +var BootstrapKVM []byte From 24f6e40a5d5783afbea887d30bd47829abb595e4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:44:40 +0200 Subject: [PATCH 17/25] =?UTF-8?q?f-50:=20docs:=20sch=C3=A9ma=20d'architect?= =?UTF-8?q?ure=20du=20cluster=20=E2=80=94=20routeurs,=20route=20reflector,?= =?UTF-8?q?=20hyperviseurs,=20plans=20de=20donn=C3=A9es=20et=20de=20contr?= =?UTF-8?q?=C3=B4le=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/architecture-cluster.rst | 14 +- docs/schemas/architecture-cluster-dark.svg | 154 +++++++++++++++++++++ docs/schemas/architecture-cluster.svg | 154 +++++++++++++++++++++ docs/schemas/topologie-cluster-dark.svg | 52 ------- docs/schemas/topologie-cluster.svg | 52 ------- 5 files changed, 316 insertions(+), 110 deletions(-) create mode 100644 docs/schemas/architecture-cluster-dark.svg create mode 100644 docs/schemas/architecture-cluster.svg delete mode 100644 docs/schemas/topologie-cluster-dark.svg delete mode 100644 docs/schemas/topologie-cluster.svg diff --git a/docs/deploiement/architecture-cluster.rst b/docs/deploiement/architecture-cluster.rst index d1eb9bf..3956bab 100644 --- a/docs/deploiement/architecture-cluster.rst +++ b/docs/deploiement/architecture-cluster.rst @@ -4,21 +4,23 @@ Architecture du cluster Topologie --------- -.. figure:: /schemas/topologie-cluster.svg - :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs +.. figure:: /schemas/architecture-cluster.svg + :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle :align: center :width: 100% :class: only-light - Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle + (sessions BGP). Pointillé gris : interfaces créées par l'agent. -.. figure:: /schemas/topologie-cluster-dark.svg - :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs +.. figure:: /schemas/architecture-cluster-dark.svg + :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle :align: center :width: 100% :class: only-dark - Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. + Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle + (sessions BGP). Pointillé gris : interfaces créées par l'agent. Deux plans distincts, à ne pas confondre au moment du diagnostic : diff --git a/docs/schemas/architecture-cluster-dark.svg b/docs/schemas/architecture-cluster-dark.svg new file mode 100644 index 0000000..eecea79 --- /dev/null +++ b/docs/schemas/architecture-cluster-dark.svg @@ -0,0 +1,154 @@ + + + + + + + + + + ROUTEURS DE CLUSTER · AS 65100 + + routeur 1 + 169.254.0.1/28 + + routeur 2 + 169.254.0.2/28 + + + VM ROUTE REFLECTOR · AS 65000 + lo1 · 10.255.255.1/32 + router-id, cluster-id, source des sessions EVPN + 169.254.0.3/28 + adresse secondaire sur son interface du segment + hébergée par le cluster lui-même + + + eBGP · BFD (×2) + annonce lo1 seule + n'accepte rien + + + + + + + + segment underlay · L2 + 192.168.14.0/24 · 169.254.0.0/28 + + + HYPERVISEUR 1 + + + br-000000 + uplink · 192.168.14.11 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + HYPERVISEUR 2 + + + br-000000 + uplink · 192.168.14.12 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + + + + + + iBGP l2vpn evpn · AS 64600 + iBGP + + + VXLAN + UDP 4789 + + + lien L2 / interface + + plan de données : tunnels VXLAN + + plan de contrôle : sessions BGP + + créé par l'agent + + Les hyperviseurs joignent 10.255.255.1 par leur route par défaut : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. + Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). + MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. + ASN, loopback et plages : valeurs de production (#50). Adresses des hyperviseurs : celles du lab. Placement de la VM route reflector : à rédiger. + diff --git a/docs/schemas/architecture-cluster.svg b/docs/schemas/architecture-cluster.svg new file mode 100644 index 0000000..130b014 --- /dev/null +++ b/docs/schemas/architecture-cluster.svg @@ -0,0 +1,154 @@ + + + + + + + + + + ROUTEURS DE CLUSTER · AS 65100 + + routeur 1 + 169.254.0.1/28 + + routeur 2 + 169.254.0.2/28 + + + VM ROUTE REFLECTOR · AS 65000 + lo1 · 10.255.255.1/32 + router-id, cluster-id, source des sessions EVPN + 169.254.0.3/28 + adresse secondaire sur son interface du segment + hébergée par le cluster lui-même + + + eBGP · BFD (×2) + annonce lo1 seule + n'accepte rien + + + + + + + + segment underlay · L2 + 192.168.14.0/24 · 169.254.0.0/28 + + + HYPERVISEUR 1 + + + br-000000 + uplink · 192.168.14.11 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + HYPERVISEUR 2 + + + br-000000 + uplink · 192.168.14.12 + + vxlan-<vni> + dans br-<subnet>, sans learning + + netns du VPC + passerelle, DHCP + + VM + QEMU / KVM + + VTEP + + veth + + tap + + + FRR + bgpd · AS 64600 + advertise-all-vni + + agent + API REST · Badger + + + FDB + + crée + + + + + + + + iBGP l2vpn evpn · AS 64600 + iBGP + + + VXLAN + UDP 4789 + + + lien L2 / interface + + plan de données : tunnels VXLAN + + plan de contrôle : sessions BGP + + créé par l'agent + + Les hyperviseurs joignent 10.255.255.1 par leur route par défaut : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. + Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). + MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. + ASN, loopback et plages : valeurs de production (#50). Adresses des hyperviseurs : celles du lab. Placement de la VM route reflector : à rédiger. + diff --git a/docs/schemas/topologie-cluster-dark.svg b/docs/schemas/topologie-cluster-dark.svg deleted file mode 100644 index 48a3916..0000000 --- a/docs/schemas/topologie-cluster-dark.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - ROUTEURS DE CLUSTER - - routeur A - - routeur B - - - VM route reflector - plan de contrôle - - - HYPERVISEUR 1 - - br-000000 - - FRR - - agent - - - - - HYPERVISEUR 2 - - br-000000 - - FRR - - agent - - - - - - uplink - uplink - - - - diff --git a/docs/schemas/topologie-cluster.svg b/docs/schemas/topologie-cluster.svg deleted file mode 100644 index 858d18a..0000000 --- a/docs/schemas/topologie-cluster.svg +++ /dev/null @@ -1,52 +0,0 @@ - - - - - ROUTEURS DE CLUSTER - - routeur A - - routeur B - - - VM route reflector - plan de contrôle - - - HYPERVISEUR 1 - - br-000000 - - FRR - - agent - - - - - HYPERVISEUR 2 - - br-000000 - - FRR - - agent - - - - - - uplink - uplink - - - - From 1403db4077f80623625ae0a76e11b7550d5833f4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:49:09 +0200 Subject: [PATCH 18/25] =?UTF-8?q?f-50:=20docs:=20adresses=20192.168.14.0/2?= =?UTF-8?q?4=20du=20route=20reflector=20et=20de=20la=20passerelle=20sur=20?= =?UTF-8?q?le=20sch=C3=A9ma=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/schemas/architecture-cluster-dark.svg | 9 +++++---- docs/schemas/architecture-cluster.svg | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/schemas/architecture-cluster-dark.svg b/docs/schemas/architecture-cluster-dark.svg index eecea79..483d0f0 100644 --- a/docs/schemas/architecture-cluster-dark.svg +++ b/docs/schemas/architecture-cluster-dark.svg @@ -31,13 +31,14 @@ routeur 2 169.254.0.2/28 + passerelle 192.168.14.1/24 VM ROUTE REFLECTOR · AS 65000 lo1 · 10.255.255.1/32 router-id, cluster-id, source des sessions EVPN - 169.254.0.3/28 - adresse secondaire sur son interface du segment + 192.168.14.2/24 · 169.254.0.3/28 + principale, et secondaire pour le lien avec les routeurs hébergée par le cluster lui-même @@ -146,9 +147,9 @@ créé par l'agent - Les hyperviseurs joignent 10.255.255.1 par leur route par défaut : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. - ASN, loopback et plages : valeurs de production (#50). Adresses des hyperviseurs : celles du lab. Placement de la VM route reflector : à rédiger. + ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. diff --git a/docs/schemas/architecture-cluster.svg b/docs/schemas/architecture-cluster.svg index 130b014..25028d6 100644 --- a/docs/schemas/architecture-cluster.svg +++ b/docs/schemas/architecture-cluster.svg @@ -31,13 +31,14 @@ routeur 2 169.254.0.2/28 + passerelle 192.168.14.1/24 VM ROUTE REFLECTOR · AS 65000 lo1 · 10.255.255.1/32 router-id, cluster-id, source des sessions EVPN - 169.254.0.3/28 - adresse secondaire sur son interface du segment + 192.168.14.2/24 · 169.254.0.3/28 + principale, et secondaire pour le lien avec les routeurs hébergée par le cluster lui-même @@ -146,9 +147,9 @@ créé par l'agent - Les hyperviseurs joignent 10.255.255.1 par leur route par défaut : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. + Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. - ASN, loopback et plages : valeurs de production (#50). Adresses des hyperviseurs : celles du lab. Placement de la VM route reflector : à rédiger. + ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. From 1d793b03e3c1d2fd3a4348f4d2b3d15a9424695c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 17:57:11 +0200 Subject: [PATCH 19/25] =?UTF-8?q?f-50:=20docs:=20route=20reflector=20?= =?UTF-8?q?=E2=80=94=20adressage=20et=20configuration=20FRR=20valid=C3=A9s?= =?UTF-8?q?=20dans=20le=20lab=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/route-reflector.rst | 103 +++++++++++++++++++++++++-- 1 file changed, 97 insertions(+), 6 deletions(-) diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index e0095d2..ab57b32 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -8,15 +8,106 @@ reflector, qui redistribue. Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster. +Adressage +--------- + +Le route reflector est **sur le même segment L2 que les hyperviseurs**, avec trois adresses : + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Adresse + - Interface + - Rôle + * - une adresse de ``192.168.14.0/24`` + - interface principale + - celle de n'importe quelle machine du segment ; les réponses aux hyperviseurs en partent + * - ``169.254.0.3/28`` + - interface principale, **adresse secondaire** + - le lien avec les routeurs de cluster (``169.254.0.1`` et ``.2``), sur le même L2 + * - ``10.255.255.1/32`` + - ``lo1``, interface ``dummy`` + - ``router-id``, ``cluster-id`` et source des sessions EVPN ; seule route annoncée aux routeurs + +Les hyperviseurs ne connaissent que la loopback : ils ouvrent leur session vers ``10.255.255.1`` +par leur passerelle (``192.168.14.1``), les routeurs l'ayant apprise du route reflector en eBGP. +Aucune adresse d'hyperviseur n'est déclarée côté route reflector : il accepte toute session venant +du segment (``bgp listen range``). + +L'adresse du lien est une **adresse secondaire** de l'interface principale, pas une interface à +part : même L2, même MAC sur le fil, une interface de moins qu'avec un ``ipvlan``. Elle doit +survivre aux renouvellements DHCP de l'adresse principale : la déclarer dans la configuration +réseau du système, pas la poser à la main. + .. note:: - **À rédiger.** À documenter : + **Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est : - * la création de la VM : ressources, subnet et mode utilisés, et s'il s'agit d'une VM créée - par l'agent comme les autres ou d'un cas particulier — l'image, elle, est l'image golden de - :doc:`image-qcow2` ; - * son adressage, et comment les hyperviseurs le connaissent ; - * la configuration du démon de routage qu'elle héberge ; + .. code-block:: bash + + nmcli connection modify +ipv4.addresses 169.254.0.3/28 + nmcli connection add type dummy ifname lo1 con-name lo1 \ + ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled + + Le lab, en Debian, pose ces adresses par cloud-init et un script rejoué au démarrage ; ces + commandes restent à valider sur l'image golden. + +Configuration de FRR +-------------------- + +``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du +lab** (``conf/lab/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom +d'hôte diffère. Le lab qualifie donc exactement ce fichier. + +.. literalinclude:: ../../conf/lab/frr/rr1.conf + :language: text + +Ce qu'elle établit : + +* **Routeurs de cluster** (groupe ``CLUSTER``) : eBGP vers l'AS 65100, avec BFD, en IPv4 + unicast. Le route reflector n'annonce que sa loopback (``RR-LOOPBACK-OUT``) et **n'accepte + rien** (``NO-IN``) : il ne reçoit aucune route des routeurs. +* **Hyperviseurs** (groupe ``fabric``) : voisins dynamiques, toute session venant de + ``192.168.14.0/24`` étant acceptée, jusqu'à 200. ``local-as 64600 no-prepend replace-as`` fait + que, du point de vue des hyperviseurs, la session est en iBGP dans l'AS 64600 — celui de leur + configuration — alors que le route reflector est en AS 65000 face aux routeurs. +* **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients + (``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres. + +Vérifié dans le lab +~~~~~~~~~~~~~~~~~~~ + +Le 2026-10-04, FRR 10.7.1, route reflector en Debian 12 sur le segment des hyperviseurs, avec +l'adresse du lien en secondaire et la loopback sur ``lo1`` : + +.. list-table:: + :header-rows: 1 + :widths: 55 45 + + * - Vérification + - Résultat + * - session avec le routeur, IPv4 unicast + - Established ; le routeur reçoit **un seul** préfixe, la loopback, et l'installe via + ``169.254.0.3`` + * - BFD avec le routeur + - up des deux côtés + * - sessions des deux hyperviseurs vers ``10.255.255.1`` + - Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée + * - routes EVPN échangées + - aucune : rien à annoncer tant que two n'a pas créé de VXLAN — voir + :doc:`premier-hyperviseur` + +Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs +de cluster (:doc:`routeurs`). + +.. note:: + + **À rédiger.** Reste à documenter : + + * la création de la VM : ressources, subnet et mode utilisés (``bridge``, pour pouvoir la + lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les + autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; * la procédure de reconstruction, et l'état du cluster pendant que le route reflector est absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de From 0659dc69a525005fc03f39e02e3e124c23d4fdbf Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 19:37:57 +0200 Subject: [PATCH 20/25] =?UTF-8?q?f-50:=20lab:=20seedfrom=20avec=20barre=20?= =?UTF-8?q?oblique=20finale,=20push=20sans=20propri=C3=A9taire,=20r=C3=A9s?= =?UTF-8?q?ultats=20d'E4=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/image-qcow2.rst | 20 +++++++++++----- docs/developpement/lab.rst | 40 ++++++++++++++++++++++++++++++++ scripts/lab-host.sh | 2 +- scripts/lab-host_test.sh | 2 +- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index 1283f52..cba2e27 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -250,7 +250,7 @@ champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une sessio datasource_list: [ NoCloud ] datasource: NoCloud: - seedfrom: 'http://169.254.169.254:80' + seedfrom: 'http://169.254.169.254:80/' timeout: 5 max_wait: 10 ENDFILE @@ -315,12 +315,20 @@ Points de vigilance aucun espace à l'host : les qcow2 ne se rétractent pas. L'activation reste utile pour le jour où l'option sera ajoutée côté agent, mais ne pas compter dessus pour la place disque. -.. note:: +.. warning:: - **À vérifier** — ``seedfrom`` sans barre oblique finale. cloud-init construit l'URL des - documents en concaténant ``seedfrom`` avec ``meta-data`` et ``user-data``. Confirmer sur une - VM réelle que les deux documents sont bien récupérés, et corriger en - ``http://169.254.169.254:80/`` si ce n'est pas le cas. + **La barre oblique finale de** ``seedfrom`` **est indispensable avant cloud-init 23.1.** + cloud-init construit l'URL des documents en concaténant ``seedfrom`` avec ``meta-data``, + ``user-data`` et ``vendor-data`` (``util.read_seeded``). Jusqu'à la 22.4 — celle de Debian 12, + 22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la + source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de + 23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre + oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. + + Vérifié le 2026-10-04 dans le lab (#50), sur une VM Debian 12 lancée par l'agent : sans barre + oblique, ``Datasource DataSourceNone`` ; avec, ``DataSourceNoCloudNet + [seed=…http://169.254.169.254…]`` et le nom d'hôte de la VM. Avec la barre oblique, la + configuration fonctionne quelle que soit la version de cloud-init. .. note:: diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 3282898..2504bc0 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -372,6 +372,46 @@ secours, et la VM redémarrée ne rejoue pas ``runcmd``. exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le lab : ``lab down`` puis ``lab up``. +Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` : + +.. code-block:: text + + $ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml' + sw1: started + rr1: started + hv1: started + hv2: started + sw1: ready + rr1: ready + hv1: ready + hv2: ready + +.. list-table:: + :header-rows: 1 + :widths: 55 45 + + * - Vérification + - Résultat + * - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle + - 6 min 15 + * - session switch ↔ route reflector, IPv4 unicast, BFD + - Established, BFD up ; le switch reçoit la seule loopback du route reflector + * - sessions EVPN des deux hyperviseurs vers la loopback du route reflector + - Established, voisins dynamiques, stables + * - réseau d'un hyperviseur après ``deploy.sh`` + - adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond + * - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1 + - ``login:`` en 20 s, en KVM imbriqué + * - DHCP et routes (option 121) servis par two à la VM + - conformes, route ``/32`` vers ``169.254.169.254`` comprise + * - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2` + - ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de + ``seedfrom`` (voir cette page) + * - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan`` + - **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN — + `#51 `_ ; avec l'adresse posée, ping et MTU 1500 + passent + .. note:: La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh index a0d1f7b..d06c1d7 100755 --- a/scripts/lab-host.sh +++ b/scripts/lab-host.sh @@ -337,7 +337,7 @@ push_file () { push_dir () { local SOURCE="${1}" COPYFILE_DISABLE=1 tar --no-xattrs -C "${SOURCE}" -cf - . \ - | ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part -xf - && rm -rf topology && mv topology.part topology" + | ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" } cmd_push () { diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh index 1e82388..9953579 100755 --- a/scripts/lab-host_test.sh +++ b/scripts/lab-host_test.sh @@ -671,7 +671,7 @@ test_push_builds_for_linux_and_sends_binary_and_topology () { [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \ || fail "compilation : $(cat "${WORK}/go.log")" grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent" - grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \ + grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \ || fail "envoi du répertoire absent" [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")" LISTING=$(tar -tf "${WORK}/pushed.2" | sed 's|^\./||' | grep -v '/$' | grep -v '^$' | sort | tr '\n' ' ') From 5728fdd33c85f7df1781c53370723fac64d3cf20 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 20:43:53 +0200 Subject: [PATCH 21/25] =?UTF-8?q?f-50:=20lab:=20sc=C3=A9narios=20E5,=20con?= =?UTF-8?q?figuration=20de=20l'agent=20par=20hyperviseur,=20release=200.2.?= =?UTF-8?q?0rc003=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main.go | 7 +- conf/lab/agent/two.yml | 2 + conf/lab/evpn-2hv.yml | 5 +- docs/developpement/lab.rst | 85 ++++++++++- internal/lab/machine/machine_test.go | 4 +- internal/lab/provision/provision.go | 24 +++- internal/lab/provision/provision_test.go | 20 +++ internal/lab/render/render.go | 4 + internal/lab/render/roles_test.go | 34 +++++ internal/lab/render/seed.go | 4 + internal/lab/topology/format.go | 6 +- internal/lab/topology/plan.go | 2 + internal/lab/topology/plan_test.go | 6 +- internal/lab/topology/roles_test.go | 57 +++++++- internal/lab/topology/topology.go | 6 + internal/lab/topology/validate.go | 3 + scripts/lab/node.sh | 151 ++++++++++++++++++++ scripts/lab/scenario.sh | 67 +++++++++ scripts/lab/scenario_test.sh | 140 ++++++++++++++++++ scripts/lab/scenarios/s1-dhcp-two.sh | 34 +++++ scripts/lab/scenarios/s2-gateway.sh | 26 ++++ scripts/lab/scenarios/s3-isolation-local.sh | 16 +++ scripts/lab/scenarios/s4-evpn.sh | 25 ++++ scripts/lab/scenarios/s5-isolation-evpn.sh | 23 +++ scripts/lab/scenarios/s6-rr-loss.sh | 36 +++++ 25 files changed, 760 insertions(+), 27 deletions(-) create mode 100644 conf/lab/agent/two.yml create mode 100644 scripts/lab/node.sh create mode 100755 scripts/lab/scenario.sh create mode 100755 scripts/lab/scenario_test.sh create mode 100644 scripts/lab/scenarios/s1-dhcp-two.sh create mode 100644 scripts/lab/scenarios/s2-gateway.sh create mode 100644 scripts/lab/scenarios/s3-isolation-local.sh create mode 100644 scripts/lab/scenarios/s4-evpn.sh create mode 100644 scripts/lab/scenarios/s5-isolation-evpn.sh create mode 100644 scripts/lab/scenarios/s6-rr-loss.sh diff --git a/cmd/lab/main.go b/cmd/lab/main.go index df86e39..3ff3cf2 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -135,7 +135,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 } - nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr}) + agent, err := provision.ReadAgent(p) + if err != nil { + fmt.Fprintf(stderr, "lab: %v\n", err) + return 1 + } + nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr, Agent: agent}) if err != nil { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 diff --git a/conf/lab/agent/two.yml b/conf/lab/agent/two.yml new file mode 100644 index 0000000..6334d32 --- /dev/null +++ b/conf/lab/agent/two.yml @@ -0,0 +1,2 @@ +dhcp: + backend: two diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index a3ebfa5..0176ef1 100644 --- a/conf/lab/evpn-2hv.yml +++ b/conf/lab/evpn-2hv.yml @@ -21,7 +21,8 @@ nodes: loopback: 10.255.255.1/32, frr: frr/rr1.conf } hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc002 } + addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc003, + agent: agent/two.yml } hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc002 } + addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc003 } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 2504bc0..6ff6fb1 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -230,8 +230,12 @@ Ce que le fichier déclare : * ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_). * ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de - two que ``deploy.sh`` installe (``release: 0.2.0rc002``). Sans lui, ``deploy.sh`` prendrait la + two que ``deploy.sh`` installe (``release: 0.2.0rc003``). Sans lui, ``deploy.sh`` prendrait la dernière release, et le lab ne serait plus reproductible. + * ``agent`` — pour un hyperviseur seulement : le chemin d'un ``agent.yml``, relatif au fichier + de topologie, déposé dans ``/etc/two/agent.yml`` avant ``deploy.sh``. Sans lui, l'agent tourne + avec sa configuration par défaut. L'exemple met hv1 sur le serveur DHCP intégré + (``conf/lab/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq. ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. @@ -278,11 +282,11 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203 roles - name loopback secondary frr release - 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 0.2.0rc002 - hv2 - - hv2.conf 0.2.0rc002 + name loopback secondary frr release agent + 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 0.2.0rc003 two.yml + hv2 - - hv2.conf 0.2.0rc003 - segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1 node interface address mac udp switch port mac udp @@ -660,6 +664,74 @@ Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, top * - ``lab down`` puis ``lab up`` d'une autre topologie - conforme +Scénarios +--------- + +Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``push``, ``./lab up``) : + +.. code-block:: text + + scripts/lab/scenario.sh s1 # un scénario + scripts/lab/scenario.sh s1 s3 # plusieurs + scripts/lab/scenario.sh all # tous, dans l'ordre + +Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière +ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de +sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**. + +.. list-table:: + :header-rows: 1 + :widths: 22 58 20 + + * - Scénario + - Ce qui doit être vrai + - Hyperviseurs + * - ``s1-dhcp-two`` + - backend DHCP ``two``, une VPC et deux subnets : chaque VM reçoit l'adresse de **son** + subnet, démarrée seule ou en même temps qu'une autre ; le bail est tenu par + systemd-networkd ; route par défaut, route vers la VPC et ``/32`` vers les métadonnées + via ``interface_ip`` ; l'état de chaque serveur DHCP ne connaît que les MAC de son subnet + (#46) + - hv1 + * - ``s2-gateway`` + - route par défaut via ``interface_ip``, ou via ``gateway`` avec ``default_route`` ; route + vers la VPC toujours via ``interface_ip`` ; même résultat après recréation du subnet (#31) + - hv1 + * - ``s3-isolation-local`` + - deux VPC sur le même hyperviseur ne se joignent pas, en ICMP comme en TCP ; chaque VM + joint sa passerelle (témoin) + - hv1 + * - ``s4-evpn`` + - adresse VTEP locale sur les VXLAN (#51), VTEP distant appris par EVPN, ping VM ↔ VM + entre hyperviseurs, trame de 1472 octets en ``-M do``, 1473 refusés + - hv1, hv2 + * - ``s5-isolation-evpn`` + - deux VPC de même plage, VNI différentes, sur deux hyperviseurs : la VM joint celle de sa + VPC sur l'autre hyperviseur (témoin) et pas celle de l'autre VPC — aucune résolution ARP + - hv1, hv2 + * - ``s6-rr-loss`` + - FRR arrêté sur le route reflector, ping continu entre les VM de ``s4`` : la session tombe, + le trafic restant est **mesuré** (``INFO``) à 30 et 90 s ; au retour du route reflector, le + VTEP distant est réappris et le trafic repasse — délai mesuré + - hv1, hv2, rr1 + +``s6`` réutilise les VM de ``s4`` : le lancer après. + +**Comment c'est fait.** ``scripts/lab/scenario.sh`` exécute chaque scénario sur le Mac ; un +scénario envoie des blocs de shell aux nœuds par ``on [VAR=valeur…] <<'NODE'``, précédés de +``scripts/lab/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two +(préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM, +``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` : +elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un +SSH en panne ne passe jamais pour une isolation. + +Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur +``syonad`` créé par les métadonnées de two : + +.. code-block:: text + + scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'" + Facturation ----------- @@ -751,6 +823,7 @@ Tests .. code-block:: bash bash scripts/lab-host_test.sh + bash scripts/lab/scenario_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 diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go index d3bc49a..78e2ba4 100644 --- a/internal/lab/machine/machine_test.go +++ b/internal/lab/machine/machine_test.go @@ -32,8 +32,8 @@ func (e exitErr) Error() string { return "exit status " + strconv.Itoa(int(e)) } func (e exitErr) ExitCode() int { return int(e) } type fakeRunner struct { - mu sync.Mutex - calls [][]string + mu sync.Mutex + calls [][]string fail string ssh map[string][]error always map[string]error diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go index b1ee4d0..b83751a 100644 --- a/internal/lab/provision/provision.go +++ b/internal/lab/provision/provision.go @@ -76,7 +76,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: frr}) + agent, err := ReadAgent(p) + if err != nil { + return nil, err + } + nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr, Agent: agent}) if err != nil { return nil, err } @@ -89,18 +93,26 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e } func ReadFRR(p *topology.Plan) (map[string]string, error) { - configs := map[string]string{} + return readNodeFiles(p, func(n topology.NodePlan) string { return n.FRR }) +} + +func ReadAgent(p *topology.Plan) (map[string]string, error) { + return readNodeFiles(p, func(n topology.NodePlan) string { return n.Agent }) +} + +func readNodeFiles(p *topology.Plan, path func(topology.NodePlan) string) (map[string]string, error) { + files := map[string]string{} for _, n := range p.Nodes { - if n.FRR == "" { + if path(n) == "" { continue } - data, err := os.ReadFile(n.FRR) + data, err := os.ReadFile(path(n)) if err != nil { return nil, fmt.Errorf("node %s: %w", n.Name, err) } - configs[n.Name] = string(data) + files[n.Name] = string(data) } - return configs, nil + return files, nil } func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) { diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go index 668a0f8..403d3cc 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -385,3 +385,23 @@ func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) { } t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data) } + +func TestReadAgent_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) { + conf := filepath.Join(t.TempDir(), "two.yml") + if err := os.WriteFile(conf, []byte("dhcp:\n backend: two\n"), 0o600); err != nil { + t.Fatal(err) + } + m := newMirror(t) + p := labPlan(t, m) + p.Nodes[2].Agent = conf + + got, err := ReadAgent(p) + + if err != nil || !reflect.DeepEqual(got, map[string]string{"hv1": "dhcp:\n backend: two\n"}) { + t.Errorf("ReadAgent = %q, %v", got, err) + } + p.Nodes[3].Agent = filepath.Join(t.TempDir(), "absent.yml") + if _, err := ReadAgent(p); err == nil || !strings.Contains(err.Error(), "node hv2: ") { + t.Errorf("error = %v", err) + } +} diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go index 6a11f0d..be95453 100644 --- a/internal/lab/render/render.go +++ b/internal/lab/render/render.go @@ -31,6 +31,7 @@ type Options struct { RunDir string AuthorizedKeys []string FRR map[string]string + Agent map[string]string } type Node struct { @@ -50,6 +51,9 @@ func Render(p *topology.Plan, o Options) ([]Node, error) { 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) } + if _, ok := o.Agent[n.Name]; n.Agent != "" && !ok { + return nil, fmt.Errorf("node %s: agent configuration %s was not read", n.Name, n.Agent) + } } var nodes []Node for index, n := range p.Nodes { diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go index 001677b..fd0e0de 100644 --- a/internal/lab/render/roles_test.go +++ b/internal/lab/render/roles_test.go @@ -262,3 +262,37 @@ func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) { t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want) } } + +func TestRoles_HypervisorAgentConfigIsWrittenBeforeTheDeployment(t *testing.T) { + doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1) + nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs, Agent: map[string]string{"hv1": "dhcp:\n backend: two\n"}}) + if err != nil { + t.Fatalf("Render: %v", err) + } + var hv1, hv2 Node + for _, n := range nodes { + switch n.Name { + case "hv1": + hv1 = n + case "hv2": + hv2 = n + } + } + f := fileAt(t, user(t, hv1), "/etc/two/agent.yml") + if f.Content != "dhcp:\n backend: two\n" || f.Permissions != "0640" { + t.Errorf("agent.yml (%s) = %q", f.Permissions, f.Content) + } + for _, w := range user(t, hv2).WriteFiles { + if w.Path == "/etc/two/agent.yml" { + t.Error("hv2 receives an agent.yml it does not declare") + } + } +} + +func TestRoles_RefusesAnUnreadAgentConfig(t *testing.T) { + doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1) + _, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) + if err == nil || err.Error() != "node hv1: agent configuration two.yml was not read" { + t.Errorf("error = %v", err) + } +} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go index 804ed45..0255140 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -31,6 +31,7 @@ const ( DeployScript = "/usr/local/sbin/lab-deploy" TwoScriptsDir = "/opt/two/scripts" TwoGitServer = "https://git.g3e.fr/" + AgentConfig = "/etc/two/agent.yml" ) //go:embed frrouting.gpg @@ -114,6 +115,9 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) } steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service") } + if n.Agent != "" { + cfg.WriteFiles = append(cfg.WriteFiles, writeFile{Path: AgentConfig, Permissions: "0640", Content: o.Agent[n.Name]}) + } if n.Role == topology.RoleHypervisor { cfg.WriteFiles = append(cfg.WriteFiles, writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)}, diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go index 567933a..1ba73fd 100644 --- a/internal/lab/topology/format.go +++ b/internal/lab/topology/format.go @@ -22,15 +22,15 @@ func (p *Plan) Write(w io.Writer) error { var extras []NodePlan for _, n := range p.Nodes { - if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" { + if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" || n.Agent != "" { extras = append(extras, n) } } if len(extras) > 0 { fmt.Fprintf(tw, "\nroles\n") - fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\n") + fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\tagent\n") for _, n := range extras { - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release)) + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release), orDash(filepath.Base(n.Agent))) } } diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 83487ba..e5ba293 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -44,6 +44,7 @@ type NodePlan struct { Loopback netip.Prefix FRR string Release string + Agent string } type Cable struct { @@ -78,6 +79,7 @@ func Compute(t *Topology) (*Plan, error) { SSHPort: SSHBasePort + i, FRR: n.FRR, Release: n.Release, + Agent: n.Agent, } for segment, raws := range n.Secondary { for _, raw := range raws { diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 4f23fef..c8d4e05 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -315,9 +315,9 @@ nodes hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 roles - name loopback secondary frr release - hv1 - - - 0.2.0rc002 - hv2 - - - 0.2.0rc002 + name loopback secondary frr release agent + hv1 - - - 0.2.0rc002 - + hv2 - - - 0.2.0rc002 - 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 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go index 97ee53c..d3e209c 100644 --- a/internal/lab/topology/roles_test.go +++ b/internal/lab/topology/roles_test.go @@ -142,10 +142,10 @@ func TestWrite_ShowsTheRoles(t *testing.T) { } want := ` roles - name loopback secondary frr release - 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 0.2.0rc002 + name loopback secondary frr release agent + 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 0.2.0rc002 - ` if !bytes.Contains(buf.Bytes(), []byte(want)) { t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want) @@ -214,3 +214,52 @@ func TestCompute_CarriesTheRelease(t *testing.T) { t.Errorf("rr1 release = %q", got) } } + +func TestValidate_AgentOnlyForHypervisors(t *testing.T) { + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], agent: agent.yml } + hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent.yml } +` + msg := validationError(t, doc) + requireContains(t, msg, "node rr: agent is only for hypervisors") + if bytes.Contains([]byte(msg), []byte("node hv: agent")) { + t.Errorf("a hypervisor agent was refused:\n%s", msg) + } +} + +func TestLoad_ResolvesTheAgentPathAgainstTheTopologyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "lab.yml") + doc := header + ` +segments: + red: { switch: sw, cidr: 10.1.0.0/24 } +nodes: + sw: { role: switch, image: deb, cpus: 1, memory: 512 } + hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent/two.yml } + hv2: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: /etc/lab/agent.yml } +` + if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + topo, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + want := map[string]string{"sw": "", "hv1": filepath.Join(dir, "agent", "two.yml"), "hv2": "/etc/lab/agent.yml"} + for _, n := range topo.Nodes { + if n.Agent != want[n.Name] { + t.Errorf("%s agent = %q, want %q", n.Name, n.Agent, want[n.Name]) + } + } + p, err := Compute(topo) + if err != nil { + t.Fatal(err) + } + if got := nodeOf(t, p, "hv1").Agent; got != filepath.Join(dir, "agent", "two.yml") { + t.Errorf("plan hv1 agent = %q", got) + } +} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go index 72c532a..66337d8 100644 --- a/internal/lab/topology/topology.go +++ b/internal/lab/topology/topology.go @@ -50,6 +50,7 @@ type Node struct { Loopback string FRR string Release string + Agent string } type fileImage struct { @@ -74,6 +75,7 @@ type fileNode struct { Loopback string `yaml:"loopback"` FRR string `yaml:"frr"` Release string `yaml:"release"` + Agent string `yaml:"agent"` } type file struct { @@ -96,6 +98,9 @@ func Load(path string) (*Topology, error) { if n.FRR != "" && !filepath.IsAbs(n.FRR) { t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), n.FRR) } + if n.Agent != "" && !filepath.IsAbs(n.Agent) { + t.Nodes[i].Agent = filepath.Join(filepath.Dir(path), n.Agent) + } } return t, nil } @@ -144,6 +149,7 @@ func Parse(data []byte) (*Topology, error) { Loopback: n.Loopback, FRR: n.FRR, Release: n.Release, + Agent: n.Agent, }) } return t, nil diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 2128c68..432fedf 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -215,6 +215,9 @@ func validateExtras(n Node, segments map[string]Segment, add func(string, ...any case n.Role != RoleHypervisor && n.Release != "": add("node %s: release is only for hypervisors", n.Name) } + if n.Role != RoleHypervisor && n.Agent != "" { + add("node %s: agent is only for hypervisors", n.Name) + } if n.Loopback != "" { prefix, err := netip.ParsePrefix(n.Loopback) switch { diff --git a/scripts/lab/node.sh b/scripts/lab/node.sh new file mode 100644 index 0000000..06a8749 --- /dev/null +++ b/scripts/lab/node.sh @@ -0,0 +1,151 @@ +set -u +API=http://127.0.0.1:8080 +IMAGE_URL=https://cloud.debian.org/images/cloud/bookworm/latest +IMAGE_FILE=debian-12-genericcloud-amd64.qcow2 +TWO_IMAGE=/var/tmp/deb-two.qcow2 +VM_KEY=/root/.ssh/lab-vm +VOLUMES=/var/lib/two/volumes + +check () { + local desc="${1}" out + shift + if out=$("$@" 2>&1); then + echo "RÉUSSI: ${desc}" + else + echo "ÉCHOUÉ: ${desc}${out:+ — $(printf '%s' "${out}" | tail -n 1)}" + fi +} + +info () { echo "INFO: ${1}"; } + +api_post () { + curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json' -d "${2}" "${API}/${1}" +} + +api_delete () { + curl -fsS -o /dev/null -X DELETE "${API}/${1}" +} + +api_state () { + curl -fsS "${API}/${1}" 2>/dev/null | jq -r .state +} + +wait_state () { + local path="${1}" want="${2}" i s + for i in $(seq 1 90); do + s=$(api_state "${path}") + [ "${s}" = "${want}" ] && return 0 + [ "${want}" = deleted ] && [ -z "${s}" ] && return 0 + [ "${s}" = error ] && { echo "${path}: error" >&2; return 1; } + sleep 2 + done + echo "${path}: ${s:-absent}, ${want} attendu" >&2 + return 1 +} + +vpc_create () { + api_state "vpcs/${1}" | grep -qx running && return 0 + api_post vpcs "{\"name\":\"${1}\",\"cidr\":\"${2}\"}" && wait_state "vpcs/${1}" running +} + +subnet_create () { + local name="${1}" vpc="${2}" vni="${3}" gw="${4}" cidr="${5}" extra="${6:-}" + api_state "subnets/${name}" | grep -qx running && return 0 + api_post subnets "{\"name\":\"${name}\",\"vpc\":\"${vpc}\",\"mode\":\"vxlan\",\"vxlan_id\":${vni},\"iface_type\":\"vms\",\"interface_ip\":\"${gw}\",\"cidr\":\"${cidr}\"${extra}}" \ + && wait_state "subnets/${name}" running +} + +two_image () { + [ -f "${TWO_IMAGE}" ] && return 0 + local work + work=$(mktemp -d) + curl -fsSL -o "${work}/${IMAGE_FILE}" "${IMAGE_URL}/${IMAGE_FILE}" || return 1 + curl -fsSL "${IMAGE_URL}/SHA512SUMS" | grep " ${IMAGE_FILE}\$" > "${work}/sums" || return 1 + (cd "${work}" && sha512sum -c sums >/dev/null) || { echo "image: somme SHA-512 incorrecte" >&2; return 1; } + command -v qemu-nbd >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-utils >/dev/null + modprobe nbd max_part=8 + qemu-nbd -c /dev/nbd0 "${work}/${IMAGE_FILE}" || return 1 + for i in $(seq 1 20); do [ -b /dev/nbd0p1 ] && break; sleep 0.5; done + mkdir -p /mnt/two-image + mount /dev/nbd0p1 /mnt/two-image || { qemu-nbd -d /dev/nbd0 >/dev/null; return 1; } + printf '%s\n' 'datasource_list: [ NoCloud ]' 'datasource:' ' NoCloud:' " seedfrom: 'http://169.254.169.254:80/'" ' timeout: 5' ' max_wait: 10' \ + > /mnt/two-image/etc/cloud/cloud.cfg.d/99_metadata.cfg + umount /mnt/two-image + qemu-nbd -d /dev/nbd0 >/dev/null + mv "${work}/${IMAGE_FILE}" "${TWO_IMAGE}" + rm -rf "${work}" +} + +vm_key () { + [ -f "${VM_KEY}" ] || ssh-keygen -q -t ed25519 -N '' -C lab-vm -f "${VM_KEY}" + cat "${VM_KEY}.pub" +} + +vm_create () { + local name="${1}" subnet="${2}" ip="${3}" key="${4:-}" meta="{}" + [ -n "${key}" ] && meta="{\"sshkey\":\"${key}\"}" + mkdir -p "${VOLUMES}" + cp "${TWO_IMAGE}" "${VOLUMES}/${name}.qcow2" || return 1 + api_post vms "{\"name\":\"${name}\",\"memory\":1024,\"cpus\":1,\"metadata\":${meta},\"interfaces\":[{\"subnet\":\"${subnet}\",\"ip\":\"${ip}\",\"primary\":true}],\"storage\":[{\"path\":\"${VOLUMES}/${name}.qcow2\",\"dev\":\"vda\"}]}" \ + && wait_state "vms/${name}" running +} + +vm_delete () { + api_delete "vms/${1}" && wait_state "vms/${1}" deleted +} + +vm_ssh () { + local vpc="${1}" ip="${2}" + shift 2 + ip netns exec "${vpc}" ssh -i "${VM_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=3 -o BatchMode=yes -n "syonad@${ip}" "$@" +} + +vm_wait () { + local i + for i in $(seq 1 40); do + vm_ssh "${1}" "${2}" true 2>/dev/null && return 0 + sleep 5 + done + echo "${2}: SSH injoignable" >&2 + return 1 +} + +vm_fails () { + local out + out=$(vm_ssh "${1}" "${2}" "${3} >/dev/null 2>&1; echo rc=\$?") || { echo "SSH vers ${2} en échec"; return 1; } + case "${out}" in + rc=0) echo "la commande a réussi dans ${2}"; return 1 ;; + rc=*) return 0 ;; + *) echo "sortie inattendue : ${out}"; return 1 ;; + esac +} + +vm_grep () { + vm_ssh "${1}" "${2}" "${3} 2>&1" | grep -q -- "${4}" +} + +has_addr () { + vm_ssh "${1}" "${2}" 'ip -4 -o addr show dev ens3' | grep -q " ${2}/" +} + +route_via () { + vm_ssh "${1}" "${2}" "ip -4 route show ${3}" | grep -q "via ${4} " +} + +dhcp_hosts () { + python3 - "/run/two/dhcp/${1}.sock" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sys.argv[1]) +s.sendall(b'{"verb":"get-state"}\n') +buf = b"" +while not buf.endswith(b"\n"): + c = s.recv(65536) + if not c: + break + buf += c +state = json.loads(buf).get("state") or {} +print(" ".join(sorted(h["mac"].lower() for h in state.get("hosts") or []))) +PY +} diff --git a/scripts/lab/scenario.sh b/scripts/lab/scenario.sh new file mode 100755 index 0000000..58a6d24 --- /dev/null +++ b/scripts/lab/scenario.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LAB_HOST="${LAB_HOST:-${LAB_DIR}/../lab-host.sh}" +SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}" +SCENARIO_LOG="" + +usage () { + cat >&2 <... | all + + scénarios : $(cd "${SCENARIO_DIR}" && ls -- *.sh | sed 's/\.sh$//' | tr '\n' ' ') + +Chaque scénario s'exécute depuis le Mac sur le lab en cours (lab-host.sh up, push, ./lab up). +Une ligne RÉUSSI ou ÉCHOUÉ par vérification ; code de sortie 1 si une vérification échoue ou +si aucune n'a été faite. +USAGE + exit 2 +} + +on () { + local node="${1}" assignment + shift + { + for assignment in "$@"; do + printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}" + done + cat "${LAB_DIR}/node.sh" + cat + } | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}" +} + +run_scenario () { + local file="${1}" name passed failed + name="$(basename "${file}" .sh)" + SCENARIO_LOG="$(mktemp)" + echo "=== ${name}" + ( . "${file}" ) + passed=$(grep -c '^RÉUSSI: ' "${SCENARIO_LOG}") + failed=$(grep -c '^ÉCHOUÉ: ' "${SCENARIO_LOG}") + rm -f "${SCENARIO_LOG}" + echo "=== ${name} : ${passed} réussi(s), ${failed} échoué(s)" + [[ "${failed}" -eq 0 && "${passed}" -gt 0 ]] +} + +main () { + local -a files=() + local arg file rc=0 + [[ $# -gt 0 ]] || usage + for arg in "$@"; do + if [[ "${arg}" == all ]]; then + files+=("${SCENARIO_DIR}"/*.sh) + continue + fi + file=$(ls "${SCENARIO_DIR}/${arg}"*.sh 2>/dev/null | head -n 1) + [[ -n "${file}" ]] || { echo "scénario inconnu : ${arg}" >&2; usage; } + files+=("${file}") + done + for file in "${files[@]}"; do + run_scenario "${file}" || rc=1 + done + return "${rc}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then + main "$@" +fi diff --git a/scripts/lab/scenario_test.sh b/scripts/lab/scenario_test.sh new file mode 100755 index 0000000..0ca394c --- /dev/null +++ b/scripts/lab/scenario_test.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } + +setup () { + CURRENT="${1}" + CASE_OK=1 + WORK=$(mktemp -d "${TMPDIR:-/tmp}/scenario.XXXXXX") + mkdir -p "${WORK}/scenarios" + cat > "${WORK}/lab-host" <<'FAKE' +#!/usr/bin/env bash +N=$(ls "${FAKE_DIR}" | grep -c '^payload\.') +cat > "${FAKE_DIR}/payload.$(( N + 1 ))" +printf '%s\n' "$*" >> "${FAKE_DIR}/calls.log" +if ! bash -n "${FAKE_DIR}/payload.$(( N + 1 ))" 2>"${FAKE_DIR}/syntax.$(( N + 1 ))"; then + echo "ÉCHOUÉ: syntaxe du bloc $(( N + 1 ))" + exit 0 +fi +[ -n "${FAKE_OUT:-}" ] && printf '%b\n' "${FAKE_OUT}" +exit 0 +FAKE + chmod +x "${WORK}/lab-host" + : > "${WORK}/calls.log" +} + +run () { + env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \ + FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/scenario.sh" "$@" > "${WORK}/out.log" 2>&1 +} + +teardown () { + [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" + [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } + rm -rf "${WORK}" +} + +node () { + bash -c ". '${DIR}/node.sh'; ${1}" 2>&1 +} + +test_on_sends_assignments_library_and_block_to_the_node () { + setup "on : variables, bibliothèque et bloc envoyés au bon nœud" + printf '%s\n' "on hv2 KEY='ssh-ed25519 AAAA x' NAME=vm <<'NODE'" 'echo "${NAME}"' 'NODE' > "${WORK}/scenarios/t1.sh" + FAKE_OUT='RÉUSSI: un' run t1 || fail "code de sortie $?" + [[ $(cat "${WORK}/calls.log") == "ssh ./lab ssh hv2 'sudo bash -s'" ]] || fail "appel : $(cat "${WORK}/calls.log")" + grep -q '^check () {' "${WORK}/payload.1" || fail "node.sh absent du bloc" + [[ $(tail -n 1 "${WORK}/payload.1") == 'echo "${NAME}"' ]] || fail "bloc du scénario absent en fin d'envoi" + [[ $(bash -c "$(grep '^export ' "${WORK}/payload.1"); printf '%s|%s' \"\${KEY}\" \"\${NAME}\"") == "ssh-ed25519 AAAA x|vm" ]] \ + || fail "variables mal transmises : $(grep '^export ' "${WORK}/payload.1")" + teardown +} + +test_scenario_result_counts_and_exit_codes () { + local CASE + for CASE in ok fail none; do + setup "résultat d'un scénario (${CASE})" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/t2.sh" + case "${CASE}" in + ok) FAKE_OUT='RÉUSSI: a\nRÉUSSI: b' run t2; [[ $? -eq 0 ]] || fail "code non nul" + grep -q '=== t2 : 2 réussi(s), 0 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; + fail) FAKE_OUT='RÉUSSI: a\nÉCHOUÉ: b — détail' run t2; [[ $? -eq 1 ]] || fail "code différent de 1" + grep -q '=== t2 : 1 réussi(s), 1 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; + none) FAKE_OUT='INFO: rien' run t2; [[ $? -eq 1 ]] || fail "un scénario sans vérification a réussi" ;; + esac + teardown + done +} + +test_unknown_scenario_and_no_argument () { + setup "scénario inconnu ou absent : usage, code 2" + run nope; [[ $? -eq 2 ]] || fail "scénario inconnu : code $?" + run; [[ $? -eq 2 ]] || fail "sans argument : code $?" + [[ -s "${WORK}/calls.log" ]] && fail "un nœud a été appelé" + teardown +} + +test_all_runs_every_scenario_and_fails_if_one_fails () { + setup "all : chaque scénario, échec si l'un échoue" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/a.sh" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/b.sh" + FAKE_OUT='ÉCHOUÉ: x' run all; [[ $? -eq 1 ]] || fail "code différent de 1" + [[ $(grep -c '^=== [ab] :' "${WORK}/out.log") -eq 2 ]] || fail "les deux scénarios n'ont pas tourné" + teardown +} + +test_every_shipped_block_is_valid_bash () { + local f + setup "chaque bloc envoyé par les scénarios livrés est du bash valide" + SCENARIO_DIR="${DIR}/scenarios" FAKE_OUT='RÉUSSI: x' run all + for f in "${WORK}"/syntax.*; do + [[ -s "${f}" ]] && fail "${f##*/} : $(cat "${f}")" + done + [[ $(ls "${WORK}" | grep -c '^payload\.') -ge 10 ]] || fail "trop peu de blocs envoyés : $(ls "${WORK}" | grep -c '^payload\.')" + grep -q 'ÉCHOUÉ: syntaxe' "${WORK}/out.log" && fail "bloc invalide" + teardown +} + +test_check_reports_success_and_failure_with_reason () { + setup "check : RÉUSSI, ou ÉCHOUÉ avec la dernière ligne de sortie" + [[ $(node 'check "a" true') == "RÉUSSI: a" ]] || fail "succès : $(node 'check "a" true')" + [[ $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"') == "ÉCHOUÉ: b — deux" ]] || fail "échec : $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"')" + [[ $(node 'check "c" false') == "ÉCHOUÉ: c" ]] || fail "échec muet : $(node 'check "c" false')" + teardown +} + +test_vm_fails_only_when_ssh_worked_and_command_failed () { + setup "vm_fails : réussit seulement si SSH marche et la commande échoue" + [[ $(node 'vm_ssh () { echo rc=1; }; vm_fails v 1.2.3.4 x && echo OUI') == "OUI" ]] || fail "commande échouée non reconnue" + [[ $(node 'vm_ssh () { echo rc=0; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "commande réussie prise pour un échec" + [[ $(node 'vm_ssh () { return 255; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "SSH en panne pris pour une isolation" + [[ $(node 'vm_ssh () { echo bizarre; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "sortie inattendue acceptée" + teardown +} + +test_route_via_matches_the_next_hop_exactly () { + setup "route_via : le next-hop exact, pas un préfixe" + [[ $(node 'vm_ssh () { echo "default via 10.220.1.1 dev ens3 proto dhcp"; }; route_via v ip default 10.220.1.1 && echo OUI') == OUI ]] || fail "next-hop exact refusé" + [[ $(node 'vm_ssh () { echo "default via 10.220.1.10 dev ens3"; }; route_via v ip default 10.220.1.1 || echo NON') == NON ]] || fail "10.220.1.10 pris pour 10.220.1.1" + teardown +} + +test_wait_state () { + setup "wait_state : état atteint, erreur, suppression" + [[ $(node 'api_state () { echo running; }; wait_state vms/x running && echo OUI') == OUI ]] || fail "running" + local start=${SECONDS} + [[ $(node 'api_state () { echo error; }; wait_state vms/x running 2>&1 || echo NON') == *NON ]] || fail "error accepté" + (( SECONDS - start < 5 )) || fail "une ressource en error n'arrête pas l'attente immédiatement ($(( SECONDS - start )) s)" + [[ $(node 'api_state () { echo; }; wait_state vms/x deleted && echo OUI') == OUI ]] || fail "absence = supprimé" + teardown +} + +for T in $(declare -F | awk '{print $3}' | grep '^test_'); do + "${T}" +done +echo "${PASS} réussi(s), ${FAIL} échec(s)" +[[ "${FAIL}" -eq 0 ]] diff --git a/scripts/lab/scenarios/s1-dhcp-two.sh b/scripts/lab/scenarios/s1-dhcp-two.sh new file mode 100644 index 0000000..2ac1c2f --- /dev/null +++ b/scripts/lab/scenarios/s1-dhcp-two.sh @@ -0,0 +1,34 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s1" vpc_create vp-s1 10.210.0.0/16 +check "subnet sn-s1a" subnet_create sn-s1a vp-s1 2101 10.210.1.1 10.210.1.0/24 +check "subnet sn-s1b" subnet_create sn-s1b vp-s1 2102 10.210.2.1 10.210.2.0/24 +check "backend two : socket de contrôle de sn-s1a" test -S /run/two/dhcp/vp-s1_br-s1a.sock +check "backend two : socket de contrôle de sn-s1b" test -S /run/two/dhcp/vp-s1_br-s1b.sock +check "VM s1-a1, démarrée seule" vm_create s1-a1 sn-s1a 10.210.1.10 "${KEY}" +check "VM s1-a1 joignable" vm_wait vp-s1 10.210.1.10 +check "VM s1-b1, démarrée seule" vm_create s1-b1 sn-s1b 10.210.2.10 "${KEY}" +check "VM s1-b1 joignable" vm_wait vp-s1 10.210.2.10 +vm_create s1-a2 sn-s1a 10.210.1.11 "${KEY}" > /tmp/s1-a2.log 2>&1 & A=$! +vm_create s1-b2 sn-s1b 10.210.2.11 "${KEY}" > /tmp/s1-b2.log 2>&1 & B=$! +wait "${A}"; RA=$? +wait "${B}"; RB=$? +check "VM s1-a2, démarrée en même temps que s1-b2" test "${RA}" -eq 0 +check "VM s1-b2, démarrée en même temps que s1-a2" test "${RB}" -eq 0 +for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 10.210.2.11:10.210.2.1; do + ip=${vm%%:*}; gw=${vm#*:} + check "${ip} joignable" vm_wait vp-s1 "${ip}" + check "${ip} : adresse de son propre subnet" has_addr vp-s1 "${ip}" + check "${ip} : bail DHCP tenu par systemd-networkd" vm_ssh vp-s1 "${ip}" 'ls /run/systemd/netif/leases/ | grep -q .' + check "${ip} : route par défaut via ${gw}" route_via vp-s1 "${ip}" default "${gw}" + check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}" + check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}" +done +mac () { vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address'; } +A_MACS=$(printf '%s\n' "$(mac 10.210.1.10)" "$(mac 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//') +B_MACS=$(printf '%s\n' "$(mac 10.210.2.10)" "$(mac 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//') +info "MAC de sn-s1a : ${A_MACS} ; de sn-s1b : ${B_MACS}" +check "probe sn-s1a : uniquement les MAC de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_MACS}" +check "probe sn-s1b : uniquement les MAC de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_MACS}" +NODE diff --git a/scripts/lab/scenarios/s2-gateway.sh b/scripts/lab/scenarios/s2-gateway.sh new file mode 100644 index 0000000..4785731 --- /dev/null +++ b/scripts/lab/scenarios/s2-gateway.sh @@ -0,0 +1,26 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s2" vpc_create vp-s2 10.220.0.0/16 +check "subnet sn-s2a, default_route absent" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 +check "subnet sn-s2b, default_route et gateway 10.220.2.254" subnet_create sn-s2b vp-s2 2202 10.220.2.1 10.220.2.0/24 ',"default_route":true,"gateway":"10.220.2.254"' +check "VM s2-a" vm_create s2-a sn-s2a 10.220.1.10 "${KEY}" +check "VM s2-b" vm_create s2-b sn-s2b 10.220.2.10 "${KEY}" +check "s2-a joignable" vm_wait vp-s2 10.220.1.10 +check "s2-b joignable" vm_wait vp-s2 10.220.2.10 +check "sn-s2a : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.10 default 10.220.1.1 +check "sn-s2a : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.10 10.220.0.0/16 10.220.1.1 +check "sn-s2a : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.1.10 169.254.169.254 10.220.1.1 +check "sn-s2b : route par défaut via la gateway 10.220.2.254" route_via vp-s2 10.220.2.10 default 10.220.2.254 +check "sn-s2b : route vers la VPC via interface_ip, pas via la gateway" route_via vp-s2 10.220.2.10 10.220.0.0/16 10.220.2.1 +check "sn-s2b : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.2.10 169.254.169.254 10.220.2.1 +check "suppression de s2-a" vm_delete s2-a +check "suppression de sn-s2a" api_delete subnets/sn-s2a +check "sn-s2a supprimé" wait_state subnets/sn-s2a deleted +check "sn-s2a recréé" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 +check "VM s2-a2 sur le subnet recréé" vm_create s2-a2 sn-s2a 10.220.1.11 "${KEY}" +check "s2-a2 joignable" vm_wait vp-s2 10.220.1.11 +check "après recréation : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.11 default 10.220.1.1 +check "après recréation : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.11 10.220.0.0/16 10.220.1.1 +check "après recréation : route /32 vers les métadonnées" route_via vp-s2 10.220.1.11 169.254.169.254 10.220.1.1 +NODE diff --git a/scripts/lab/scenarios/s3-isolation-local.sh b/scripts/lab/scenarios/s3-isolation-local.sh new file mode 100644 index 0000000..dc822f5 --- /dev/null +++ b/scripts/lab/scenarios/s3-isolation-local.sh @@ -0,0 +1,16 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s3a" vpc_create vp-s3a 10.230.0.0/16 +check "VPC vp-s3b" vpc_create vp-s3b 10.231.0.0/16 +check "subnet sn-s3a" subnet_create sn-s3a vp-s3a 2301 10.230.1.1 10.230.1.0/24 +check "subnet sn-s3b" subnet_create sn-s3b vp-s3b 2302 10.231.1.1 10.231.1.0/24 +check "VM s3-x dans vp-s3a" vm_create s3-x sn-s3a 10.230.1.10 "${KEY}" +check "VM s3-y dans vp-s3b" vm_create s3-y sn-s3b 10.231.1.10 "${KEY}" +check "s3-x joignable depuis sa VPC" vm_wait vp-s3a 10.230.1.10 +check "s3-y joignable depuis sa VPC (témoin : la cible est vivante)" vm_wait vp-s3b 10.231.1.10 +check "s3-x joint sa passerelle (témoin)" vm_ssh vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.230.1.1' +check "s3-x ne joint pas s3-y en ICMP" vm_fails vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.231.1.10' +check "s3-x n'ouvre pas de connexion TCP vers s3-y:22" vm_fails vp-s3a 10.230.1.10 'timeout 5 bash -c "/dev/null 2>&1 && break; sleep 5; done +check "s4-p (hv1) joint s4-q (hv2)" vm_ssh vp-s4 10.240.1.10 'ping -c 3 -W 2 10.240.1.11' +check "trame pleine taille : 1472 octets en -M do" vm_ssh vp-s4 10.240.1.10 'ping -M do -s 1472 -c 2 -W 2 10.240.1.11' +check "1473 octets refusés localement (MTU 1500)" vm_grep vp-s4 10.240.1.10 'ping -M do -s 1473 -c 1 -W 2 10.240.1.11' 'message too long' +NODE diff --git a/scripts/lab/scenarios/s5-isolation-evpn.sh b/scripts/lab/scenarios/s5-isolation-evpn.sh new file mode 100644 index 0000000..a761056 --- /dev/null +++ b/scripts/lab/scenarios/s5-isolation-evpn.sh @@ -0,0 +1,23 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; } +KEY=$(vm_key) +check "hv1 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 +check "hv1 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 +check "hv1 : VM s5-r dans vp-s5a" vm_create s5-r sn-s5a 10.245.1.10 "${KEY}" +NODE +on hv2 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; } +check "hv2 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 +check "hv2 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 +check "hv2 : VM s5-t dans vp-s5a (témoin)" vm_create s5-t sn-s5a 10.245.1.12 +check "hv2 : VPC vp-s5b, même plage" vpc_create vp-s5b 10.245.0.0/16 +check "hv2 : subnet sn-s5b (VNI 2502), même plage" subnet_create sn-s5b vp-s5b 2502 10.245.1.1 10.245.1.0/24 +check "hv2 : VM s5-s dans vp-s5b" vm_create s5-s sn-s5b 10.245.1.11 +NODE +on hv1 <<'NODE' +check "s5-r joignable" vm_wait vp-s5a 10.245.1.10 +for i in $(seq 1 24); do vm_ssh vp-s5a 10.245.1.10 'ping -c 1 -W 2 10.245.1.12' >/dev/null 2>&1 && break; sleep 5; done +check "s5-r joint s5-t, même VPC sur hv2 (témoin)" vm_ssh vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.12' +check "s5-r ne joint pas s5-s, autre VPC sur hv2, même plage" vm_fails vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.11' +check "aucune entrée ARP résolue pour 10.245.1.11 dans s5-r" vm_fails vp-s5a 10.245.1.10 'ip neigh show 10.245.1.11 | grep -q lladdr' +NODE diff --git a/scripts/lab/scenarios/s6-rr-loss.sh b/scripts/lab/scenarios/s6-rr-loss.sh new file mode 100644 index 0000000..e4ea28e --- /dev/null +++ b/scripts/lab/scenarios/s6-rr-loss.sh @@ -0,0 +1,36 @@ +on hv1 <<'NODE' +check "prérequis : VM s4-p de S4 en marche" test "$(api_state vms/s4-p)" = running +check "session EVPN de hv1 établie avant l'arrêt" sh -c "vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" +check "prérequis : s4-p joint s4-q" vm_ssh vp-s4 10.240.1.10 'ping -c 2 -W 2 10.240.1.11' +vm_ssh vp-s4 10.240.1.10 'rm -f /tmp/s6.log; nohup ping -D -i 0.2 10.240.1.11 > /tmp/s6.log 2>&1 &' +sleep 5 +NODE +on rr1 <<'NODE' +date +%s > /tmp/s6.stop +check "arrêt de FRR sur le route reflector" systemctl stop frr +NODE +sleep 30 +on hv1 <<'NODE' +recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } +check "session EVPN de hv1 tombée" sh -c "! vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" +info "30 s après l'arrêt du route reflector : $(recent 10) réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)" +info "VTEP distant encore connu : $(vtysh -c 'show evpn vni 2401' | grep -c 192.168.14.12) ; entrée d'inondation : $(bridge fdb show dev vxlan-2401 | grep -c '00:00:00:00:00:00 dst')" +sleep 60 +info "90 s après l'arrêt : $(recent 10) réponses dans les 10 dernières secondes" +NODE +on rr1 <<'NODE' +check "redémarrage de FRR sur le route reflector" systemctl start frr +date +%s > /tmp/s6.start +NODE +on hv1 <<'NODE' +recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } +start=$(date +%s) +for i in $(seq 1 90); do + vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && [ "$(recent 3)" -gt 0 ] && break + sleep 2 +done +info "retour du VTEP distant et du trafic $(( $(date +%s) - start )) s après le redémarrage de FRR" +check "après le retour du route reflector : VTEP distant réappris" sh -c "vtysh -c 'show evpn vni 2401' | grep -q 192.168.14.12" +check "après le retour du route reflector : le trafic passe" test "$(recent 5)" -gt 0 +vm_ssh vp-s4 10.240.1.10 'pkill -x ping' || true +NODE From b6933d39190d8fb62aea0e89dafe204af8c47657 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 21:17:48 +0200 Subject: [PATCH 22/25] =?UTF-8?q?f-50:=20lab:=20r=C3=A9sultats=20d'E5,=20c?= =?UTF-8?q?ontr=C3=B4le=20DHCP=20de=20s1=20sur=20les=20couples=20MAC/IP,?= =?UTF-8?q?=20perte=20du=20route=20reflector=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/route-reflector.rst | 23 +++++++++++++++++------ docs/developpement/lab.rst | 23 +++++++++++++++++++++++ scripts/lab/node.sh | 2 +- scripts/lab/scenarios/s1-dhcp-two.sh | 12 ++++++------ 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index ab57b32..5f71659 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -94,9 +94,21 @@ l'adresse du lien en secondaire et la loopback sur ``lo1`` : - up des deux côtés * - sessions des deux hyperviseurs vers ``10.255.255.1`` - Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée - * - routes EVPN échangées - - aucune : rien à annoncer tant que two n'a pas créé de VXLAN — voir - :doc:`premier-hyperviseur` + * - routes EVPN échangées, VM ↔ VM entre deux hyperviseurs + - routes de type 3 échangées, ping et MTU 1500 de bout en bout — à partir de la release + ``0.2.0rc003``, qui pose l'adresse VTEP locale des VXLAN + (`#51 `_) + +.. warning:: + + **Les tunnels ne survivent pas à la perte du route reflector.** Mesuré dans le lab + (2026-10-04, FRR 10.7.1, un seul route reflector, sans ``graceful-restart``) : à l'arrêt de + FRR sur le route reflector, la session EVPN des hyperviseurs tombe aussitôt, FRR retire les + routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus + aucun paquet ne passe** entre hyperviseurs, à 30 s comme à 90 s. Au redémarrage de FRR sur le + route reflector, VTEP distant et trafic reviennent **31 s** plus tard. Le trafic entre VM d'un + même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou + ``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié. Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs de cluster (:doc:`routeurs`). @@ -109,9 +121,8 @@ de cluster (:doc:`routeurs`). lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; - * la procédure de reconstruction, et l'état du cluster pendant que le route reflector est - absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de - temps ; + * la procédure de reconstruction — l'état du cluster pendant l'absence du route reflector est + mesuré ci-dessus : plus de trafic entre hyperviseurs ; * la procédure d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster entier depuis zéro — le premier hyperviseur n'a pas de session FRR établie tant que cette VM n'existe pas, cf. :doc:`premier-hyperviseur`. diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 6ff6fb1..0ed0d2f 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -725,6 +725,29 @@ scénario envoie des blocs de shell aux nœuds par ``on [VAR=valeur…] elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un SSH en panne ne passe jamais pour une isolation. +Résultats du 2026-10-04 sur le serveur de lab, release ``0.2.0rc003``, hv1 sur le DHCP intégré et +hv2 sur dnsmasq — toute la série en 8 minutes : + +.. code-block:: text + + $ scripts/lab/scenario.sh all | grep -E '^(=== s[0-9].* : |INFO)' + INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b + === s1-dhcp-two : 37 réussi(s), 0 échoué(s) + === s2-gateway : 22 réussi(s), 0 échoué(s) + === s3-isolation-local : 12 réussi(s), 0 échoué(s) + === s4-evpn : 14 réussi(s), 0 échoué(s) + === s5-isolation-evpn : 13 réussi(s), 0 échoué(s) + INFO: 30 s après l'arrêt du route reflector : 0 réponses dans les 10 dernières secondes (50 si le trafic passe intégralement) + INFO: VTEP distant encore connu : 0 ; entrée d'inondation : 0 + INFO: 90 s après l'arrêt : 0 réponses dans les 10 dernières secondes + INFO: retour du VTEP distant et du trafic 31 s après le redémarrage de FRR + === s6-rr-loss : 8 réussi(s), 0 échoué(s) + +Dans ce run, le contrôle « uniquement les MAC de son subnet » de ``s1`` ne prouvait rien : two +dérive la MAC du rang de l'IP, et les VM ``.10``/``.11`` des deux subnets avaient les mêmes MAC. +Vérifié à la main sur le lab, chaque serveur DHCP ne connaissait que les couples MAC/IP de son +subnet ; le scénario compare désormais ces couples. + Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur ``syonad`` créé par les métadonnées de two : diff --git a/scripts/lab/node.sh b/scripts/lab/node.sh index 06a8749..0a7111c 100644 --- a/scripts/lab/node.sh +++ b/scripts/lab/node.sh @@ -146,6 +146,6 @@ while not buf.endswith(b"\n"): break buf += c state = json.loads(buf).get("state") or {} -print(" ".join(sorted(h["mac"].lower() for h in state.get("hosts") or []))) +print(" ".join(sorted(h["mac"].lower() + "=" + h["ip"] for h in state.get("hosts") or []))) PY } diff --git a/scripts/lab/scenarios/s1-dhcp-two.sh b/scripts/lab/scenarios/s1-dhcp-two.sh index 2ac1c2f..bbf727c 100644 --- a/scripts/lab/scenarios/s1-dhcp-two.sh +++ b/scripts/lab/scenarios/s1-dhcp-two.sh @@ -25,10 +25,10 @@ for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 1 check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}" check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}" done -mac () { vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address'; } -A_MACS=$(printf '%s\n' "$(mac 10.210.1.10)" "$(mac 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//') -B_MACS=$(printf '%s\n' "$(mac 10.210.2.10)" "$(mac 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//') -info "MAC de sn-s1a : ${A_MACS} ; de sn-s1b : ${B_MACS}" -check "probe sn-s1a : uniquement les MAC de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_MACS}" -check "probe sn-s1b : uniquement les MAC de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_MACS}" +vm_host () { echo "$(vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address')=${1}"; } +A_HOSTS=$(printf '%s\n' "$(vm_host 10.210.1.10)" "$(vm_host 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//') +B_HOSTS=$(printf '%s\n' "$(vm_host 10.210.2.10)" "$(vm_host 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//') +info "sn-s1a : ${A_HOSTS} ; sn-s1b : ${B_HOSTS}" +check "probe sn-s1a : uniquement les couples MAC/IP de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_HOSTS}" +check "probe sn-s1b : uniquement les couples MAC/IP de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_HOSTS}" NODE From 5620da4d540eab188efa0c88e2071cea2ae783f6 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 21:51:54 +0200 Subject: [PATCH 23/25] =?UTF-8?q?f-50:=20docs:=20lab=20livr=C3=A9,=20route?= =?UTF-8?q?ur=20Linux=20par=20choix,=20=C3=A9crire=20un=20sc=C3=A9nario=20?= =?UTF-8?q?#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/route-reflector.rst | 4 +- docs/developpement/lab.rst | 62 ++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index 5f71659..d8977ce 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -110,8 +110,8 @@ l'adresse du lien en secondaire et la loopback sur ``lo1`` : même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou ``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié. -Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs -de cluster (:doc:`routeurs`). +Le routeur du lab est un Linux avec FRR, par choix : il joue le rôle générique de routeur de +cluster, l'équipement réel n'ayant besoin que de BGP et d'EVPN (:doc:`routeurs`). .. note:: diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 0ed0d2f..95a18a2 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -13,12 +13,13 @@ comment s'en servir. .. note:: - État actuel : étapes **E0** à **E3** livrées — le cycle de vie du serveur qui porte le lab - (``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan - (``lab plan``), la génération des arguments QEMU et des fichiers cloud-init de chaque VM - (``lab render``), puis leur lancement sur le serveur (``lab up`` / ``status`` / ``down`` / - ``ssh``). Les rôles — FRR sur le switch et le route reflector, two sur les hyperviseurs — - viendront avec l'étape suivante. + État actuel : le lab de #50 est livré — serveur loué à l'heure (``scripts/lab-host.sh``), + topologie déclarative et plan déterministe (``lab plan``), VM rendues et lancées sur le + serveur (``lab render``, ``lab up`` / ``status`` / ``down`` / ``ssh``), rôles installés au + démarrage (FRR sur le switch et le route reflector, two par ``deploy.sh`` sur les + hyperviseurs) et scénarios versionnés (``scripts/lab/scenario.sh``). La redondance du plan de + contrôle — deux route reflectors, deux switchs — est l'objet de + `#54 `_. Le serveur de lab ----------------- @@ -418,9 +419,11 @@ Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ` .. note:: - La configuration du switch (``conf/lab/frr/sw1.conf``) **n'est pas celle des routeurs** : écrite - pour l'essai du 2026-10-04, elle se contente d'établir la session avec le route reflector et de - n'accepter que sa loopback. Elle sera remplacée par la configuration réelle des routeurs. + **Le switch du lab est un routeur Linux avec FRR, par choix.** Il joue le rôle générique de + routeur de cluster : passerelle des hyperviseurs, session eBGP avec BFD vers le route reflector, + dont il n'accepte que la loopback (``conf/lab/frr/sw1.conf``). L'équipement réel dépend de qui + déploie l'infrastructure (MikroTik aujourd'hui ; Cisco, Juniper, Arista… demain) : il n'a besoin + que de BGP et d'EVPN, et sa configuration propre au constructeur n'a pas sa place dans le lab. Rendu des VM ------------ @@ -755,6 +758,47 @@ Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'u scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'" +Écrire un scénario +~~~~~~~~~~~~~~~~~~ + +Le lab sert à qualifier des comportements qui ne se voient qu'à plusieurs hyperviseurs — la +campagne L3VNI de `#41 `_ en est le prochain exemple. Un +scénario est un fichier ``scripts/lab/scenarios/-.sh``, exécuté par ``scenario.sh`` sur le +Mac ; il envoie des blocs aux nœuds : + +.. code-block:: bash + + on hv1 <<'NODE' + two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } + KEY=$(vm_key) + check "VPC vp-x" vpc_create vp-x 10.250.0.0/16 + check "subnet sn-x" subnet_create sn-x vp-x 2601 10.250.1.1 10.250.1.0/24 + check "VM x1" vm_create x1 sn-x 10.250.1.10 "${KEY}" + check "x1 joignable" vm_wait vp-x 10.250.1.10 + check "x1 joint sa passerelle" vm_ssh vp-x 10.250.1.10 'ping -c 2 -W 2 10.250.1.1' + check "x1 ne joint pas 10.251.1.10" vm_fails vp-x 10.250.1.10 'ping -c 2 -W 2 10.251.1.10' + info "mesure : $(vtysh -c 'show evpn vni 2601' | grep -c 'flood')" + NODE + +Les règles qui ont fait leurs preuves en E5 : + +* **une vérification par ligne**, avec ``check`` — jamais un ``echo RÉUSSI`` écrit à la main ; +* **toute vérification négative a son témoin** : avant « ne joint pas », une ligne qui prouve que + la cible est vivante et que le chemin du test fonctionne ; +* **``vm_fails`` pour le négatif**, jamais ``!`` devant un ``vm_ssh`` : un SSH en panne doit + échouer, pas passer pour une isolation ; +* **une donnée qui distingue réellement les cas** : two dérivant la MAC du rang de l'IP, deux + subnets ont les mêmes MAC — comparer des couples MAC/IP, pas des MAC ; +* **les mesures en ``INFO``**, les attentes en ``check`` : un temps de reconvergence se mesure, + il ne se décrète pas ; +* chaque scénario crée ses propres VPC, plages et VNI, distinctes de celles des autres, pour que + ``all`` les enchaîne sur le même lab ; un scénario qui dépend d'un autre le vérifie en tête + (``check "prérequis : …"``) ; +* variables vers un nœud : ``on hv1 NOM=valeur <<'NODE'`` (valeurs échappées par + ``scenario.sh``) ; +* ``bash scripts/lab/scenario_test.sh`` vérifie la syntaxe de chaque bloc réellement envoyé — à + lancer avant toute session. + Facturation ----------- From ab12484a7066357a61aa9f7f1037c4daa3fd07ca Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 5 Oct 2026 20:55:51 +0200 Subject: [PATCH 24/25] =?UTF-8?q?f-50:=20lab:=20sc=C3=A9narios=20et=20topo?= =?UTF-8?q?logies=20d=C3=A9plac=C3=A9s=20sous=20test/e2e=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main_test.go | 10 ++--- docs/deploiement/route-reflector.rst | 4 +- docs/developpement/lab.rst | 42 +++++++++---------- {scripts/lab => test/e2e/lib}/node.sh | 0 scripts/lab/scenario.sh => test/e2e/run.sh | 4 +- .../scenario_test.sh => test/e2e/run_test.sh | 4 +- .../lab => test/e2e}/scenarios/s1-dhcp-two.sh | 0 .../lab => test/e2e}/scenarios/s2-gateway.sh | 0 .../e2e}/scenarios/s3-isolation-local.sh | 0 .../lab => test/e2e}/scenarios/s4-evpn.sh | 0 .../e2e}/scenarios/s5-isolation-evpn.sh | 0 .../lab => test/e2e}/scenarios/s6-rr-loss.sh | 0 .../lab => test/e2e/topologies}/agent/two.yml | 0 .../lab => test/e2e/topologies}/evpn-2hv.yml | 0 .../lab => test/e2e/topologies}/frr/hv1.conf | 0 .../lab => test/e2e/topologies}/frr/hv2.conf | 0 .../lab => test/e2e/topologies}/frr/rr1.conf | 0 .../lab => test/e2e/topologies}/frr/sw1.conf | 0 18 files changed, 32 insertions(+), 32 deletions(-) rename {scripts/lab => test/e2e/lib}/node.sh (100%) rename scripts/lab/scenario.sh => test/e2e/run.sh (94%) rename scripts/lab/scenario_test.sh => test/e2e/run_test.sh (98%) rename {scripts/lab => test/e2e}/scenarios/s1-dhcp-two.sh (100%) rename {scripts/lab => test/e2e}/scenarios/s2-gateway.sh (100%) rename {scripts/lab => test/e2e}/scenarios/s3-isolation-local.sh (100%) rename {scripts/lab => test/e2e}/scenarios/s4-evpn.sh (100%) rename {scripts/lab => test/e2e}/scenarios/s5-isolation-evpn.sh (100%) rename {scripts/lab => test/e2e}/scenarios/s6-rr-loss.sh (100%) rename {conf/lab => test/e2e/topologies}/agent/two.yml (100%) rename {conf/lab => test/e2e/topologies}/evpn-2hv.yml (100%) rename {conf/lab => test/e2e/topologies}/frr/hv1.conf (100%) rename {conf/lab => test/e2e/topologies}/frr/hv2.conf (100%) rename {conf/lab => test/e2e/topologies}/frr/rr1.conf (100%) rename {conf/lab => test/e2e/topologies}/frr/sw1.conf (100%) diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go index fe17d78..733fd3c 100644 --- a/cmd/lab/main_test.go +++ b/cmd/lab/main_test.go @@ -27,7 +27,7 @@ func TestRun_UsageOnMissingArguments(t *testing.T) { } func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) { - code, stdout, stderr := runLab("plan", filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml")) + code, stdout, stderr := runLab("plan", filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml")) if code != 0 { t.Fatalf("code %d, stderr %s", code, stderr) } @@ -82,7 +82,7 @@ func TestRun_RenderWritesEveryNodeFiles(t *testing.T) { t.Fatal(err) } out := filepath.Join(dir, "run") - code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out) + code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out) if code != 0 { t.Fatalf("code %d, stderr %s", code, stderr) } @@ -118,7 +118,7 @@ func TestRun_RenderWritesEveryNodeFiles(t *testing.T) { } func TestRun_RenderRefusesMissingKeyFile(t *testing.T) { - code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), t.TempDir()) + code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), t.TempDir()) if code != 1 || !strings.Contains(stderr, "absent.pub") { t.Errorf("code %d, stderr %q", code, stderr) } @@ -130,7 +130,7 @@ func savedLab(t *testing.T) string { if err := os.MkdirAll(run, 0o700); err != nil { t.Fatal(err) } - example, err := os.ReadFile(filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml")) + example, err := os.ReadFile(filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml")) if err != nil { t.Fatal(err) } @@ -308,7 +308,7 @@ func TestRun_RenderShipsTheExampleFRRConfigs(t *testing.T) { t.Fatal(err) } out := filepath.Join(dir, "run") - code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out) + code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out) if code != 0 { t.Fatalf("code %d, stderr %s", code, stderr) } diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index d8977ce..a9bcb08 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -57,10 +57,10 @@ Configuration de FRR -------------------- ``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du -lab** (``conf/lab/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom +lab** (``test/e2e/topologies/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom d'hôte diffère. Le lab qualifie donc exactement ce fichier. -.. literalinclude:: ../../conf/lab/frr/rr1.conf +.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf :language: text Ce qu'elle établit : diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 95a18a2..6e01936 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -17,7 +17,7 @@ comment s'en servir. topologie déclarative et plan déterministe (``lab plan``), VM rendues et lancées sur le serveur (``lab render``, ``lab up`` / ``status`` / ``down`` / ``ssh``), rôles installés au démarrage (FRR sur le switch et le route reflector, two par ``deploy.sh`` sur les - hyperviseurs) et scénarios versionnés (``scripts/lab/scenario.sh``). La redondance du plan de + hyperviseurs) et scénarios versionnés (``test/e2e/run.sh``). La redondance du plan de contrôle — deux route reflectors, deux switchs — est l'objet de `#54 `_. @@ -179,7 +179,7 @@ Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécu .. code-block:: text scripts/lab-host.sh up - scripts/lab-host.sh push conf/lab/evpn-2hv.yml + scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml' scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1 scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé @@ -194,9 +194,9 @@ 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`` : +portés par un switch). Exemple livré, ``test/e2e/topologies/evpn-2hv.yml`` : -.. literalinclude:: ../../conf/lab/evpn-2hv.yml +.. literalinclude:: ../../test/e2e/topologies/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 ; @@ -236,7 +236,7 @@ Ce que le fichier déclare : * ``agent`` — pour un hyperviseur seulement : le chemin d'un ``agent.yml``, relatif au fichier de topologie, déposé dans ``/etc/two/agent.yml`` avant ``deploy.sh``. Sans lui, l'agent tourne avec sa configuration par défaut. L'exemple met hv1 sur le serveur DHCP intégré - (``conf/lab/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq. + (``test/e2e/topologies/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq. ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. @@ -272,7 +272,7 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe .. code-block:: text - $ go run ./cmd/lab plan conf/lab/evpn-2hv.yml + $ go run ./cmd/lab plan test/e2e/topologies/evpn-2hv.yml lab evpn-2hv: nodes 4, segments 1, cables 3 nodes @@ -308,7 +308,7 @@ champs inconnus et les clés en double sont refusés aussi : Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs de l'exemple sont **ceux de la production** (décision du 2026-10-04, #50) : les fichiers de -``conf/lab/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont +``test/e2e/topologies/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont **fixées** par ``addresses`` — le route reflector en ``.2``, les hyperviseurs à partir de ``.11`` — pour que le modèle se lise sans le plan et ne dépende pas de l'ordre de déclaration : le ``frr.conf`` d'un hyperviseur, écrit à la main, porte son adresse en ``router-id``. Seul le switch @@ -317,11 +317,11 @@ n'en déclare pas : il porte toujours la passerelle, la première adresse du seg Rôles ~~~~~ -Les configurations FRR du lab vivent dans ``conf/lab/frr/``, une par nœud, **écrites à la main** : +Les configurations FRR du lab vivent dans ``test/e2e/topologies/frr/``, une par nœud, **écrites à la main** : ce sont les mêmes fichiers que la documentation de déploiement inclut, pour que le lab qualifie exactement ce qu'elle prescrit. Celle du route reflector : -.. literalinclude:: ../../conf/lab/frr/rr1.conf +.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf :language: text Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les @@ -421,7 +421,7 @@ Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ` **Le switch du lab est un routeur Linux avec FRR, par choix.** Il joue le rôle générique de routeur de cluster : passerelle des hyperviseurs, session eBGP avec BFD vers le route reflector, - dont il n'accepte que la loopback (``conf/lab/frr/sw1.conf``). L'équipement réel dépend de qui + dont il n'accepte que la loopback (``test/e2e/topologies/frr/sw1.conf``). L'équipement réel dépend de qui déploie l'infrastructure (MikroTik aujourd'hui ; Cisco, Juniper, Arista… demain) : il n'a besoin que de BGP et d'EVPN, et sa configuration propre au constructeur n'a pas sa place dans le lab. @@ -432,7 +432,7 @@ Rendu des VM .. code-block:: text - $ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub conf/lab/evpn-2hv.yml + $ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub test/e2e/topologies/evpn-2hv.yml ``//`` reçoit : @@ -611,7 +611,7 @@ Une campagne réelle, de la création du serveur à la première commande sur un … qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y == prêt : root@ - $ scripts/lab-host.sh push conf/lab/evpn-2hv.yml + $ scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml == compilation de lab (linux/amd64) == déposés sur le serveur : ~/lab, ~/evpn-2hv.yml — ensuite : lab-host.sh ssh './lab up evpn-2hv.yml' $ scripts/lab-host.sh ssh './lab up evpn-2hv.yml' @@ -674,9 +674,9 @@ Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``pus .. code-block:: text - scripts/lab/scenario.sh s1 # un scénario - scripts/lab/scenario.sh s1 s3 # plusieurs - scripts/lab/scenario.sh all # tous, dans l'ordre + test/e2e/run.sh s1 # un scénario + test/e2e/run.sh s1 s3 # plusieurs + test/e2e/run.sh all # tous, dans l'ordre Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de @@ -720,9 +720,9 @@ sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**. ``s6`` réutilise les VM de ``s4`` : le lancer après. -**Comment c'est fait.** ``scripts/lab/scenario.sh`` exécute chaque scénario sur le Mac ; un +**Comment c'est fait.** ``test/e2e/run.sh`` exécute chaque scénario sur le Mac ; un scénario envoie des blocs de shell aux nœuds par ``on [VAR=valeur…] <<'NODE'``, précédés de -``scripts/lab/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two +``test/e2e/lib/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two (préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM, ``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` : elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un @@ -733,7 +733,7 @@ hv2 sur dnsmasq — toute la série en 8 minutes : .. code-block:: text - $ scripts/lab/scenario.sh all | grep -E '^(=== s[0-9].* : |INFO)' + $ test/e2e/run.sh all | grep -E '^(=== s[0-9].* : |INFO)' INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b === s1-dhcp-two : 37 réussi(s), 0 échoué(s) === s2-gateway : 22 réussi(s), 0 échoué(s) @@ -763,7 +763,7 @@ Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'u Le lab sert à qualifier des comportements qui ne se voient qu'à plusieurs hyperviseurs — la campagne L3VNI de `#41 `_ en est le prochain exemple. Un -scénario est un fichier ``scripts/lab/scenarios/-.sh``, exécuté par ``scenario.sh`` sur le +scénario est un fichier ``test/e2e/scenarios/-.sh``, exécuté par ``scenario.sh`` sur le Mac ; il envoie des blocs aux nœuds : .. code-block:: bash @@ -796,7 +796,7 @@ Les règles qui ont fait leurs preuves en E5 : (``check "prérequis : …"``) ; * variables vers un nœud : ``on hv1 NOM=valeur <<'NODE'`` (valeurs échappées par ``scenario.sh``) ; -* ``bash scripts/lab/scenario_test.sh`` vérifie la syntaxe de chaque bloc réellement envoyé — à +* ``bash test/e2e/run_test.sh`` vérifie la syntaxe de chaque bloc réellement envoyé — à lancer avant toute session. Facturation @@ -890,7 +890,7 @@ Tests .. code-block:: bash bash scripts/lab-host_test.sh - bash scripts/lab/scenario_test.sh + bash test/e2e/run_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 diff --git a/scripts/lab/node.sh b/test/e2e/lib/node.sh similarity index 100% rename from scripts/lab/node.sh rename to test/e2e/lib/node.sh diff --git a/scripts/lab/scenario.sh b/test/e2e/run.sh similarity index 94% rename from scripts/lab/scenario.sh rename to test/e2e/run.sh index 58a6d24..4b9e84d 100755 --- a/scripts/lab/scenario.sh +++ b/test/e2e/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LAB_HOST="${LAB_HOST:-${LAB_DIR}/../lab-host.sh}" +LAB_HOST="${LAB_HOST:-${LAB_DIR}/../../scripts/lab-host.sh}" SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}" SCENARIO_LOG="" @@ -25,7 +25,7 @@ on () { for assignment in "$@"; do printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}" done - cat "${LAB_DIR}/node.sh" + cat "${LAB_DIR}/lib/node.sh" cat } | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}" } diff --git a/scripts/lab/scenario_test.sh b/test/e2e/run_test.sh similarity index 98% rename from scripts/lab/scenario_test.sh rename to test/e2e/run_test.sh index 0ca394c..dd8022f 100755 --- a/scripts/lab/scenario_test.sh +++ b/test/e2e/run_test.sh @@ -29,7 +29,7 @@ FAKE run () { env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \ - FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/scenario.sh" "$@" > "${WORK}/out.log" 2>&1 + FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/run.sh" "$@" > "${WORK}/out.log" 2>&1 } teardown () { @@ -39,7 +39,7 @@ teardown () { } node () { - bash -c ". '${DIR}/node.sh'; ${1}" 2>&1 + bash -c ". '${DIR}/lib/node.sh'; ${1}" 2>&1 } test_on_sends_assignments_library_and_block_to_the_node () { diff --git a/scripts/lab/scenarios/s1-dhcp-two.sh b/test/e2e/scenarios/s1-dhcp-two.sh similarity index 100% rename from scripts/lab/scenarios/s1-dhcp-two.sh rename to test/e2e/scenarios/s1-dhcp-two.sh diff --git a/scripts/lab/scenarios/s2-gateway.sh b/test/e2e/scenarios/s2-gateway.sh similarity index 100% rename from scripts/lab/scenarios/s2-gateway.sh rename to test/e2e/scenarios/s2-gateway.sh diff --git a/scripts/lab/scenarios/s3-isolation-local.sh b/test/e2e/scenarios/s3-isolation-local.sh similarity index 100% rename from scripts/lab/scenarios/s3-isolation-local.sh rename to test/e2e/scenarios/s3-isolation-local.sh diff --git a/scripts/lab/scenarios/s4-evpn.sh b/test/e2e/scenarios/s4-evpn.sh similarity index 100% rename from scripts/lab/scenarios/s4-evpn.sh rename to test/e2e/scenarios/s4-evpn.sh diff --git a/scripts/lab/scenarios/s5-isolation-evpn.sh b/test/e2e/scenarios/s5-isolation-evpn.sh similarity index 100% rename from scripts/lab/scenarios/s5-isolation-evpn.sh rename to test/e2e/scenarios/s5-isolation-evpn.sh diff --git a/scripts/lab/scenarios/s6-rr-loss.sh b/test/e2e/scenarios/s6-rr-loss.sh similarity index 100% rename from scripts/lab/scenarios/s6-rr-loss.sh rename to test/e2e/scenarios/s6-rr-loss.sh diff --git a/conf/lab/agent/two.yml b/test/e2e/topologies/agent/two.yml similarity index 100% rename from conf/lab/agent/two.yml rename to test/e2e/topologies/agent/two.yml diff --git a/conf/lab/evpn-2hv.yml b/test/e2e/topologies/evpn-2hv.yml similarity index 100% rename from conf/lab/evpn-2hv.yml rename to test/e2e/topologies/evpn-2hv.yml diff --git a/conf/lab/frr/hv1.conf b/test/e2e/topologies/frr/hv1.conf similarity index 100% rename from conf/lab/frr/hv1.conf rename to test/e2e/topologies/frr/hv1.conf diff --git a/conf/lab/frr/hv2.conf b/test/e2e/topologies/frr/hv2.conf similarity index 100% rename from conf/lab/frr/hv2.conf rename to test/e2e/topologies/frr/hv2.conf diff --git a/conf/lab/frr/rr1.conf b/test/e2e/topologies/frr/rr1.conf similarity index 100% rename from conf/lab/frr/rr1.conf rename to test/e2e/topologies/frr/rr1.conf diff --git a/conf/lab/frr/sw1.conf b/test/e2e/topologies/frr/sw1.conf similarity index 100% rename from conf/lab/frr/sw1.conf rename to test/e2e/topologies/frr/sw1.conf From 80ade71653ee0af8a7bf01711afc969a25e87b61 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 5 Oct 2026 20:55:52 +0200 Subject: [PATCH 25/25] =?UTF-8?q?f-50:=20docs:=20routeur=20de=20cluster=20?= =?UTF-8?q?et=20FRR=20des=20hyperviseurs,=20d=C3=A9ploiement=20sans=20r?= =?UTF-8?q?=C3=A9sultats=20de=20lab=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- docs/deploiement/image-qcow2.rst | 8 +-- docs/deploiement/premier-hyperviseur.rst | 48 ++++++++++++++---- docs/deploiement/route-reflector.rst | 43 +++------------- docs/deploiement/routeurs.rst | 64 +++++++++++++++++++++--- 4 files changed, 103 insertions(+), 60 deletions(-) diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index cba2e27..1bf8866 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -323,12 +323,8 @@ Points de vigilance 22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de 23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre - oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. - - Vérifié le 2026-10-04 dans le lab (#50), sur une VM Debian 12 lancée par l'agent : sans barre - oblique, ``Datasource DataSourceNone`` ; avec, ``DataSourceNoCloudNet - [seed=…http://169.254.169.254…]`` et le nom d'hôte de la VM. Avec la barre oblique, la - configuration fonctionne quelle que soit la version de cloud-init. + oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. Avec la barre + oblique, la configuration fonctionne quelle que soit la version de cloud-init. .. note:: diff --git a/docs/deploiement/premier-hyperviseur.rst b/docs/deploiement/premier-hyperviseur.rst index ae70bc1..dc480fe 100644 --- a/docs/deploiement/premier-hyperviseur.rst +++ b/docs/deploiement/premier-hyperviseur.rst @@ -24,19 +24,47 @@ créées par l'agent. C'est ce qui rend un subnet utilisable au-delà d'un seul l'agent désactive l'apprentissage et ne configure aucun voisin — cf. :doc:`architecture-cluster`. +Configuration de FRR +~~~~~~~~~~~~~~~~~~~~ + +Seul ``bgpd`` est activé dans ``/etc/frr/daemons``. D'un hyperviseur à l'autre ne changent que +``hostname`` et ``router-id``, l'adresse de l'hyperviseur sur son segment. + +.. literalinclude:: ../../test/e2e/topologies/frr/hv1.conf + :language: text + +Ce qu'elle établit : + +* **Une seule session**, en iBGP dans l'AS 64600, vers la loopback du route reflector + (``10.255.255.1``), jointe par la passerelle par défaut — le routeur de cluster l'a apprise du + route reflector (:doc:`routeurs`). Aucune adresse d'un autre hyperviseur n'est écrite : ajouter + un nœud ne touche pas la configuration des autres. +* **EVPN seulement** : l'IPv4 unicast n'est pas activé (``no bgp default ipv4-unicast``). + ``advertise-all-vni`` annonce les VNI de toutes les interfaces VXLAN que FRR voit, et donc + celles que l'agent crée. +* **Pas de BFD** sur cette session, contrairement à la session entre route reflector et routeur. + +Articulation avec l'agent +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Rien à configurer à la création d'un subnet : la VXLAN apparaît, FRR la voit et l'annonce, sans +action ni redémarrage. Il faut en revanche que la VXLAN porte une **adresse VTEP locale** : sans +elle, FRR voit la VNI mais n'annonce rien, et deux hyperviseurs gérés par two ne se joignent pas. +L'agent la pose à partir de la release ``0.2.0rc003`` +(`#51 `_) : l'adresse IPv4 primaire du ``local_iface`` +du subnet. + .. note:: - **À rédiger.** À documenter : + **À rédiger.** Reste à documenter : - * la version de FRR de référence et son mode d'installation, sachant que l'hyperviseur est - sans état : le paquet et la configuration doivent être posés à chaque démarrage, par le - bootstrap ou par un mécanisme équivalent ; - * les démons activés dans ``/etc/frr/daemons`` ; - * la configuration de référence : numéro d'AS, session vers le route reflector, famille - d'adresses utilisée pour annoncer les MAC et les VNI ; - * l'articulation avec les interfaces créées par l'agent : comment FRR découvre une interface - VXLAN qui apparaît à la création d'un subnet, et si une action est nécessaire ensuite ; - * ce qui se passe au démarrage à froid, quand FRR démarre avant ou après l'agent ; + * la version de FRR de référence et son installation sur l'hyperviseur **sans état** : paquet + et configuration doivent être posés à chaque démarrage, par le bootstrap ou un mécanisme + équivalent (`#52 `_) ; + * le démarrage à froid, selon que FRR démarre avant ou après l'agent ; + * les subnets ``vxlan`` créés avant ``0.2.0rc003``, dont la VXLAN n'a pas d'adresse VTEP + locale : les recréer ou poser l'adresse à chaud, à trancher dans + `#51 `_ ; * le cas particulier du **premier** hyperviseur, dont la session ne peut pas s'établir tant que le route reflector n'existe pas. diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index a9bcb08..e4143a4 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -50,15 +50,12 @@ réseau du système, pas la poser à la main. nmcli connection add type dummy ifname lo1 con-name lo1 \ ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled - Le lab, en Debian, pose ces adresses par cloud-init et un script rejoué au démarrage ; ces - commandes restent à valider sur l'image golden. + Ces commandes restent à valider sur l'image golden. Configuration de FRR -------------------- -``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du -lab** (``test/e2e/topologies/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom -d'hôte diffère. Le lab qualifie donc exactement ce fichier. +``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. .. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf :language: text @@ -75,44 +72,16 @@ Ce qu'elle établit : * **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients (``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres. -Vérifié dans le lab -~~~~~~~~~~~~~~~~~~~ - -Le 2026-10-04, FRR 10.7.1, route reflector en Debian 12 sur le segment des hyperviseurs, avec -l'adresse du lien en secondaire et la loopback sur ``lo1`` : - -.. list-table:: - :header-rows: 1 - :widths: 55 45 - - * - Vérification - - Résultat - * - session avec le routeur, IPv4 unicast - - Established ; le routeur reçoit **un seul** préfixe, la loopback, et l'installe via - ``169.254.0.3`` - * - BFD avec le routeur - - up des deux côtés - * - sessions des deux hyperviseurs vers ``10.255.255.1`` - - Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée - * - routes EVPN échangées, VM ↔ VM entre deux hyperviseurs - - routes de type 3 échangées, ping et MTU 1500 de bout en bout — à partir de la release - ``0.2.0rc003``, qui pose l'adresse VTEP locale des VXLAN - (`#51 `_) - .. warning:: - **Les tunnels ne survivent pas à la perte du route reflector.** Mesuré dans le lab - (2026-10-04, FRR 10.7.1, un seul route reflector, sans ``graceful-restart``) : à l'arrêt de - FRR sur le route reflector, la session EVPN des hyperviseurs tombe aussitôt, FRR retire les - routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus + **Les tunnels ne survivent pas à la perte du route reflector.** Avec un seul route reflector + et sans ``graceful-restart``, à l'arrêt de FRR sur le route reflector, la session EVPN des + hyperviseurs tombe aussitôt, FRR retire les routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus aucun paquet ne passe** entre hyperviseurs, à 30 s comme à 90 s. Au redémarrage de FRR sur le route reflector, VTEP distant et trafic reviennent **31 s** plus tard. Le trafic entre VM d'un même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou ``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié. -Le routeur du lab est un Linux avec FRR, par choix : il joue le rôle générique de routeur de -cluster, l'équipement réel n'ayant besoin que de BGP et d'EVPN (:doc:`routeurs`). - .. note:: **À rédiger.** Reste à documenter : @@ -122,7 +91,7 @@ cluster, l'équipement réel n'ayant besoin que de BGP et d'EVPN (:doc:`routeurs autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; * la procédure de reconstruction — l'état du cluster pendant l'absence du route reflector est - mesuré ci-dessus : plus de trafic entre hyperviseurs ; + décrit ci-dessus : plus de trafic entre hyperviseurs ; * la procédure d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster entier depuis zéro — le premier hyperviseur n'a pas de session FRR établie tant que cette VM n'existe pas, cf. :doc:`premier-hyperviseur`. diff --git a/docs/deploiement/routeurs.rst b/docs/deploiement/routeurs.rst index 76ec2e7..f8e8dbf 100644 --- a/docs/deploiement/routeurs.rst +++ b/docs/deploiement/routeurs.rst @@ -15,16 +15,66 @@ Routeurs de datacentre Routeurs de bordure terminent le routage vers l'extérieur. +Routeur de cluster +------------------ + +Le routeur de cluster est la passerelle des hyperviseurs et le voisin eBGP du route reflector, dont +il apprend la loopback : c'est par lui que chaque hyperviseur joint ``10.255.255.1`` pour ouvrir sa +session EVPN (:doc:`route-reflector`). + +Adressage +~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Adresse + - Interface + - Rôle + * - ``192.168.14.1/24`` + - interface du segment des hyperviseurs + - passerelle par défaut des hyperviseurs et du route reflector + * - ``169.254.0.1/28`` + - même interface, **adresse secondaire** + - lien avec le route reflector (``169.254.0.3``) ; ``router-id`` ; le second routeur prend + ``169.254.0.2`` + +Le MTU minimal du segment, imposé par VXLAN, est donné plus bas. + +Configuration de FRR +~~~~~~~~~~~~~~~~~~~~ + +``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. + +.. literalinclude:: ../../test/e2e/topologies/frr/sw1.conf + :language: text + +Ce qu'elle établit : + +* **Route reflector** : eBGP de l'AS 65100 vers l'AS 65000, avec BFD, en IPv4 unicast. +* **En entrée**, seule la loopback du route reflector est acceptée (``RR-IN``) ; **en sortie**, + rien n'est annoncé (``NO-OUT``). Symétrique de ``RR-LOOPBACK-OUT`` et ``NO-IN`` côté route + reflector : chaque côté filtre, aucun ne dépend du filtre de l'autre. +* **Aucune session avec les hyperviseurs** : le routeur leur sert de passerelle, pas de voisin BGP. + +Le route reflector déclare aussi le second routeur (``169.254.0.2``). La redondance est l'objet de +`#54 `_. + .. note:: - **À rédiger.** Cette page attend les éléments de terrain. Pour chacun des trois niveaux : + Ce qui précède est le contrat du routeur de cluster : adresses, ASN, session, filtres. Sa + traduction dans la configuration d'un constructeur n'a pas sa place ici. - * le matériel ou le logiciel employé, et la version de référence ; - * la configuration de référence : interfaces, adressage, protocole de routage et numéros - d'AS ; - * la redondance : combien d'équipements, quel mécanisme de bascule, quel comportement attendu - pendant une bascule ; - * ce qui est annoncé et ce qui est filtré à chaque niveau ; +.. note:: + + **À rédiger.** Reste à documenter, faute d'éléments de terrain : + + * pour le routeur de cluster : le matériel employé et sa version de référence ; la redondance + — deux routeurs, mécanisme de bascule, comportement attendu pendant une bascule + (`#54 `_) ; + * pour les routeurs de datacentre et de bordure : tout — équipement, configuration de + référence, ce qui est annoncé et filtré à chaque niveau, redondance ; * l'ordre de mise en service, et ce qui doit être opérationnel avant de préparer le premier hyperviseur.