package app import ( "context" "errors" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" "github.com/bnema/zerowrap" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/adapters/in/http/registry" trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" "github.com/bnema/gordon/internal/domain" cfgusecase "github.com/bnema/gordon/internal/usecase/config" "github.com/bnema/gordon/internal/usecase/container" "github.com/bnema/gordon/internal/usecase/proxy" servicecfg "github.com/bnema/gordon/internal/usecase/services" traffic "github.com/bnema/gordon/internal/usecase/traffic" ) type watchRecorder struct { called bool onChange func() } func (w *watchRecorder) Watch(ctx context.Context, onChange func()) error { w.called = true w.onChange = onChange return nil } type coordinatorRecorder struct { mu sync.Mutex triggerCalls int applyCalls int err error } func (c *coordinatorRecorder) Trigger(context.Context) error { c.mu.Lock() c.triggerCalls++ err := c.err c.mu.Unlock() return err } func (c *coordinatorRecorder) ApplyLoadedConfig(context.Context) error { c.mu.Lock() c.applyCalls++ err := c.err c.mu.Unlock() return err } func (c *coordinatorRecorder) TriggerCalls() int { c.mu.Lock() defer c.mu.Unlock() return c.triggerCalls } func (c *coordinatorRecorder) ApplyCalls() int { c.mu.Lock() defer c.mu.Unlock() return c.applyCalls } type reloadRecorder struct { mu sync.Mutex calls int err error onReload func() } func (r *reloadRecorder) Reload(context.Context) error { r.mu.Lock() r.calls++ err := r.err onReload := r.onReload r.mu.Unlock() if onReload != nil { onReload() } return err } func (r *reloadRecorder) Calls() int { r.mu.Lock() defer r.mu.Unlock() return r.calls } type proxyRecorder struct { calls int config proxy.Config } func (p *proxyRecorder) UpdateConfig(config proxy.Config) { p.calls++ p.config = config } type registryLimitsRecorder struct { calls int maxBlobChunkSize int64 maxBlobSize int64 } func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) { r.calls++ r.maxBlobChunkSize = maxBlobChunkSize r.maxBlobSize = maxBlobSize } type containerConfigApplyRecorder struct { calls int ctx context.Context cfg Config } func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error { r.calls++ r.ctx = ctx r.cfg = cfg return nil } type standaloneServiceRecorder struct { mu sync.Mutex calls int services []domain.StandaloneService err error onReconcile func() } func (r *standaloneServiceRecorder) Reconcile(_ context.Context, services []domain.StandaloneService) error { r.mu.Lock() r.calls++ r.services = append([]domain.StandaloneService(nil), services...) err := r.err onReconcile := r.onReconcile r.mu.Unlock() if onReconcile != nil { onReconcile() } return err } func (r *standaloneServiceRecorder) Status(context.Context) ([]domain.StandaloneServiceStatus, error) { return nil, nil } func (r *standaloneServiceRecorder) Calls() int { r.mu.Lock() defer r.mu.Unlock() return r.calls } type eventBusRecorder struct { mu sync.Mutex calls int eventType domain.EventType payload any err error } func (e *eventBusRecorder) Publish(eventType domain.EventType, payload any) error { e.mu.Lock() e.calls++ e.eventType = eventType e.payload = payload err := e.err e.mu.Unlock() return err } func (e *eventBusRecorder) Calls() int { e.mu.Lock() defer e.mu.Unlock() return e.calls } func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) { ctx := context.Background() configSvc := &watchRecorder{} coordinator := &coordinatorRecorder{} err := setupConfigHotReload(ctx, configSvc, coordinator) require.NoError(t, err) require.True(t, configSvc.called) require.NotNil(t, configSvc.onChange) configSvc.onChange() require.Equal(t, 1, coordinator.ApplyCalls()) require.Equal(t, 0, coordinator.TriggerCalls()) } func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) { cfg := Config{} cfg.Server.MaxBlobSize = "2GB" result, err := buildProxyConfig(cfg, zerowrap.Default()) require.NoError(t, err) assert.Equal(t, int64(2<<30), result.maxBlobSize) assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize) } func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) { result, err := buildProxyConfig(Config{}, zerowrap.Default()) require.NoError(t, err) assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize) } func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_blob_size", "8MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) v.Set("server.legacy_registry_domains", []string{"old.example.com"}) v.Set("images.allowed_registries", []string{"docker.io"}) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{} containerCfg := &containerConfigApplyRecorder{} registryLimits := ®istryLimitsRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) coord.SetContainerConfigApplier(containerCfg.Apply) require.NoError(t, coord.ApplyLoadedConfig(ctx)) require.Equal(t, 0, reloadSvc.Calls()) require.Equal(t, 1, proxySvc.calls) require.Equal(t, 1, events.Calls()) require.Equal(t, 1, registryLimits.calls) require.Equal(t, 1, containerCfg.calls) require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) require.Equal(t, domain.EventConfigReload, events.eventType) require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain) require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains) require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries) require.Equal(t, proxy.Config{ RegistryDomain: "reload.example.com", RegistryPort: 5000, MaxBodySize: 5 << 20, MaxResponseSize: 7 << 20, MaxConcurrentConns: 99, }, proxySvc.config) } func TestServiceInit_ReloadRegistersCustomTLSMuxHTTPSFallback(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() httpsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("reload secure")) }) pkiSvc := newTestPKIService(t) customAddress := freeTCPAddress(t) si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: &services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), trafficManager: manager, httpsProxyHandler: httpsHandler, pkiSvc: pkiSvc, }, } si.registerReloadCoordinatorHooks() reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.Server.TLSPort = freeTCPPort(t) reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{ "custom-secure": {Address: customAddress, Protocol: domain.EntryPointProtocolTLSMux}, } require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)) _, portValue, err := net.SplitHostPort(customAddress) require.NoError(t, err) port, err := strconv.Atoi(portValue) require.NoError(t, err) body := httpsGetForTest(t, port, "app.example.com") require.Contains(t, body, "reload secure") } func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: &services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), }, } si.registerReloadCoordinatorHooks() require.NotNil(t, si.svc.reloadCoordinator.applyContainerConfig) reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.Images.AllowedRegistries = []string{"docker.io"} require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)) } func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t *testing.T) { ctx := context.Background() dataDir := t.TempDir() secretPath := "service/game/rcon" require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "secrets", "service", "game"), 0700)) require.NoError(t, os.WriteFile(filepath.Join(dataDir, "secrets", secretPath), []byte("test-secret-value"), 0600)) rt := outmocks.NewMockContainerRuntime(t) var sawSecretEnv bool rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once() rt.On("InspectImageVolumes", mock.Anything, "game:latest").Return([]string(nil), nil).Once() rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) { config := args.Get(1).(*domain.ContainerConfig) for _, entry := range config.Env { if strings.HasPrefix(entry, "RCON_PASSWORD=") { sawSecretEnv = true } } }).Return(&domain.Container{ID: "created-1"}, nil).Once() rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once() provider := createStandaloneServiceSecretProvider(domain.SecretsBackendUnsafe, dataDir, zerowrap.Default()) reconciler := servicecfg.NewServiceWithSecretProvider(rt, provider) err := reconciler.Reconcile(ctx, []domain.StandaloneService{{Name: "game", Image: "game:latest", Enabled: true, Secrets: []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}}}) require.NoError(t, err) assert.True(t, sawSecretEnv) } func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTrafficBeforeStandaloneServices(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "reload.example.com") v.Set("server.registry_port", 5000) configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() reconciler := &standaloneServiceRecorder{onReconcile: func() { assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") }} si := &serviceInit{ ctx: ctx, v: v, cfg: Config{}, log: zerowrap.Default(), svc: &services{ configSvc: configSvc, containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc), internalRegUser: "gordon", internalRegPass: "secret", reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()), trafficManager: manager, standaloneServiceSvc: reconciler, }, } si.registerReloadCoordinatorHooks() reloadCfg := Config{} reloadCfg.Server.GordonDomain = "reload.example.com" reloadCfg.Server.RegistryPort = 5000 reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}} reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}} reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}} reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}} require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg)) require.Equal(t, 1, reconciler.Calls()) require.Len(t, reconciler.services, 1) assert.Equal(t, "game", reconciler.services[0].Name) assert.NotEmpty(t, manager.Status().EntryPoints) } func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTrafficBeforeStandaloneServices(t *testing.T) { ctx := context.Background() v := viper.New() configSvc := cfgusecase.NewService(v, nil) require.NoError(t, configSvc.Load(ctx)) manager := trafficadapter.NewManager() defer func() { require.NoError(t, manager.Shutdown(ctx)) }() reconciler := &standaloneServiceRecorder{onReconcile: func() { assert.NotEmpty(t, manager.Status().EntryPoints, "traffic graph should apply before standalone services reconcile") }} cfg := Config{} cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}} cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}} cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}} cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}} ready := make(chan struct{}) close(ready) svc := &services{configSvc: configSvc, trafficManager: manager, standaloneServiceSvc: reconciler} require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil)) require.Equal(t, 1, reconciler.Calls()) assert.NotEmpty(t, manager.Status().EntryPoints) } func TestReconcileStandaloneServices_ConfigConversionErrorIsClear(t *testing.T) { reconciler := &standaloneServiceRecorder{} err := reconcileStandaloneServices(context.Background(), reconciler, Config{Services: []servicecfg.Config{{Name: "broken", Image: "", Enabled: true}}}) require.Error(t, err) assert.ErrorContains(t, err, "convert standalone service config") assert.Equal(t, 0, reconciler.Calls()) } func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "new.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_blob_size", "8MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{} registryLimits := ®istryLimitsRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) require.NoError(t, coord.Trigger(ctx)) require.NoError(t, coord.Trigger(ctx)) require.Equal(t, 1, reloadSvc.Calls()) require.Equal(t, 1, proxySvc.calls) require.Equal(t, 1, events.Calls()) require.Equal(t, 1, registryLimits.calls) require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize) require.Equal(t, int64(8<<20), registryLimits.maxBlobSize) require.Equal(t, domain.EventConfigReload, events.eventType) require.Equal(t, proxy.Config{ RegistryDomain: "new.example.com", RegistryPort: 5000, MaxBodySize: 5 << 20, MaxResponseSize: 7 << 20, MaxConcurrentConns: 99, }, proxySvc.config) } func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "retry.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "2MB") v.Set("server.max_blob_chunk_size", "3MB") v.Set("server.max_proxy_response_size", "4MB") v.Set("server.max_concurrent_connections", 11) reloadErr := errors.New("reload failed") reloadSvc := &reloadRecorder{err: reloadErr} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) err := coord.Trigger(ctx) require.ErrorIs(t, err, reloadErr) reloadSvc.mu.Lock() reloadSvc.err = nil reloadSvc.mu.Unlock() require.NoError(t, coord.Trigger(ctx)) require.Equal(t, 2, reloadSvc.Calls()) assert.Equal(t, 1, proxySvc.calls) } func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "publish.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "5MB") v.Set("server.max_blob_chunk_size", "6MB") v.Set("server.max_proxy_response_size", "7MB") v.Set("server.max_concurrent_connections", 99) publishErr := errors.New("publish failed") reloadSvc := &reloadRecorder{} proxySvc := &proxyRecorder{} events := &eventBusRecorder{err: publishErr} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default()) err := coord.Trigger(ctx) require.ErrorIs(t, err, publishErr) err = coord.Trigger(ctx) require.ErrorIs(t, err, publishErr) require.Equal(t, 2, reloadSvc.Calls()) require.Equal(t, 2, proxySvc.calls) require.Equal(t, 2, events.Calls()) require.Equal(t, domain.EventConfigReload, events.eventType) } func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { ctx := context.Background() v := viper.New() v.Set("server.gordon_domain", "app.example.com") v.Set("server.registry_port", 5000) v.Set("server.max_proxy_body_size", "2MB") v.Set("server.max_blob_chunk_size", "3MB") v.Set("server.max_proxy_response_size", "4MB") v.Set("server.max_concurrent_connections", 11) started := make(chan struct{}) release := make(chan struct{}) var once sync.Once reloadSvc := &reloadRecorder{onReload: func() { once.Do(func() { close(started) }) <-release }} proxySvc := &proxyRecorder{} coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) firstDone := make(chan struct{}) go func() { _ = coord.Trigger(ctx) close(firstDone) }() select { case <-started: case <-time.After(2 * time.Second): t.Fatal("first reload did not start") } secondDone := make(chan struct{}) go func() { _ = coord.Trigger(ctx) close(secondDone) }() select { case <-secondDone: t.Fatal("second reload finished before the first was released") case <-time.After(75 * time.Millisecond): } require.Equal(t, 1, reloadSvc.Calls()) close(release) select { case <-firstDone: case <-time.After(2 * time.Second): t.Fatal("first reload did not finish") } select { case <-secondDone: case <-time.After(2 * time.Second): t.Fatal("second reload did not finish") } require.Equal(t, 1, reloadSvc.Calls()) assert.Equal(t, 1, proxySvc.calls) }