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 }