Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions config/cfgmodel/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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),
)
}
Expand Down
60 changes: 59 additions & 1 deletion config/cfgmodel/model_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
57 changes: 57 additions & 0 deletions mock-flipper.example.yaml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 20 additions & 10 deletions monitor/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -65,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)
})
}
Expand Down
58 changes: 58 additions & 0 deletions monitor/monitor_test.go
Original file line number Diff line number Diff line change
@@ -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",
MockID: 1,
})

cfg := cfgmodel.GroupConfig{
ID: "test-group",
DisplayName: "Test Group",
Provider: "mock",
PollInterval: 100 * time.Millisecond,
}

group := NewGroup(cfg, provider, slog.Default(), &notification.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")
}
}
4 changes: 2 additions & 2 deletions monitor/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
MockID: 1,
})

watcher := NewResourcesWatcher(cfg, slog.Default(), provider)
Expand All @@ -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,
MockID: 2,
})

r, cs, err = watcher.Update(ctx)
Expand Down
2 changes: 1 addition & 1 deletion notification/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading