From 53abfba956a76ed620a0017bb7d8aeaf9fc602a6 Mon Sep 17 00:00:00 2001 From: Jamie Nguyen Date: Mon, 8 Dec 2025 15:40:09 +0000 Subject: [PATCH 1/3] Add mocks for easier local testing --- config/cfgmodel/model.go | 13 ++++--- config/cfgmodel/model_provider.go | 60 ++++++++++++++++++++++++++++++- mock-flipper.example.yaml | 57 +++++++++++++++++++++++++++++ monitor/monitor.go | 25 +++++++++---- monitor/resources_test.go | 4 +-- notification/notifier.go | 2 +- plan/plan_test.go | 42 +++++++++++----------- plan/state.go | 4 +-- provider/hetzner/provider.go | 11 +++--- provider/mock/floating_ip.go | 2 +- provider/mock/provider.go | 58 ++++++++++++++++++++++++++++++ resource/floating_ip.go | 8 ++--- resource/server.go | 8 ++--- 13 files changed, 243 insertions(+), 51 deletions(-) create mode 100644 mock-flipper.example.yaml diff --git a/config/cfgmodel/model.go b/config/cfgmodel/model.go index 4cbaeef..ea6e443 100644 --- a/config/cfgmodel/model.go +++ b/config/cfgmodel/model.go @@ -40,12 +40,16 @@ type GroupConfig struct { PlanApplyWithUnkownStatus bool `koanf:"plan_apply_with_unknown_status"` // Provider is the name of the cloud provider that the group is using. - // Currently only "hetzner" is supported. + // Supported values: "hetzner", "mock". Provider string `koanf:"provider"` // Hetzner is the Hetzner-specific configuration. - // This is only used if the provider is "hetzner". - Hetzner HetznerProviderConfig `koanf:"hetzner"` + // nil when using a different provider. + Hetzner *HetznerProviderConfig `koanf:"hetzner"` + + // Mock is the mock provider configuration. + // nil when using a different provider. + Mock *MockProviderConfig `koanf:"mock"` // Checks is a list of health checks to perform on the servers. Checks []HealthCheckConfig `koanf:"checks"` @@ -65,8 +69,9 @@ func (c GroupConfig) Validate() error { return validation.ValidateStruct(&c, validation.Field(&c.ID, validation.Required), validation.Field(&c.DisplayName, validation.Required), - validation.Field(&c.Provider, validation.Required, validation.In("hetzner")), + validation.Field(&c.Provider, validation.Required, validation.In("hetzner", "mock")), validation.Field(&c.Hetzner, validation.When(c.Provider == "hetzner", validation.Required)), + validation.Field(&c.Mock, validation.When(c.Provider == "mock", validation.Required)), validation.Field(&c.Checks), ) } diff --git a/config/cfgmodel/model_provider.go b/config/cfgmodel/model_provider.go index 3e4d8d2..b54e783 100644 --- a/config/cfgmodel/model_provider.go +++ b/config/cfgmodel/model_provider.go @@ -39,6 +39,64 @@ type HetznerSelector struct { // Validate validates the Hetzner selector. func (c HetznerSelector) Validate() error { return validation.ValidateStruct(&c, - validation.Field(&c.LabelSelector, validation.Required), // This disallows an empty selector. + validation.Field(&c.LabelSelector, validation.Required), + ) +} + +// MockProviderConfig is the config for the mock provider used for testing. +type MockProviderConfig struct { + // Servers is a list of mock servers to create. + Servers []MockServerConfig `koanf:"servers"` + // FloatingIPs is a list of mock floating IPs to create. + FloatingIPs []MockFloatingIPConfig `koanf:"floating_ips"` +} + +// Validate validates the mock provider config. +func (c MockProviderConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.Servers), + validation.Field(&c.FloatingIPs), + ) +} + +// MockServerConfig is the config for a mock server. +type MockServerConfig struct { + ID int64 `koanf:"id"` + Name string `koanf:"name"` + Location string `koanf:"location"` + NetworkZone string `koanf:"network_zone"` + ResourceIndex int `koanf:"resource_index"` + PublicIPv4 string `koanf:"public_ipv4"` + PublicIPv6 string `koanf:"public_ipv6"` +} + +// Validate validates the mock server config. +func (c MockServerConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.ID, validation.Required, validation.Min(int64(1))), + validation.Field(&c.Name, validation.Required), + validation.Field(&c.PublicIPv4, validation.Required), + ) +} + +// MockFloatingIPConfig is the config for a mock floating IP. +type MockFloatingIPConfig struct { + ID int64 `koanf:"id"` + Name string `koanf:"name"` + Location string `koanf:"location"` + NetworkZone string `koanf:"network_zone"` + ResourceIndex int `koanf:"resource_index"` + IP string `koanf:"ip"` + // CurrentTarget is the ID of the server this floating IP is assigned to. + // Use 0 or omit for unassigned. + CurrentTarget int64 `koanf:"current_target"` +} + +// Validate validates the mock floating IP config. +func (c MockFloatingIPConfig) Validate() error { + return validation.ValidateStruct(&c, + validation.Field(&c.ID, validation.Required, validation.Min(int64(1))), + validation.Field(&c.Name, validation.Required), + validation.Field(&c.IP, validation.Required), ) } diff --git a/mock-flipper.example.yaml b/mock-flipper.example.yaml new file mode 100644 index 0000000..507203e --- /dev/null +++ b/mock-flipper.example.yaml @@ -0,0 +1,57 @@ +version: 1 + +telemetry: + logging: + level: "debug" + format: "text" + +heartbeat: + enabled: false + +notifications: + enabled: false + +server: + enabled: false + +groups: + - id: "test_group" + display_name: "Local Test" + provider: "mock" + poll_interval: 5s + post_plan_delay: 5s + plan_apply_with_unknown_status: true + + mock: + servers: + - id: 1 + name: "mock-server-1" + location: "fsn1" + network_zone: "eu-central" + resource_index: 0 + public_ipv4: "10.0.0.1" + - id: 2 + name: "mock-server-2" + location: "fsn1" + network_zone: "eu-central" + resource_index: 1 + public_ipv4: "10.0.0.2" + floating_ips: + - id: 100 + name: "test-fip" + location: "fsn1" + network_zone: "eu-central" + resource_index: 0 + ip: "192.168.1.100" + current_target: 2 # server with id=2 + + checks: + - id: "health" + display_name: "Check" + type: "http" + interval: 2s + timeout: 1s + fall: 2 + rise: 1 + path: "/health" + port: 18081 diff --git a/monitor/monitor.go b/monitor/monitor.go index be2152a..6320956 100644 --- a/monitor/monitor.go +++ b/monitor/monitor.go @@ -8,21 +8,32 @@ import ( "github.com/gzuidhof/flipper/config/cfgmodel" "github.com/gzuidhof/flipper/notification" "github.com/gzuidhof/flipper/provider/hetzner" + "github.com/gzuidhof/flipper/provider/mock" "github.com/gzuidhof/flipper/resource" "golang.org/x/sync/errgroup" ) //nolint:ireturn,nolintlint // This is a factory function. func buildProvider(ctx context.Context, group cfgmodel.GroupConfig) (resource.Provider, error) { - if group.Provider != string(resource.ProviderNameHetzner) { + switch group.Provider { + case string(resource.ProviderNameHetzner): + provider, err := hetzner.NewProvider(ctx, group) + if err != nil { + return nil, fmt.Errorf("failed to create hetzner provider: %w", err) + } + return provider, nil + case string(resource.ProviderNameMock): + if group.Mock == nil { + return nil, fmt.Errorf("mock config is required") + } + provider, err := mock.NewProviderFromConfig(*group.Mock) + if err != nil { + return nil, fmt.Errorf("failed to create mock provider: %w", err) + } + return provider, nil + default: return nil, fmt.Errorf("unsupported provider: %s", group.Provider) } - - provider, err := hetzner.NewProvider(ctx, group) - if err != nil { - return nil, fmt.Errorf("failed to create hetzner provider: %w", err) - } - return provider, nil } // Monitor watches resources. It supports watching multiple groups of resources in parallel. diff --git a/monitor/resources_test.go b/monitor/resources_test.go index f223f1d..87f590b 100644 --- a/monitor/resources_test.go +++ b/monitor/resources_test.go @@ -22,7 +22,7 @@ func TestResourcesWatcher(t *testing.T) { provider.Servers = append(provider.Servers, resource.Server{ Provider: resource.ProviderNameMock, ServerName: "mock-server-1", - HetznerID: 1, + ServerID: 1, }) watcher := NewResourcesWatcher(cfg, slog.Default(), provider) @@ -47,7 +47,7 @@ func TestResourcesWatcher(t *testing.T) { provider.Servers = append(provider.Servers, resource.Server{ Provider: resource.ProviderNameMock, ServerName: "mock-server-2", - HetznerID: 2, + ServerID: 2, }) r, cs, err = watcher.Update(ctx) diff --git a/notification/notifier.go b/notification/notifier.go index c7cdd92..26039d9 100644 --- a/notification/notifier.go +++ b/notification/notifier.go @@ -24,7 +24,7 @@ func (n *NoopNotifier) Notify(_ context.Context, _ string) error { // NewNotifierFromConfig creates a new Notifier from the given NotificationsConfig. // -//nolint:ireturn // This is a factory function, it's okay to return an interface. +//nolint:ireturn,nolintlint // This is a factory function, it's okay to return an interface. func NewNotifierFromConfig(cfg cfgmodel.NotificationsConfig, logger *slog.Logger) (Notifier, error) { if !cfg.Enabled { return &NoopNotifier{}, nil diff --git a/plan/plan_test.go b/plan/plan_test.go index 9f0b98a..53cb4bd 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -16,7 +16,7 @@ func servers(state resource.Status, location, networkZone string, ids ...int64) for idx, id := range ids { serv := resource.Server{ ServerName: fmt.Sprintf("mock-server-%d", id), - HetznerID: id, + ServerID: id, Location: location, NetworkZone: networkZone, Provider: resource.ProviderNameMock, @@ -41,9 +41,9 @@ func TestPlan(t *testing.T) { name: "no_changes", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2, 3), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "3", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "3", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, @@ -53,9 +53,9 @@ func TestPlan(t *testing.T) { name: "spread", // spread the floating IPs across the servers servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2, 3), floatingIPs: []resource.FloatingIP{ // They start out all on server 1 - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -68,9 +68,9 @@ func TestPlan(t *testing.T) { name: "spread_looparound", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -85,9 +85,9 @@ func TestPlan(t *testing.T) { servers(resource.StatusHealthy, "fsn1", "eu-central", 3, 4)..., ), floatingIPs: []resource.FloatingIP{ // Start unassigned. - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -101,9 +101,9 @@ func TestPlan(t *testing.T) { name: "other_location", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -117,7 +117,7 @@ func TestPlan(t *testing.T) { name: "different_network_zone", servers: servers(resource.StatusHealthy, "nbg1", "eu-north", 1), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, @@ -130,9 +130,9 @@ func TestPlan(t *testing.T) { servers(resource.StatusHealthy, "fsn1", "eu-central", 3, 4)..., ), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {HetznerID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, - {HetznerID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, + {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -146,7 +146,7 @@ func TestPlan(t *testing.T) { name: "assigned_to_unknown_server", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1), floatingIPs: []resource.FloatingIP{ - {HetznerID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1234", FloatingIPName: "floating-ip-1"}, + {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1234", FloatingIPName: "floating-ip-1"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, diff --git a/plan/state.go b/plan/state.go index 9aa2788..75789f7 100644 --- a/plan/state.go +++ b/plan/state.go @@ -96,7 +96,7 @@ func (s State) CandidateFloatingIPs() []resource.FloatingIP { // We do not touch floating IPs that are pointed to a server that is unknown to this tool. if _, ok := s.Servers[f.CurrentTarget]; !ok && f.CurrentTarget != "" { slog.Warn("floating IP points to unknown server", - "floating_ip_id", f.HetznerID, + "floating_ip_id", f.ProviderID, "server_id", f.CurrentTarget, ) continue @@ -108,7 +108,7 @@ func (s State) CandidateFloatingIPs() []resource.FloatingIP { // Sort todo to make the plan deterministic. // The exact order doesn't matter, as long as it's deterministic. For the tests to be simple we sort by ID. slices.SortFunc(flips, func(i, j resource.FloatingIP) int { - return int(i.HetznerID - j.HetznerID) + return int(i.ProviderID - j.ProviderID) }) return flips diff --git a/provider/hetzner/provider.go b/provider/hetzner/provider.go index 46333cc..dee23df 100644 --- a/provider/hetzner/provider.go +++ b/provider/hetzner/provider.go @@ -29,6 +29,9 @@ func hetznerIDToResourceID(hetznerID int64) string { // NewProvider creates a new Hetzner provider for a given group. func NewProvider(ctx context.Context, cfg cfgmodel.GroupConfig) (*Provider, error) { + if cfg.Hetzner == nil { + return nil, fmt.Errorf("hetzner config is required") + } if cfg.Hetzner.APIToken == "" { return nil, fmt.Errorf("hetzner API token is required") } @@ -98,7 +101,7 @@ func (c Provider) Poll(ctx context.Context) (resource.Group, error) { floatingIPs = append(floatingIPs, resource.FloatingIP{ Provider: c.Name(), - HetznerID: flip.ID, + ProviderID: flip.ID, FloatingIPName: flip.Name, Location: flip.HomeLocation.Name, NetworkZone: string(flip.HomeLocation.NetworkZone), @@ -128,7 +131,7 @@ func (c Provider) Poll(ctx context.Context) (resource.Group, error) { servers = append(servers, resource.Server{ Provider: c.Name(), - HetznerID: srv.ID, + ServerID: srv.ID, ServerName: srv.Name, Location: srv.Datacenter.Location.Name, NetworkZone: string(srv.Datacenter.Location.NetworkZone), @@ -167,8 +170,8 @@ func (c Provider) AssignFloatingIP(ctx context.Context, flip resource.FloatingIP } // We create these fake objects to use the hcloud-go API without first fetching the objects. - hflip := &hcloud.FloatingIP{ID: flip.HetznerID} - hsrv := &hcloud.Server{ID: srv.HetznerID} + hflip := &hcloud.FloatingIP{ID: flip.ProviderID} + hsrv := &hcloud.Server{ID: srv.ServerID} _, _, err := c.hc.FloatingIP.Assign(ctx, hflip, hsrv) if err != nil { diff --git a/provider/mock/floating_ip.go b/provider/mock/floating_ip.go index 54dcb02..8edd61b 100644 --- a/provider/mock/floating_ip.go +++ b/provider/mock/floating_ip.go @@ -12,7 +12,7 @@ func NewFloatingIP(name, location, networkZone string, ip netip.Addr) resource.F return resource.FloatingIP{ Provider: resource.ProviderNameMock, //nolint:gosec // This is a mock provider, so we don't need to worry about cryptographic security. - HetznerID: rand.Int63(), + ProviderID: rand.Int63(), FloatingIPName: name, Location: location, NetworkZone: networkZone, diff --git a/provider/mock/provider.go b/provider/mock/provider.go index 187583a..7aac25c 100644 --- a/provider/mock/provider.go +++ b/provider/mock/provider.go @@ -2,8 +2,11 @@ package mock import ( "context" + "fmt" + "net/netip" "time" + "github.com/gzuidhof/flipper/config/cfgmodel" "github.com/gzuidhof/flipper/resource" ) @@ -29,6 +32,61 @@ func NewProvider() *Provider { } } +// NewProviderFromConfig creates a new mock provider from config. +func NewProviderFromConfig(cfg cfgmodel.MockProviderConfig) (*Provider, error) { + p := NewProvider() + + for _, s := range cfg.Servers { + var ipv6 netip.Addr + if s.PublicIPv6 != "" { + var err error + ipv6, err = netip.ParseAddr(s.PublicIPv6) + if err != nil { + return nil, fmt.Errorf("invalid public_ipv6 %q for server %q: %w", s.PublicIPv6, s.Name, err) + } + } else { + ipv6 = netip.IPv6Unspecified() + } + ipv4, err := netip.ParseAddr(s.PublicIPv4) + if err != nil { + return nil, fmt.Errorf("invalid public_ipv4 %q for server %q: %w", s.PublicIPv4, s.Name, err) + } + p.Servers = append(p.Servers, resource.Server{ + Provider: resource.ProviderNameMock, + ServerID: s.ID, + ServerName: s.Name, + Location: s.Location, + NetworkZone: s.NetworkZone, + ResourceIndex: s.ResourceIndex, + PublicIPv4: ipv4, + PublicIPv6: ipv6, + }) + } + + for _, f := range cfg.FloatingIPs { + currentTarget := "" + if f.CurrentTarget != 0 { + currentTarget = fmt.Sprint(f.CurrentTarget) + } + ip, err := netip.ParseAddr(f.IP) + if err != nil { + return nil, fmt.Errorf("invalid ip %q for floating_ip %q: %w", f.IP, f.Name, err) + } + p.FloatingIPs = append(p.FloatingIPs, resource.FloatingIP{ + Provider: resource.ProviderNameMock, + ProviderID: f.ID, + FloatingIPName: f.Name, + Location: f.Location, + NetworkZone: f.NetworkZone, + ResourceIndex: f.ResourceIndex, + IP: ip, + CurrentTarget: currentTarget, + }) + } + + return p, nil +} + // Name returns the name of the mock provider, "mock". func (p *Provider) Name() resource.ProviderName { return resource.ProviderNameMock diff --git a/resource/floating_ip.go b/resource/floating_ip.go index bc530f1..8f75773 100644 --- a/resource/floating_ip.go +++ b/resource/floating_ip.go @@ -14,8 +14,8 @@ type FloatingIP struct { // Provider is the name of the cloud provider that the floating IP is from. Provider ProviderName - // HetznerID is the unique identifier of the floating IP in Hetzner. - HetznerID int64 + // ProviderID is the unique identifier of the floating IP in the cloud provider. + ProviderID int64 // FloatingIPName is the name of the floating IP. FloatingIPName string @@ -50,7 +50,7 @@ type FloatingIP struct { // ID returns the unique identifier of the floating IP. func (f FloatingIP) ID() string { - return fmt.Sprint(f.HetznerID) + return fmt.Sprint(f.ProviderID) } // Name returns the name of the floating IP. @@ -66,7 +66,7 @@ func (f FloatingIP) Equal(other Resource) bool { } return f.Provider == otherFloatingIP.Provider && - f.HetznerID == otherFloatingIP.HetznerID && + f.ProviderID == otherFloatingIP.ProviderID && f.FloatingIPName == otherFloatingIP.FloatingIPName && f.Location == otherFloatingIP.Location && f.NetworkZone == otherFloatingIP.NetworkZone && diff --git a/resource/server.go b/resource/server.go index 65d6ca7..6bdecb8 100644 --- a/resource/server.go +++ b/resource/server.go @@ -14,8 +14,8 @@ type Server struct { // Provider is name of the cloud provider where the server is located. Provider ProviderName - // HetznerID is the unique ID of the server in Hetzner. - HetznerID int64 + // ServerID is the unique ID of the server in the cloud provider. + ServerID int64 // ServerName is the name of the server. ServerName string @@ -50,7 +50,7 @@ type Server struct { // ID returns the unique identifier of the server. func (s Server) ID() string { - return fmt.Sprint(s.HetznerID) + return fmt.Sprint(s.ServerID) } // Name returns the name of the server. @@ -66,7 +66,7 @@ func (s Server) Equal(other Resource) bool { } return s.Provider == otherServer.Provider && - s.HetznerID == otherServer.HetznerID && + s.ServerID == otherServer.ServerID && s.ServerName == otherServer.ServerName && s.Location == otherServer.Location && s.NetworkZone == otherServer.NetworkZone && From bda4c749dd6bb2b812def1b965edb16fdbdafadf Mon Sep 17 00:00:00 2001 From: Jamie Nguyen Date: Mon, 8 Dec 2025 18:54:10 +0000 Subject: [PATCH 2/3] Fix monitor exiting immediately and add test for it --- monitor/monitor.go | 5 ++-- monitor/monitor_test.go | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 monitor/monitor_test.go diff --git a/monitor/monitor.go b/monitor/monitor.go index 6320956..c0f5bdf 100644 --- a/monitor/monitor.go +++ b/monitor/monitor.go @@ -76,12 +76,11 @@ func (w *Monitor) Watch(ctx context.Context) error { } w.didStart = true - errgrp := errgroup.Group{} - errggrp, ctx := errgroup.WithContext(ctx) + errgrp, ctx := errgroup.WithContext(ctx) for _, group := range w.groups { group := group - errggrp.Go(func() error { + errgrp.Go(func() error { return group.Start(ctx) }) } diff --git a/monitor/monitor_test.go b/monitor/monitor_test.go new file mode 100644 index 0000000..fad52df --- /dev/null +++ b/monitor/monitor_test.go @@ -0,0 +1,58 @@ +package monitor + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/gzuidhof/flipper/config/cfgmodel" + "github.com/gzuidhof/flipper/notification" + "github.com/gzuidhof/flipper/provider/mock" + "github.com/gzuidhof/flipper/resource" +) + +func TestMonitorWatch_BlocksUntilContextCancelled(t *testing.T) { + t.Parallel() + + provider := mock.NewProvider() + provider.Servers = append(provider.Servers, resource.Server{ + Provider: resource.ProviderNameMock, + ServerName: "mock-server", + ServerID: 1, + }) + + cfg := cfgmodel.GroupConfig{ + ID: "test-group", + DisplayName: "Test Group", + Provider: "mock", + PollInterval: 100 * time.Millisecond, + } + + group := NewGroup(cfg, provider, slog.Default(), ¬ification.NoopNotifier{}) + m := &Monitor{groups: []*Group{group}} + + ctx, cancel := context.WithCancel(context.Background()) + + watchReturned := make(chan struct{}) + go func() { + _ = m.Watch(ctx) + close(watchReturned) + }() + + // Watch should not return before context is cancelled. + time.Sleep(50 * time.Millisecond) + select { + case <-watchReturned: + t.Fatal("Watch() returned before context was cancelled") + default: + } + + cancel() + + select { + case <-watchReturned: + case <-time.After(5 * time.Second): + t.Fatal("Watch() did not return after context cancellation") + } +} From dddea3e82fc30d74a9f5ac4170143ccabe274ae6 Mon Sep 17 00:00:00 2001 From: Jamie Nguyen Date: Tue, 9 Dec 2025 10:19:55 +0000 Subject: [PATCH 3/3] Add an ID field per provider --- monitor/monitor_test.go | 2 +- monitor/resources_test.go | 4 ++-- plan/plan_test.go | 42 ++++++++++++++++++------------------ plan/state.go | 7 +++--- provider/hetzner/provider.go | 8 +++---- provider/mock/floating_ip.go | 2 +- provider/mock/provider.go | 4 ++-- resource/floating_ip.go | 20 +++++++++++++---- resource/server.go | 20 +++++++++++++---- 9 files changed, 67 insertions(+), 42 deletions(-) diff --git a/monitor/monitor_test.go b/monitor/monitor_test.go index fad52df..6bd3125 100644 --- a/monitor/monitor_test.go +++ b/monitor/monitor_test.go @@ -19,7 +19,7 @@ func TestMonitorWatch_BlocksUntilContextCancelled(t *testing.T) { provider.Servers = append(provider.Servers, resource.Server{ Provider: resource.ProviderNameMock, ServerName: "mock-server", - ServerID: 1, + MockID: 1, }) cfg := cfgmodel.GroupConfig{ diff --git a/monitor/resources_test.go b/monitor/resources_test.go index 87f590b..5e91092 100644 --- a/monitor/resources_test.go +++ b/monitor/resources_test.go @@ -22,7 +22,7 @@ func TestResourcesWatcher(t *testing.T) { provider.Servers = append(provider.Servers, resource.Server{ Provider: resource.ProviderNameMock, ServerName: "mock-server-1", - ServerID: 1, + MockID: 1, }) watcher := NewResourcesWatcher(cfg, slog.Default(), provider) @@ -47,7 +47,7 @@ func TestResourcesWatcher(t *testing.T) { provider.Servers = append(provider.Servers, resource.Server{ Provider: resource.ProviderNameMock, ServerName: "mock-server-2", - ServerID: 2, + MockID: 2, }) r, cs, err = watcher.Update(ctx) diff --git a/plan/plan_test.go b/plan/plan_test.go index 53cb4bd..dca75f1 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -16,7 +16,7 @@ func servers(state resource.Status, location, networkZone string, ids ...int64) for idx, id := range ids { serv := resource.Server{ ServerName: fmt.Sprintf("mock-server-%d", id), - ServerID: id, + MockID: id, Location: location, NetworkZone: networkZone, Provider: resource.ProviderNameMock, @@ -41,9 +41,9 @@ func TestPlan(t *testing.T) { name: "no_changes", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2, 3), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "3", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "3", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, @@ -53,9 +53,9 @@ func TestPlan(t *testing.T) { name: "spread", // spread the floating IPs across the servers servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2, 3), floatingIPs: []resource.FloatingIP{ // They start out all on server 1 - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -68,9 +68,9 @@ func TestPlan(t *testing.T) { name: "spread_looparound", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -85,9 +85,9 @@ func TestPlan(t *testing.T) { servers(resource.StatusHealthy, "fsn1", "eu-central", 3, 4)..., ), floatingIPs: []resource.FloatingIP{ // Start unassigned. - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -101,9 +101,9 @@ func TestPlan(t *testing.T) { name: "other_location", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1, 2), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "fsn1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -117,7 +117,7 @@ func TestPlan(t *testing.T) { name: "different_network_zone", servers: servers(resource.StatusHealthy, "nbg1", "eu-north", 1), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-1"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, @@ -130,9 +130,9 @@ func TestPlan(t *testing.T) { servers(resource.StatusHealthy, "fsn1", "eu-central", 3, 4)..., ), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, - {ProviderID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, - {ProviderID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 2, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "2", FloatingIPName: "floating-ip-2"}, + {Provider: resource.ProviderNameMock, MockID: 3, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "", FloatingIPName: "floating-ip-3"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{ @@ -146,7 +146,7 @@ func TestPlan(t *testing.T) { name: "assigned_to_unknown_server", servers: servers(resource.StatusHealthy, "nbg1", "eu-central", 1), floatingIPs: []resource.FloatingIP{ - {ProviderID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1234", FloatingIPName: "floating-ip-1"}, + {Provider: resource.ProviderNameMock, MockID: 1, Location: "nbg1", NetworkZone: "eu-central", CurrentTarget: "1234", FloatingIPName: "floating-ip-1"}, }, expectedPlan: Plan{ Actions: []ReassignFloatingIPAction{}, diff --git a/plan/state.go b/plan/state.go index 75789f7..912f93e 100644 --- a/plan/state.go +++ b/plan/state.go @@ -1,6 +1,7 @@ package plan import ( + "cmp" "log/slog" "slices" "strings" @@ -96,7 +97,7 @@ func (s State) CandidateFloatingIPs() []resource.FloatingIP { // We do not touch floating IPs that are pointed to a server that is unknown to this tool. if _, ok := s.Servers[f.CurrentTarget]; !ok && f.CurrentTarget != "" { slog.Warn("floating IP points to unknown server", - "floating_ip_id", f.ProviderID, + "floating_ip_id", f.ID(), "server_id", f.CurrentTarget, ) continue @@ -105,10 +106,10 @@ func (s State) CandidateFloatingIPs() []resource.FloatingIP { flips = append(flips, f) } - // Sort todo to make the plan deterministic. + // Sort to make the plan deterministic. // The exact order doesn't matter, as long as it's deterministic. For the tests to be simple we sort by ID. slices.SortFunc(flips, func(i, j resource.FloatingIP) int { - return int(i.ProviderID - j.ProviderID) + return cmp.Compare(i.ID(), j.ID()) }) return flips diff --git a/provider/hetzner/provider.go b/provider/hetzner/provider.go index dee23df..4d2e614 100644 --- a/provider/hetzner/provider.go +++ b/provider/hetzner/provider.go @@ -101,7 +101,7 @@ func (c Provider) Poll(ctx context.Context) (resource.Group, error) { floatingIPs = append(floatingIPs, resource.FloatingIP{ Provider: c.Name(), - ProviderID: flip.ID, + HetznerID: flip.ID, FloatingIPName: flip.Name, Location: flip.HomeLocation.Name, NetworkZone: string(flip.HomeLocation.NetworkZone), @@ -131,7 +131,7 @@ func (c Provider) Poll(ctx context.Context) (resource.Group, error) { servers = append(servers, resource.Server{ Provider: c.Name(), - ServerID: srv.ID, + HetznerID: srv.ID, ServerName: srv.Name, Location: srv.Datacenter.Location.Name, NetworkZone: string(srv.Datacenter.Location.NetworkZone), @@ -170,8 +170,8 @@ func (c Provider) AssignFloatingIP(ctx context.Context, flip resource.FloatingIP } // We create these fake objects to use the hcloud-go API without first fetching the objects. - hflip := &hcloud.FloatingIP{ID: flip.ProviderID} - hsrv := &hcloud.Server{ID: srv.ServerID} + hflip := &hcloud.FloatingIP{ID: flip.HetznerID} + hsrv := &hcloud.Server{ID: srv.HetznerID} _, _, err := c.hc.FloatingIP.Assign(ctx, hflip, hsrv) if err != nil { diff --git a/provider/mock/floating_ip.go b/provider/mock/floating_ip.go index 8edd61b..74f03d5 100644 --- a/provider/mock/floating_ip.go +++ b/provider/mock/floating_ip.go @@ -12,7 +12,7 @@ func NewFloatingIP(name, location, networkZone string, ip netip.Addr) resource.F return resource.FloatingIP{ Provider: resource.ProviderNameMock, //nolint:gosec // This is a mock provider, so we don't need to worry about cryptographic security. - ProviderID: rand.Int63(), + MockID: rand.Int63(), FloatingIPName: name, Location: location, NetworkZone: networkZone, diff --git a/provider/mock/provider.go b/provider/mock/provider.go index 7aac25c..839c7f7 100644 --- a/provider/mock/provider.go +++ b/provider/mock/provider.go @@ -53,7 +53,7 @@ func NewProviderFromConfig(cfg cfgmodel.MockProviderConfig) (*Provider, error) { } p.Servers = append(p.Servers, resource.Server{ Provider: resource.ProviderNameMock, - ServerID: s.ID, + MockID: s.ID, ServerName: s.Name, Location: s.Location, NetworkZone: s.NetworkZone, @@ -74,7 +74,7 @@ func NewProviderFromConfig(cfg cfgmodel.MockProviderConfig) (*Provider, error) { } p.FloatingIPs = append(p.FloatingIPs, resource.FloatingIP{ Provider: resource.ProviderNameMock, - ProviderID: f.ID, + MockID: f.ID, FloatingIPName: f.Name, Location: f.Location, NetworkZone: f.NetworkZone, diff --git a/resource/floating_ip.go b/resource/floating_ip.go index 8f75773..75d3804 100644 --- a/resource/floating_ip.go +++ b/resource/floating_ip.go @@ -14,8 +14,13 @@ type FloatingIP struct { // Provider is the name of the cloud provider that the floating IP is from. Provider ProviderName - // ProviderID is the unique identifier of the floating IP in the cloud provider. - ProviderID int64 + // HetznerID is the unique identifier of the floating IP in Hetzner. + // Only set when Provider is ProviderNameHetzner. + HetznerID int64 + + // MockID is the unique identifier of the floating IP in the mock provider. + // Only set when Provider is ProviderNameMock. + MockID int64 // FloatingIPName is the name of the floating IP. FloatingIPName string @@ -50,7 +55,14 @@ type FloatingIP struct { // ID returns the unique identifier of the floating IP. func (f FloatingIP) ID() string { - return fmt.Sprint(f.ProviderID) + switch f.Provider { + case ProviderNameHetzner: + return fmt.Sprint(f.HetznerID) + case ProviderNameMock: + return fmt.Sprint(f.MockID) + default: + panic(fmt.Sprintf("unknown provider: %s", f.Provider)) + } } // Name returns the name of the floating IP. @@ -66,7 +78,7 @@ func (f FloatingIP) Equal(other Resource) bool { } return f.Provider == otherFloatingIP.Provider && - f.ProviderID == otherFloatingIP.ProviderID && + f.ID() == otherFloatingIP.ID() && f.FloatingIPName == otherFloatingIP.FloatingIPName && f.Location == otherFloatingIP.Location && f.NetworkZone == otherFloatingIP.NetworkZone && diff --git a/resource/server.go b/resource/server.go index 6bdecb8..5a66ebe 100644 --- a/resource/server.go +++ b/resource/server.go @@ -14,8 +14,13 @@ type Server struct { // Provider is name of the cloud provider where the server is located. Provider ProviderName - // ServerID is the unique ID of the server in the cloud provider. - ServerID int64 + // HetznerID is the unique ID of the server in Hetzner. + // Only set when Provider is ProviderNameHetzner. + HetznerID int64 + + // MockID is the unique ID of the server in the mock provider. + // Only set when Provider is ProviderNameMock. + MockID int64 // ServerName is the name of the server. ServerName string @@ -50,7 +55,14 @@ type Server struct { // ID returns the unique identifier of the server. func (s Server) ID() string { - return fmt.Sprint(s.ServerID) + switch s.Provider { + case ProviderNameHetzner: + return fmt.Sprint(s.HetznerID) + case ProviderNameMock: + return fmt.Sprint(s.MockID) + default: + panic(fmt.Sprintf("unknown provider: %s", s.Provider)) + } } // Name returns the name of the server. @@ -66,7 +78,7 @@ func (s Server) Equal(other Resource) bool { } return s.Provider == otherServer.Provider && - s.ServerID == otherServer.ServerID && + s.ID() == otherServer.ID() && s.ServerName == otherServer.ServerName && s.Location == otherServer.Location && s.NetworkZone == otherServer.NetworkZone &&