diff --git a/docs/cli/serve.md b/docs/cli/serve.md index 584b68eb..3efa83d8 100644 --- a/docs/cli/serve.md +++ b/docs/cli/serve.md @@ -70,7 +70,7 @@ Gordon responds to these signals: | `SIGTERM` | Graceful shutdown | | `SIGINT` | Graceful shutdown (Ctrl+C) | | `SIGUSR1` | Reload configuration | -| `SIGUSR2` | Manual deploy (used by `gordon deploy`) | +| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) | ### Running with systemd @@ -106,8 +106,22 @@ sudo loginctl enable-linger $USER 6. Initialize services (registry, proxy, auth) 7. Register event handlers 8. Start config file watcher -9. Sync existing containers -10. Start HTTP servers +9. Start HTTP servers +10. Run best-effort startup recovery (sync existing containers, then recover configured routes) + +### Startup Recovery + +After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting. + +1. **Sync existing containers** - Gordon reconciles runtime state with configured routes. +2. **Recover configured routes** - Gordon runs `AutoStart` for any configured route that has no running container. +3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass. + +This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config. + +Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes. + +Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running. ### Shutdown Sequence @@ -177,14 +191,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). ### Description -**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. +**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route. -**Remote mode:** Calls the remote Gordon Admin API to trigger deployment. +**Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request. -Both modes trigger: +Both local and remote manual deploys use Gordon's explicit deploy path: -- Fresh image pull (always pulls latest, ignoring cache) -- Container redeployment for the specified route +- Fresh image content is pulled for the route +- The specified route is redeployed +- Configured readiness checks and drain behavior apply when needed ### Examples diff --git a/docs/config/index.md b/docs/config/index.md index 30f3439e..9ac1bed5 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -22,6 +22,8 @@ gordon_domain = "gordon.mydomain.com" ``` > **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting. +> +> For a staged registry host rename, set the new `server.gordon_domain` and keep old Gordon registry hosts in `server.legacy_registry_domains` until clients move. See [Server](./server.md#gordon-domain) and [Upgrading](../upgrading.md#staged-registry-host-rename). ## Full Configuration Reference diff --git a/docs/config/server.md b/docs/config/server.md index c7d47af8..bdb93430 100644 --- a/docs/config/server.md +++ b/docs/config/server.md @@ -13,6 +13,7 @@ tls_port = 8443 # HTTPS listener port (0 = disabled) # tls_key_file = "" # Optional: PEM key for static TLS # force_https_redirect = false # Redirect all HTTP traffic to HTTPS gordon_domain = "gordon.mydomain.com" # Gordon domain (required) +# legacy_registry_domains = ["registry.example.com:5000"] # data_dir = "~/.gordon" # Data storage directory (default) max_blob_chunk_size = "95MB" # Max registry blob upload chunk max_blob_size = "1GB" # Max cumulative registry blob/layer upload @@ -30,6 +31,7 @@ max_blob_size = "1GB" # Max cumulative registry blob/layer up | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. | +| `legacy_registry_domains` | []string | `[]` | Additional Gordon registry hosts treated as aliases during staged migration. See [Upgrading: Staged Registry Host Rename](../upgrading.md#staged-registry-host-rename). | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | @@ -217,6 +219,8 @@ This domain is used for: > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting. +For a staged rename, set `gordon_domain` to the new public host and add any old Gordon registry hosts that clients still use to `legacy_registry_domains` (including `host:port` forms). Gordon treats those entries as registry aliases during image matching and internal pulls, then writes canonical refs back to `gordon_domain`. Remote CLI and admin API traffic should use `gordon_domain`. + Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange. When requests arrive on the proxy port with this domain as the Host header, Gordon routes them to the backend services (registry and admin API). diff --git a/docs/upgrading.md b/docs/upgrading.md index 4a4bdd0c..2d92ee7a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -74,6 +74,29 @@ gordon_domain = "gordon.example.com" If you do not migrate, `gordon status --remote ...` and `gordon routes list --remote ...` can fail with `/auth/token` `404`, and `reg-domain/v2/` or `/admin/status` can return `404`. +### Staged Registry Host Rename + +If you cannot rename the Gordon registry host in one step, keep the new host in `server.gordon_domain` and list the old Gordon registry hosts in `server.legacy_registry_domains` during the cutover: + +```toml +[server] +gordon_domain = "gordon.example.com" +legacy_registry_domains = [ + "registry.example.com", + "registry.example.com:5000", +] +``` + +Recommended rollout: + +1. Set `gordon_domain` to the new host. +2. Add every old Gordon registry host that clients still use to `legacy_registry_domains`. +3. Restart Gordon. +4. Move Docker/Podman logins, pushes, pulls, and image references to the new host. +5. Remove `legacy_registry_domains` after every client has moved. + +During the transition, Gordon treats both the current and legacy hosts as its own registry for image matching and internal pulls, then saves canonical refs back to `gordon_domain`. Remote CLI and admin API traffic should use the new `gordon_domain`. + ## v2.16.0 to v2.30.0 ### Breaking: Password Authentication Removed diff --git a/gordon.toml.example b/gordon.toml.example index d1321c37..51752a38 100644 --- a/gordon.toml.example +++ b/gordon.toml.example @@ -5,6 +5,7 @@ port = 8088 registry_port = 5000 gordon_domain = "gordon.example.com" +# legacy_registry_domains = ["registry.example.com:5000"] # data_dir = "~/.gordon" [auth] diff --git a/internal/adapters/in/cli/controlplane_local.go b/internal/adapters/in/cli/controlplane_local.go index 11178628..f2870659 100644 --- a/internal/adapters/in/cli/controlplane_local.go +++ b/internal/adapters/in/cli/controlplane_local.go @@ -422,7 +422,7 @@ func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*r if err != nil { return nil, err } - container, err := l.containerSvc.Deploy(ctx, *route) + container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) if err != nil { return nil, err } diff --git a/internal/adapters/in/cli/controlplane_local_test.go b/internal/adapters/in/cli/controlplane_local_test.go index a1875212..e57798d9 100644 --- a/internal/adapters/in/cli/controlplane_local_test.go +++ b/internal/adapters/in/cli/controlplane_local_test.go @@ -37,6 +37,33 @@ func TestLocalControlPlane_GetStatus(t *testing.T) { require.Equal(t, "running", status.ContainerStatus["app.local"]) } +func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) { + t.Parallel() + + configSvc := inmocks.NewMockConfigService(t) + containerSvc := inmocks.NewMockContainerService(t) + + ctx := context.Background() + route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"} + + require.False(t, domain.IsInternalDeploy(ctx)) + + configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil) + containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) { + require.True(t, domain.IsInternalDeploy(deployCtx)) + require.Equal(t, *route, deployedRoute) + return &domain.Container{ID: "container-1"}, nil + }) + + cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} + result, err := cp.Deploy(ctx, "app.local") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "deployed", result.Status) + require.Equal(t, "app.local", result.Domain) + require.Equal(t, "container-1", result.ContainerID) +} + func TestLocalControlPlane_Backups(t *testing.T) { t.Parallel() diff --git a/internal/adapters/out/docker/runtime.go b/internal/adapters/out/docker/runtime.go index 6be9de9f..6e491a17 100644 --- a/internal/adapters/out/docker/runtime.go +++ b/internal/adapters/out/docker/runtime.go @@ -363,42 +363,6 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri return nil } -// EnsureContainerRestartPolicy updates a container restart policy when it does not match. -func (r *Runtime) EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error { - ctx = zerowrap.CtxWithFields(ctx, map[string]any{ - zerowrap.FieldLayer: "adapter", - zerowrap.FieldAdapter: "docker", - zerowrap.FieldAction: "EnsureContainerRestartPolicy", - zerowrap.FieldEntityID: containerID, - "restart_policy": policy, - }) - log := zerowrap.FromCtx(ctx) - - inspect, err := r.client.ContainerInspect(ctx, containerID) - if err != nil { - return log.WrapErr(err, "failed to inspect container") - } - if inspect.HostConfig == nil { - return fmt.Errorf("container inspect response missing host config: %s", containerID) - } - if string(inspect.HostConfig.RestartPolicy.Name) == policy { - return nil - } - - updateConfig := container.UpdateConfig{ - Resources: inspect.HostConfig.Resources, - RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(policy)}, - } - resp, err := r.client.ContainerUpdate(ctx, containerID, updateConfig) - if err != nil { - return log.WrapErr(err, "failed to update container restart policy") - } - for _, warning := range resp.Warnings { - log.Warn().Str("warning", warning).Msg("container restart policy update warning") - } - return nil -} - // ListContainers lists containers. func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ diff --git a/internal/adapters/out/docker/runtime_restart_policy_test.go b/internal/adapters/out/docker/runtime_restart_policy_test.go deleted file mode 100644 index 08c8b87d..00000000 --- a/internal/adapters/out/docker/runtime_restart_policy_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package docker - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/docker/docker/client" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/bnema/gordon/internal/domain" -) - -func TestRuntime_EnsureContainerRestartPolicy_SkipsWhenAlreadySet(t *testing.T) { - var updateCalled bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "Id": "abc123", - "HostConfig": { - "RestartPolicy": { - "Name": "always" - } - } - }`)) - case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update": - updateCalled = true - w.WriteHeader(http.StatusInternalServerError) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - runtime := newRuntimeForRestartPolicyHTTPServer(t, server) - err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways) - require.NoError(t, err) - assert.False(t, updateCalled, "update should not be called when restart policy is already set") -} - -func TestRuntime_EnsureContainerRestartPolicy_UpdatesAndPreservesResources(t *testing.T) { - var updateBody map[string]any - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "Id": "abc123", - "HostConfig": { - "RestartPolicy": { - "Name": "" - }, - "Memory": 268435456, - "NanoCpus": 500000000, - "PidsLimit": 128, - "Ulimits": [ - { - "Name": "nofile", - "Soft": 65536, - "Hard": 65536 - } - ] - } - }`)) - case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update": - require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody)) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"Warnings":null}`)) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - runtime := newRuntimeForRestartPolicyHTTPServer(t, server) - err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways) - require.NoError(t, err) - require.NotNil(t, updateBody) - assert.Equal(t, domain.RestartPolicyAlways, updateBody["RestartPolicy"].(map[string]any)["Name"]) - assert.Equal(t, float64(268435456), updateBody["Memory"]) - assert.Equal(t, float64(500000000), updateBody["NanoCpus"]) - assert.Equal(t, float64(128), updateBody["PidsLimit"]) - ulimits, ok := updateBody["Ulimits"].([]any) - require.True(t, ok, "Ulimits should be an array") - require.Greater(t, len(ulimits), 0) - firstUlimit, ok := ulimits[0].(map[string]any) - require.True(t, ok, "first Ulimit should be a map") - assert.Equal(t, "nofile", firstUlimit["Name"]) - assert.Equal(t, float64(65536), firstUlimit["Soft"]) - assert.Equal(t, float64(65536), firstUlimit["Hard"]) -} - -func TestRuntime_EnsureContainerRestartPolicy_ReturnsErrorWhenHostConfigMissing(t *testing.T) { - var updateCalled bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{ - "Id": "abc123" - }`)) - case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update": - updateCalled = true - w.WriteHeader(http.StatusInternalServerError) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - runtime := newRuntimeForRestartPolicyHTTPServer(t, server) - err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways) - require.Error(t, err) - assert.Contains(t, err.Error(), "missing host config") - assert.False(t, updateCalled, "update should not be called when host config is missing") -} - -func newRuntimeForRestartPolicyHTTPServer(t *testing.T, server *httptest.Server) *Runtime { - host := strings.TrimPrefix(server.URL, "http://") - cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client())) - require.NoError(t, err) - return NewRuntimeWithClient(cli) -} diff --git a/internal/app/run.go b/internal/app/run.go index d5c4603a..df9893eb 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -93,22 +93,24 @@ import ( // Config holds the application configuration. type Config struct { Server struct { - Port int `mapstructure:"port"` - RegistryPort int `mapstructure:"registry_port"` - GordonDomain string `mapstructure:"gordon_domain"` - TLSPort int `mapstructure:"tls_port"` - TLSCertFile string `mapstructure:"tls_cert_file"` - TLSKeyFile string `mapstructure:"tls_key_file"` - ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` - DataDir string `mapstructure:"data_dir"` - MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" - MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" - MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB" - MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit - MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` - RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` - ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` - RegistryListenAddr string `mapstructure:"registry_listen_address"` + Port int `mapstructure:"port"` + RegistryPort int `mapstructure:"registry_port"` + GordonDomain string `mapstructure:"gordon_domain"` + RegistryDomain string `mapstructure:"registry_domain"` + LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"` + TLSPort int `mapstructure:"tls_port"` + TLSCertFile string `mapstructure:"tls_cert_file"` + TLSKeyFile string `mapstructure:"tls_key_file"` + ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"` + DataDir string `mapstructure:"data_dir"` + MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" + MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" + MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB" + MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit + MaxConcurrentConns int `mapstructure:"max_concurrent_connections"` + RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"` + ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"` + RegistryListenAddr string `mapstructure:"registry_listen_address"` } `mapstructure:"server"` Logging struct { @@ -1356,6 +1358,14 @@ func resolveEnvDir(cfg Config) string { return envDir } +func resolveRegistryDomains(cfg Config) (string, []string) { + registryDomain := cfg.Server.GordonDomain + if registryDomain == "" { + registryDomain = cfg.Server.RegistryDomain + } + return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...) +} + func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { // Token store is always created since tokens work in both auth modes store, err := tokenstore.NewStore(backend, dataDir, log) @@ -1750,9 +1760,11 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro } // 0 means no limit (as documented in proxy.Config) + registryDomain, _ := resolveRegistryDomains(cfg) + return &proxyConfigResult{ proxyConfig: proxy.Config{ - RegistryDomain: cfg.Server.GordonDomain, + RegistryDomain: registryDomain, RegistryPort: cfg.Server.RegistryPort, MaxBodySize: maxProxyBodySize, MaxResponseSize: maxProxyResponseSize, @@ -1827,10 +1839,12 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc } attachmentConfig := svc.configSvc.GetAttachmentConfig() + registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg) containerConfig := container.Config{ RegistryAuthEnabled: cfg.Auth.Enabled, - RegistryDomain: cfg.Server.GordonDomain, + RegistryDomain: registryDomain, + LegacyRegistryDomains: legacyRegistryDomains, RegistryPort: cfg.Server.RegistryPort, InternalRegistryUsername: svc.internalRegUser, InternalRegistryPassword: svc.internalRegPass, @@ -1958,7 +1972,8 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func } // Auto-route handler for creating routes from image labels - autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain). + registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg) + autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, registryDomain, legacyRegistryDomains...). WithEnvExtractor(svc.runtime, svc.envDir) // Preview handler for creating preview environments from tagged images @@ -2013,26 +2028,6 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina return nil } -// syncAndAutoStart syncs existing containers and auto-starts if configured. -func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { - if err := svc.containerSvc.EnsureManagedContainerRestartPolicies(ctx); err != nil { - log.Warn().Err(err).Msg("failed to migrate managed container restart policies") - } - - if err := svc.containerSvc.SyncContainers(ctx); err != nil { - log.Warn().Err(err).Msg("failed to sync existing containers") - } - - if svc.configSvc.IsAutoRouteEnabled() { - routes := svc.configSvc.GetRoutes(ctx) - if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil { - log.Warn().Err(err).Msg("failed to auto-start containers") - } - } - - // Start background monitor to restart crashed containers. - svc.containerSvc.StartMonitor(ctx) -} func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.RemoteAddr) @@ -2589,8 +2584,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, defer schedulerCleanup() } - // Auto-start after servers are listening (registry port is now bound). - syncAndAutoStart(ctx, svc, log) + // Recover configured routes after servers are listening (registry port is now bound). + syncAndRecoverConfiguredRoutes(ctx, svc.configSvc, svc.containerSvc, log) waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) cleanupHandlers() // Stop debounce timers before draining containers @@ -3319,6 +3314,7 @@ func isProcessAlive(pid int) bool { func loadConfig(v *viper.Viper, configPath string) error { v.SetDefault("server.port", 8088) v.SetDefault("server.registry_port", 5000) + v.SetDefault("server.legacy_registry_domains", []string{}) v.SetDefault("server.tls_port", 8443) v.SetDefault("server.tls_cert_file", "") v.SetDefault("server.tls_key_file", "") diff --git a/internal/app/startup_recovery.go b/internal/app/startup_recovery.go new file mode 100644 index 00000000..8425a947 --- /dev/null +++ b/internal/app/startup_recovery.go @@ -0,0 +1,44 @@ +package app + +import ( + "context" + + "github.com/bnema/zerowrap" + + "github.com/bnema/gordon/internal/domain" +) + +// startupConfigService defines the route configuration needed during startup +// recovery. It intentionally excludes auto-route settings because reboot +// recovery always works from configured routes. +type startupConfigService interface { + GetRoutes(ctx context.Context) []domain.Route +} + +// startupContainerService defines the container lifecycle operations needed +// during startup recovery. +type startupContainerService interface { + SyncContainers(ctx context.Context) error + AutoStart(ctx context.Context, routes []domain.Route) error + StartMonitor(ctx context.Context) +} + +// syncAndRecoverConfiguredRoutes performs best-effort startup recovery for +// configured routes after listeners are ready. +func syncAndRecoverConfiguredRoutes( + ctx context.Context, + configSvc startupConfigService, + containerSvc startupContainerService, + log zerowrap.Logger, +) { + defer containerSvc.StartMonitor(ctx) + + if err := containerSvc.SyncContainers(ctx); err != nil { + log.Warn().Err(err).Msg("failed to sync existing containers") + } + + routes := configSvc.GetRoutes(ctx) + if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil { + log.Warn().Err(err).Msg("failed to auto-start configured routes") + } +} diff --git a/internal/app/startup_recovery_test.go b/internal/app/startup_recovery_test.go new file mode 100644 index 00000000..812ce5ad --- /dev/null +++ b/internal/app/startup_recovery_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "context" + "testing" + + "github.com/bnema/zerowrap" + "github.com/stretchr/testify/assert" + + "github.com/bnema/gordon/internal/domain" +) + +type startupRecoveryFakeConfigService struct { + calls *[]string + routes []domain.Route +} + +var _ startupConfigService = (*startupRecoveryFakeConfigService)(nil) + +func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route { + *f.calls = append(*f.calls, "routes") + return append([]domain.Route(nil), f.routes...) +} + +type startupRecoveryFakeContainerService struct { + calls *[]string + syncErr error + autoStartErr error + autoStartCtx context.Context + autoStartRoutes []domain.Route + startMonitorCtx context.Context +} + +var _ startupContainerService = (*startupRecoveryFakeContainerService)(nil) + +func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error { + *f.calls = append(*f.calls, "sync") + return f.syncErr +} + +func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error { + *f.calls = append(*f.calls, "autostart") + f.autoStartCtx = ctx + f.autoStartRoutes = append([]domain.Route(nil), routes...) + return f.autoStartErr +} + +func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) { + *f.calls = append(*f.calls, "monitor") + f.startMonitorCtx = ctx +} + +func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) { + ctx := context.Background() + routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} + calls := make([]string, 0, 4) + + configSvc := &startupRecoveryFakeConfigService{ + calls: &calls, + routes: routes, + } + containerSvc := &startupRecoveryFakeContainerService{calls: &calls} + + syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) + + assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) + assert.Equal(t, routes, containerSvc.autoStartRoutes) + assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) + assert.False(t, domain.IsInternalDeploy(ctx)) + assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) +} + +func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) { + ctx := context.Background() + routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} + calls := make([]string, 0, 4) + + configSvc := &startupRecoveryFakeConfigService{ + calls: &calls, + routes: routes, + } + containerSvc := &startupRecoveryFakeContainerService{ + calls: &calls, + syncErr: assert.AnError, + } + + assert.NotPanics(t, func() { + syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) + }) + + assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) + assert.Equal(t, routes, containerSvc.autoStartRoutes) + assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) + assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) +} + +func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) { + ctx := context.Background() + routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} + calls := make([]string, 0, 4) + + configSvc := &startupRecoveryFakeConfigService{ + calls: &calls, + routes: routes, + } + containerSvc := &startupRecoveryFakeContainerService{ + calls: &calls, + autoStartErr: assert.AnError, + } + + assert.NotPanics(t, func() { + syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) + }) + + assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) + assert.Equal(t, routes, containerSvc.autoStartRoutes) + assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) + assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) +} diff --git a/internal/boundaries/out/mocks/mock_container_runtime.go b/internal/boundaries/out/mocks/mock_container_runtime.go index 4a861e7f..6c5ec9d4 100644 --- a/internal/boundaries/out/mocks/mock_container_runtime.go +++ b/internal/boundaries/out/mocks/mock_container_runtime.go @@ -428,69 +428,6 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn return _c } -// EnsureContainerRestartPolicy provides a mock function for the type MockContainerRuntime -func (_mock *MockContainerRuntime) EnsureContainerRestartPolicy(ctx context.Context, containerID string, policy string) error { - ret := _mock.Called(ctx, containerID, policy) - - if len(ret) == 0 { - panic("no return value specified for EnsureContainerRestartPolicy") - } - - var r0 error - if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { - r0 = returnFunc(ctx, containerID, policy) - } else { - r0 = ret.Error(0) - } - return r0 -} - -// MockContainerRuntime_EnsureContainerRestartPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureContainerRestartPolicy' -type MockContainerRuntime_EnsureContainerRestartPolicy_Call struct { - *mock.Call -} - -// EnsureContainerRestartPolicy is a helper method to define mock.On call -// - ctx context.Context -// - containerID string -// - policy string -func (_e *MockContainerRuntime_Expecter) EnsureContainerRestartPolicy(ctx interface{}, containerID interface{}, policy interface{}) *MockContainerRuntime_EnsureContainerRestartPolicy_Call { - return &MockContainerRuntime_EnsureContainerRestartPolicy_Call{Call: _e.mock.On("EnsureContainerRestartPolicy", ctx, containerID, policy)} -} - -func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Run(run func(ctx context.Context, containerID string, policy string)) *MockContainerRuntime_EnsureContainerRestartPolicy_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 string - if args[1] != nil { - arg1 = args[1].(string) - } - var arg2 string - if args[2] != nil { - arg2 = args[2].(string) - } - run( - arg0, - arg1, - arg2, - ) - }) - return _c -} - -func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Return(err error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call { - _c.Call.Return(err) - return _c -} - -func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) RunAndReturn(run func(ctx context.Context, containerID string, policy string) error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call { - _c.Call.Return(run) - return _c -} - // ExecInContainer provides a mock function for the type MockContainerRuntime func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { ret := _mock.Called(ctx, containerID, cmd) diff --git a/internal/boundaries/out/runtime.go b/internal/boundaries/out/runtime.go index cda38822..e424ce5d 100644 --- a/internal/boundaries/out/runtime.go +++ b/internal/boundaries/out/runtime.go @@ -20,7 +20,6 @@ type ContainerRuntime interface { RestartContainer(ctx context.Context, containerID string) error RemoveContainer(ctx context.Context, containerID string, force bool) error RenameContainer(ctx context.Context, containerID, newName string) error - EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error // Container inspection ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) diff --git a/internal/domain/registry_image.go b/internal/domain/registry_image.go new file mode 100644 index 00000000..22999d3e --- /dev/null +++ b/internal/domain/registry_image.go @@ -0,0 +1,133 @@ +package domain + +import "strings" + +// KnownGordonRegistryDomains returns the normalized set of Gordon-managed +// registry domains, with the current domain first followed by legacy domains. +// Empty values are ignored, trailing slashes are removed, and duplicates are +// removed while preserving first-seen order. +func KnownGordonRegistryDomains(current string, legacy []string) []string { + domains := make([]string, 0, 1+len(legacy)) + seen := make(map[string]struct{}, 1+len(legacy)) + + add := func(domain string) { + normalized := normalizeRegistryDomain(domain) + if normalized == "" { + return + } + + key := strings.ToLower(normalized) + if _, ok := seen[key]; ok { + return + } + + seen[key] = struct{}{} + domains = append(domains, normalized) + } + + add(current) + for _, domain := range legacy { + add(domain) + } + + return domains +} + +// StripKnownGordonRegistry removes the Gordon-managed registry host prefix from +// an image reference when it matches the current or a legacy Gordon registry +// domain. Bare references and external registries are returned unchanged. +func StripKnownGordonRegistry(imageRef string, current string, legacy []string) string { + host, remainder, ok := splitRegistryImageRef(imageRef) + if !ok { + return imageRef + } + if !isKnownGordonRegistryDomain(host, current, legacy) { + return imageRef + } + return remainder +} + +// ExtractGordonRepoName strips a known Gordon registry host, then removes any +// tag or digest suffix, returning the repository name portion of the image +// reference. +func ExtractGordonRepoName(imageRef string, current string, legacy []string) string { + repo := StripKnownGordonRegistry(imageRef, current, legacy) + + if idx := strings.Index(repo, "@"); idx != -1 { + repo = repo[:idx] + } + if idx := strings.LastIndex(repo, ":"); idx != -1 { + slashIdx := strings.LastIndex(repo, "/") + if idx > slashIdx { + repo = repo[:idx] + } + } + + return repo +} + +// CanonicalizeGordonImageRef rewrites Gordon-managed image references to use +// the current Gordon registry domain. External registries and bare references +// are returned unchanged. If the current domain is empty, the original image +// reference is returned unchanged. +func CanonicalizeGordonImageRef(imageRef, current string, legacy []string) string { + currentDomain := normalizeRegistryDomain(current) + if currentDomain == "" { + return imageRef + } + + host, remainder, ok := splitRegistryImageRef(imageRef) + if !ok { + return imageRef + } + if !isKnownGordonRegistryDomain(host, currentDomain, legacy) { + return imageRef + } + + return currentDomain + "/" + remainder +} + +// IsGordonRegistryImageRef reports whether an image reference is explicitly +// qualified with the current or a legacy Gordon registry domain. +func IsGordonRegistryImageRef(imageRef, current string, legacy []string) bool { + host, _, ok := splitRegistryImageRef(imageRef) + if !ok { + return false + } + return isKnownGordonRegistryDomain(host, current, legacy) +} + +func normalizeRegistryDomain(domain string) string { + domain = strings.TrimSpace(domain) + domain = strings.TrimRight(domain, "/") + return domain +} + +func isKnownGordonRegistryDomain(host, current string, legacy []string) bool { + host = normalizeRegistryDomain(host) + if host == "" { + return false + } + + for _, domain := range KnownGordonRegistryDomains(current, legacy) { + if strings.EqualFold(domain, host) { + return true + } + } + return false +} + +func splitRegistryImageRef(imageRef string) (host string, remainder string, ok bool) { + host, remainder, ok = strings.Cut(imageRef, "/") + if !ok || host == "" || remainder == "" { + return "", "", false + } + if !looksLikeRegistryHost(host) { + return "", "", false + } + return host, remainder, true +} + +func looksLikeRegistryHost(host string) bool { + return strings.Contains(host, ".") || strings.Contains(host, ":") || strings.EqualFold(host, "localhost") +} diff --git a/internal/domain/registry_image_test.go b/internal/domain/registry_image_test.go new file mode 100644 index 00000000..38dd028c --- /dev/null +++ b/internal/domain/registry_image_test.go @@ -0,0 +1,127 @@ +package domain_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/bnema/gordon/internal/domain" +) + +func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) { + got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{ + "", + " old-registry.example.com/ ", + "old-registry.example.com:5000///", + "new-registry.example.com", + "old-registry.example.com", + }) + + want := []string{ + "new-registry.example.com", + "old-registry.example.com", + "old-registry.example.com:5000", + } + + assert.Equal(t, want, got) +} + +func TestRegistryImageStripKnownGordonRegistry(t *testing.T) { + current := "new-registry.example.com/" + legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"} + + tests := []struct { + name string + imageRef string + want string + }{ + {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"}, + {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"}, + {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"}, + {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"}, + {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"}, + {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"}, + {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRegistryImageExtractGordonRepoName(t *testing.T) { + current := "new-registry.example.com/" + legacy := []string{"old-registry.example.com/"} + + tests := []struct { + name string + imageRef string + want string + }{ + {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"}, + {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"}, + {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"}, + {name: "bare image", imageRef: "app:latest", want: "app"}, + {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, + {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) { + legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} + + tests := []struct { + name string + imageRef string + current string + want string + }{ + {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, + {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, + {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"}, + {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"}, + {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) { + current := "new-registry.example.com/" + legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} + + tests := []struct { + name string + imageRef string + want bool + }{ + {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true}, + {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true}, + {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true}, + {name: "bare ref", imageRef: "app:latest", want: false}, + {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false}, + {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/usecase/auto/validation.go b/internal/usecase/auto/validation.go index 7d14ccba..6d72336f 100644 --- a/internal/usecase/auto/validation.go +++ b/internal/usecase/auto/validation.go @@ -1,6 +1,10 @@ package auto -import "strings" +import ( + "strings" + + "github.com/bnema/gordon/internal/domain" +) // MatchesDomainAllowlist reports whether domain matches any of the given patterns. // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to @@ -36,23 +40,9 @@ func MatchesDomainAllowlist(domain string, patterns []string) bool { return false } -// ExtractRepoName strips the registry domain, tag, and digest from an image -// reference and returns the bare repository name (e.g. "org/myapp"). -func ExtractRepoName(imageRef, registryDomain string) string { - imageRef = strings.ToLower(imageRef) - if idx := strings.Index(imageRef, "@"); idx != -1 { - imageRef = imageRef[:idx] - } - if idx := strings.LastIndex(imageRef, ":"); idx != -1 { - slashIdx := strings.LastIndex(imageRef, "/") - if idx > slashIdx { - imageRef = imageRef[:idx] - } - } - registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/")) - if registryPrefix != "" { - prefix := registryPrefix + "/" - imageRef = strings.TrimPrefix(imageRef, prefix) - } - return imageRef +// ExtractRepoName strips the current or legacy Gordon registry domain, tag, +// and digest from an image reference and returns the bare repository name +// (e.g. "org/myapp"). +func ExtractRepoName(imageRef, registryDomain string, legacyRegistryDomains ...string) string { + return strings.ToLower(domain.ExtractGordonRepoName(imageRef, registryDomain, legacyRegistryDomains)) } diff --git a/internal/usecase/auto/validation_test.go b/internal/usecase/auto/validation_test.go index fb605fbf..8cf36a6f 100644 --- a/internal/usecase/auto/validation_test.go +++ b/internal/usecase/auto/validation_test.go @@ -57,3 +57,19 @@ func TestExtractRepoName(t *testing.T) { }) } } + +func TestExtractRepoName_TreatsLegacyAndCurrentGordonRegistryHostsAsSameRepo(t *testing.T) { + current := "new-registry.example.com" + legacy := []string{"old-registry.example.com"} + + oldRepo := ExtractRepoName("old-registry.example.com/app:latest", current, legacy...) + newRepo := ExtractRepoName("new-registry.example.com/app:latest", current, legacy...) + + assert.Equal(t, "app", oldRepo) + assert.Equal(t, oldRepo, newRepo) +} + +func TestExtractRepoName_HandlesPortsAndDigests(t *testing.T) { + assert.Equal(t, "org/app", ExtractRepoName("new-registry.example.com:5000/org/app:latest", "new-registry.example.com:5000")) + assert.Equal(t, "org/app", ExtractRepoName("old-registry.example.com:5001/org/app@sha256:deadbeef", "new-registry.example.com:5000", "old-registry.example.com:5001")) +} diff --git a/internal/usecase/config/service.go b/internal/usecase/config/service.go index 438cd8a6..80090377 100644 --- a/internal/usecase/config/service.go +++ b/internal/usecase/config/service.go @@ -30,6 +30,7 @@ type Config struct { ServerPort int RegistryPort int RegistryDomain string + LegacyRegistryDomains []string DataDir string AutoRouteEnabled bool AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"` @@ -171,10 +172,13 @@ func (s *Service) loadConfigValues() Config { previewSep = "--" } + legacyRegistryDomains := append([]string{}, s.viper.GetStringSlice("server.legacy_registry_domains")...) + return Config{ ServerPort: s.viper.GetInt("server.port"), RegistryPort: s.viper.GetInt("server.registry_port"), RegistryDomain: registryDomain, + LegacyRegistryDomains: legacyRegistryDomains, DataDir: s.viper.GetString("server.data_dir"), AutoRouteEnabled: autoEnabled, AutoRouteAllowedDomains: append([]string{}, allowedDomains...), @@ -423,7 +427,7 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai if strings.HasPrefix(domainName, "http://") { continue } - if matchesImageName(imageName, route.Image, s.config.RegistryDomain) { + if matchesImageName(imageName, route.Image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) { routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS}) } } @@ -439,7 +443,7 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri var targets []string for target, images := range s.config.Attachments { for _, image := range images { - if matchesImageName(imageName, image, s.config.RegistryDomain) { + if matchesImageName(imageName, image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) { targets = append(targets, target) break } @@ -449,10 +453,10 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri return targets } -func matchesImageName(inputImage, candidateImage, registryDomain string) bool { - normalizedInput := NormalizeRegistryImage(inputImage, registryDomain) +func matchesImageName(inputImage, candidateImage, registryDomain string, legacyRegistryDomains []string) bool { + normalizedInput := NormalizeRegistryImage(inputImage, registryDomain, legacyRegistryDomains) inputName, inputHasTag := splitImageNameTag(normalizedInput) - normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain) + normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain, legacyRegistryDomains) if inputHasTag { return strings.EqualFold(normalizedCandidate, normalizedInput) @@ -470,19 +474,10 @@ func splitImageNameTag(image string) (name string, hasTag bool) { return image, false } -// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. -func NormalizeRegistryImage(imageName, registryDomain string) string { - registryDomain = strings.TrimSuffix(registryDomain, "/") - if registryDomain == "" { - return imageName - } - - prefix := registryDomain + "/" - if strings.HasPrefix(imageName, prefix) { - return strings.TrimPrefix(imageName, prefix) - } - - return imageName +// NormalizeRegistryImage strips the current or legacy Gordon registry domain +// prefix from an image name for comparison. +func NormalizeRegistryImage(imageName, registryDomain string, legacyRegistryDomains []string) string { + return domain.StripKnownGordonRegistry(imageName, registryDomain, legacyRegistryDomains) } // NormalizeBootstrapImage converts a user-supplied image argument into @@ -816,7 +811,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) delete(configMap, "routes") configMap["external_routes"] = snapshotConfig.ExternalRoutes - configMap["attachments"] = snapshotConfig.Attachments + configMap["attachments"] = canonicalAttachmentsForSave(snapshotConfig.Attachments, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains) configMap["network_groups"] = snapshotConfig.NetworkGroups applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains) @@ -825,7 +820,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) return fmt.Errorf("failed to marshal config: %w", err) } - routes, err := canonicalRoutesForSave(snapshotConfig.Routes) + routes, err := canonicalRoutesForSave(snapshotConfig.Routes, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains) if err != nil { return err } @@ -879,12 +874,45 @@ func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) { } } -func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) { +func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain string, legacyRegistryDomains []string) map[string][]string { + if attachments == nil { + return nil + } + + result := make(map[string][]string, len(attachments)) + for target, images := range attachments { + if images == nil { + result[target] = nil + continue + } + + canonicalImages := make([]string, 0, len(images)) + seen := make(map[string]struct{}, len(images)) + for _, image := range images { + canonicalImage := domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) + if _, ok := seen[canonicalImage]; ok { + continue + } + seen[canonicalImage] = struct{}{} + canonicalImages = append(canonicalImages, canonicalImage) + } + result[target] = canonicalImages + } + + return result +} + +func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) { result := make(map[string]routeConfig) if routes == nil { return result, nil } + canonicalizeRoute := func(route routeConfig) routeConfig { + route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains) + return route + } + for key, route := range routes { if strings.HasPrefix(key, "http://") { continue @@ -892,7 +920,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf if !domain.IsValidRouteDomain(key) { return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid) } - result[key] = route + result[key] = canonicalizeRoute(route) } for key, route := range routes { @@ -908,7 +936,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf continue } route.HTTPS = false - result[domainName] = route + result[domainName] = canonicalizeRoute(route) } return result, nil @@ -1121,6 +1149,13 @@ func (s *Service) GetRegistryDomain() string { return s.config.RegistryDomain } +// GetLegacyRegistryDomains returns the configured legacy registry domains. +func (s *Service) GetLegacyRegistryDomains() []string { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]string{}, s.config.LegacyRegistryDomains...) +} + // GetDataDir returns the configured data directory. func (s *Service) GetDataDir() string { s.mu.RLock() @@ -1249,8 +1284,9 @@ func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string // Check if already exists existing := s.config.Attachments[domainOrGroup] + canonicalImage := domain.CanonicalizeGordonImageRef(image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) for _, img := range existing { - if img == image { + if img == image || domain.CanonicalizeGordonImageRef(img, s.config.RegistryDomain, s.config.LegacyRegistryDomains) == canonicalImage { s.mu.Unlock() return domain.ErrAttachmentExists } diff --git a/internal/usecase/config/service_test.go b/internal/usecase/config/service_test.go index 831be414..d6fff036 100644 --- a/internal/usecase/config/service_test.go +++ b/internal/usecase/config/service_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/bnema/zerowrap" @@ -217,6 +218,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test assert.True(t, routes[0].HTTPS) } +func TestCanonicalRoutesForSave(t *testing.T) { + routes := map[string]routeConfig{ + "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true}, + "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true}, + "bare.example.com": {Image: "worker:v2", HTTPS: true}, + "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true}, + "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true}, + } + + savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"}) + require.NoError(t, err) + + assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com")) + assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com")) + assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com")) + assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com")) + assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com")) + + assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image) + assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image) +} + func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") @@ -242,9 +265,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "gordon.toml") - initialConfig := `[routes] -"secure.example.com" = "secure:v1" -"http://insecure.example.com" = "insecure:v1" + initialConfig := `[server] +gordon_domain = "registry.example.com" +legacy_registry_domains = ["old-registry.example.com"] + +[routes] +"secure.example.com" = "old-registry.example.com/secure:v1" +"http://insecure.example.com" = "old-registry.example.com/insecure:v1" ` err := os.WriteFile(configFile, []byte(initialConfig), 0600) require.NoError(t, err) @@ -266,13 +293,147 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { content, err := os.ReadFile(configFile) require.NoError(t, err) text := string(content) - assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`) - assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`) + assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`) + assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`) assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`) assert.NotContains(t, text, "http://insecure.example.com") assert.Contains(t, text, "[routes]") } +func TestService_SaveCanonicalizesRouteImages(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + initialConfig := `[server] +gordon_domain = "registry.example.com" +legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"] + +[routes] +"legacy.example.com" = "old-registry.example.com/app:latest" +"legacy-port.example.com" = "old-registry.example.com:5000/api:v2" +"current.example.com" = "registry.example.com/web:v3" +"bare.example.com" = "worker:v4" +"external.example.com" = "docker.io/library/nginx:latest" +` + err := os.WriteFile(configFile, []byte(initialConfig), 0600) + require.NoError(t, err) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + + require.NoError(t, svc.Load(ctx)) + assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image) + + require.NoError(t, svc.Save(ctx)) + + content, err := os.ReadFile(configFile) + require.NoError(t, err) + text := string(content) + assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`) + assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`) + assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`) + assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`) + assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) +} + +func TestCanonicalAttachmentsForSave(t *testing.T) { + attachments := map[string][]string{ + "app.example.com": { + "old-registry.example.com/postgres:18", + "registry.example.com/postgres:18", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, + } + + saved := canonicalAttachmentsForSave(attachments, "registry.example.com", []string{"old-registry.example.com"}) + + assert.Equal(t, []string{ + "registry.example.com/postgres:18", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, saved["app.example.com"]) + assert.Equal(t, []string{ + "old-registry.example.com/postgres:18", + "registry.example.com/postgres:18", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, attachments["app.example.com"]) +} + +func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + initialConfig := `[server] + gordon_domain = "registry.example.com" + legacy_registry_domains = ["old-registry.example.com"] + + [attachments] + "app.example.com" = [ + "old-registry.example.com/postgres:18", + "registry.example.com/postgres:18", + "registry.example.com/redis:7", + "rabbitmq:3", + "docker.io/library/nginx:latest", + ] + ` + err := os.WriteFile(configFile, []byte(initialConfig), 0600) + require.NoError(t, err) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + + require.NoError(t, svc.Load(ctx)) + require.Equal(t, []string{ + "old-registry.example.com/postgres:18", + "registry.example.com/postgres:18", + "registry.example.com/redis:7", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs") + + require.NoError(t, svc.Save(ctx)) + require.Equal(t, []string{ + "old-registry.example.com/postgres:18", + "registry.example.com/postgres:18", + "registry.example.com/redis:7", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs") + + saved := viper.New() + saved.SetConfigFile(configFile) + require.NoError(t, saved.ReadInConfig()) + + var attachments map[string][]string + require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) + assert.Equal(t, []string{ + "registry.example.com/postgres:18", + "registry.example.com/redis:7", + "rabbitmq:3", + "docker.io/library/nginx:latest", + }, attachments["app.example.com"]) + + content, err := os.ReadFile(configFile) + require.NoError(t, err) + text := string(content) + assert.Contains(t, text, `registry.example.com/postgres:18`) + assert.Equal(t, 1, strings.Count(text, `registry.example.com/postgres:18`)) + assert.Contains(t, text, `registry.example.com/redis:7`) + assert.Contains(t, text, `rabbitmq:3`) + assert.Contains(t, text, `docker.io/library/nginx:latest`) + assert.NotContains(t, text, `old-registry.example.com/postgres:18`) +} + func TestService_Reload(t *testing.T) { t.Run("success - picks up config file changes", func(t *testing.T) { // Create temp config file @@ -1382,6 +1543,103 @@ func TestService_AddAttachment(t *testing.T) { assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) }) + t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + initialConfig := `[server] + gordon_domain = "registry.example.com" + legacy_registry_domains = ["old-registry.example.com"] + + [attachments] + ` + err := os.WriteFile(configFile, []byte(initialConfig), 0600) + require.NoError(t, err) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + require.NoError(t, svc.Load(ctx)) + + err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") + require.NoError(t, err) + + require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) + + saved := viper.New() + saved.SetConfigFile(configFile) + require.NoError(t, saved.ReadInConfig()) + + var attachments map[string][]string + require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) + assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"]) + + content, err := os.ReadFile(configFile) + require.NoError(t, err) + text := string(content) + assert.Contains(t, text, `registry.example.com/postgres:18`) + assert.NotContains(t, text, `old-registry.example.com/postgres:18`) + }) + + t.Run("rejects canonical duplicate when existing legacy ref matches current ref", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + initialConfig := `[server] + gordon_domain = "registry.example.com" + legacy_registry_domains = ["old-registry.example.com"] + + [attachments] + "app.example.com" = ["old-registry.example.com/postgres:18"] + ` + err := os.WriteFile(configFile, []byte(initialConfig), 0600) + require.NoError(t, err) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + require.NoError(t, svc.Load(ctx)) + + err = svc.AddAttachment(ctx, "app.example.com", "registry.example.com/postgres:18") + + assert.ErrorIs(t, err, domain.ErrAttachmentExists) + assert.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) + }) + + t.Run("rejects canonical duplicate when existing current ref matches legacy ref", func(t *testing.T) { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + initialConfig := `[server] + gordon_domain = "registry.example.com" + legacy_registry_domains = ["old-registry.example.com"] + + [attachments] + "app.example.com" = ["registry.example.com/postgres:18"] + ` + err := os.WriteFile(configFile, []byte(initialConfig), 0600) + require.NoError(t, err) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + require.NoError(t, svc.Load(ctx)) + + err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") + + assert.ErrorIs(t, err, domain.ErrAttachmentExists) + assert.Equal(t, []string{"registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) + }) + t.Run("duplicate attachment", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ @@ -1700,23 +1958,25 @@ func TestSplitImageNameTag(t *testing.T) { func TestNormalizeRegistryImage(t *testing.T) { tests := []struct { - name string - imageName string - registryDomain string - expected string + name string + imageName string + registryDomain string + legacyRegistryDomains []string + expected string }{ - {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, - {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, - {"empty registry domain", "myapp:latest", "", "myapp:latest"}, - {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, - {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, - {"bare name no registry", "myapp", "reg.example.com", "myapp"}, - {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, + {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"}, + {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"}, + {"strips legacy registry prefix with explicit port and digest", "old-reg.example.com:5000/myapp@sha256:deadbeef", "new-reg.example.com", []string{"old-reg.example.com:5000"}, "myapp@sha256:deadbeef"}, + {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"}, + {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"}, + {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"}, + {"hostile lookalike is not stripped", "old-reg.example.com.evil/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "old-reg.example.com.evil/myapp:latest"}, + {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) + result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains) assert.Equal(t, tt.expected, result) }) } @@ -1869,6 +2129,24 @@ func TestService_FindRoutesByImage(t *testing.T) { assert.Equal(t, "app.example.com", routes[0].Domain) }) + t.Run("matches current image when route stores legacy registry host", func(t *testing.T) { + v := viper.New() + v.Set("server.gordon_domain", "new-registry.example.com") + v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) + v.Set("routes", map[string]interface{}{ + "app.example.com": "old-registry.example.com/myapp:latest", + }) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + require.NoError(t, svc.Load(ctx)) + + routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest") + assert.Len(t, routes, 1) + assert.Equal(t, "app.example.com", routes[0].Domain) + }) + t.Run("multiple routes for same image", func(t *testing.T) { v := viper.New() v.Set("routes", map[string]interface{}{ @@ -1980,6 +2258,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) { assert.Equal(t, []string{"backend"}, targets) }) + t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) { + v := viper.New() + v.Set("server.gordon_domain", "new-registry.example.com") + v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) + v.Set("attachments", map[string]interface{}{ + "backend": []interface{}{"old-registry.example.com/postgres:18"}, + }) + + eventBus := mocks.NewMockEventPublisher(t) + svc := NewService(v, eventBus) + ctx := testContext() + require.NoError(t, svc.Load(ctx)) + + targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18") + assert.Equal(t, []string{"backend"}, targets) + }) + t.Run("shared attachment used by multiple targets", func(t *testing.T) { v := viper.New() v.Set("attachments", map[string]interface{}{ diff --git a/internal/usecase/container/autoroute.go b/internal/usecase/container/autoroute.go index 07185a4c..4c74b389 100644 --- a/internal/usecase/container/autoroute.go +++ b/internal/usecase/container/autoroute.go @@ -22,15 +22,21 @@ type EnvFileExtractor interface { ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) } +type registryDomainsProvider interface { + GetRegistryDomain() string + GetLegacyRegistryDomains() []string +} + // AutoRouteHandler handles image.pushed events for auto-route from labels. type AutoRouteHandler struct { - configSvc in.ConfigService - containerSvc in.ContainerService - blobStorage out.BlobStorage - extractor EnvFileExtractor - registryDomain string - envDir string - ctx context.Context + configSvc in.ConfigService + containerSvc in.ContainerService + blobStorage out.BlobStorage + extractor EnvFileExtractor + registryDomain string + legacyRegistryDomains []string + envDir string + ctx context.Context } // NewAutoRouteHandler creates a new AutoRouteHandler. @@ -40,13 +46,15 @@ func NewAutoRouteHandler( containerSvc in.ContainerService, blobStorage out.BlobStorage, registryDomain string, + legacyRegistryDomains ...string, ) *AutoRouteHandler { return &AutoRouteHandler{ - configSvc: configSvc, - containerSvc: containerSvc, - blobStorage: blobStorage, - registryDomain: registryDomain, - ctx: ctx, + configSvc: configSvc, + containerSvc: containerSvc, + blobStorage: blobStorage, + registryDomain: registryDomain, + legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...), + ctx: ctx, } } @@ -130,15 +138,24 @@ func (h *AutoRouteHandler) buildImageName(name, reference string) string { return fmt.Sprintf("%s:%s", name, reference) } +func (h *AutoRouteHandler) registryDomains() (string, []string) { + provider, ok := h.configSvc.(registryDomainsProvider) + if !ok { + return h.registryDomain, append([]string(nil), h.legacyRegistryDomains...) + } + return provider.GetRegistryDomain(), provider.GetLegacyRegistryDomains() +} + // buildFullImageRef constructs a fully qualified image reference with registry domain. // This is needed for Docker operations since images are stored with the registry prefix. func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { - if h.registryDomain == "" { + registryDomain, _ := h.registryDomains() + if registryDomain == "" { return imageName } // Don't prefix if already has registry domain - if strings.HasPrefix(imageName, h.registryDomain+"/") { + if strings.HasPrefix(imageName, registryDomain+"/") { return imageName } @@ -151,7 +168,7 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { return imageName } - return fmt.Sprintf("%s/%s", h.registryDomain, imageName) + return fmt.Sprintf("%s/%s", registryDomain, imageName) } // processRoutes processes each domain and creates/updates routes. @@ -208,11 +225,12 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, HTTPS: true, } + registryDomain, legacyRegistryDomains := h.registryDomains() existingRoutes := h.configSvc.GetRoutes(ctx) for _, existing := range existingRoutes { if existing.Domain == routeDomain { route.HTTPS = existing.HTTPS - if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) { + if auto.ExtractRepoName(existing.Image, registryDomain, legacyRegistryDomains...) != auto.ExtractRepoName(imageName, registryDomain, legacyRegistryDomains...) { log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") return false } diff --git a/internal/usecase/container/autoroute_test.go b/internal/usecase/container/autoroute_test.go index 5a112123..0836318c 100644 --- a/internal/usecase/container/autoroute_test.go +++ b/internal/usecase/container/autoroute_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/bnema/zerowrap" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -18,6 +19,7 @@ import ( "github.com/bnema/gordon/internal/boundaries/out/mocks" "github.com/bnema/gordon/internal/domain" "github.com/bnema/gordon/internal/usecase/auto" + configusecase "github.com/bnema/gordon/internal/usecase/config" ) // domainToEnvFileName tests @@ -829,6 +831,77 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) { assert.False(t, created) } +func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) { + ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) + configSvc := inmocks.NewMockConfigService(t) + containerSvc := inmocks.NewMockContainerService(t) + blobStorage := mocks.NewMockBlobStorage(t) + handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") + + configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ + Domain: "app.example.com", + Image: "old-registry.example.com/app:v1", + HTTPS: true, + }}) + configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{ + Domain: "app.example.com", + Image: "new-registry.example.com/app:v2", + HTTPS: true, + }).Return(nil) + containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ + Domain: "app.example.com", + Image: "new-registry.example.com/app:v2", + HTTPS: true, + }).Return(&domain.Container{ID: "c1"}, nil) + + created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) + assert.False(t, created) +} + +func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) { + ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "gordon.toml") + require.NoError(t, os.WriteFile(configFile, []byte(`[server] + gordon_domain = "old-registry.example.com" + + [routes] + "app.example.com" = "old-registry.example.com/app:v1" + `), 0600)) + + v := viper.New() + v.SetConfigFile(configFile) + require.NoError(t, v.ReadInConfig()) + + configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t)) + require.NoError(t, configSvc.Load(ctx)) + + containerSvc := inmocks.NewMockContainerService(t) + handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com") + + require.NoError(t, os.WriteFile(configFile, []byte(`[server] + gordon_domain = "new-registry.example.com" + legacy_registry_domains = ["old-registry.example.com"] + + [routes] + "app.example.com" = "old-registry.example.com/app:v1" + `), 0600)) + require.NoError(t, configSvc.Reload(ctx)) + + containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ + Domain: "app.example.com", + Image: "new-registry.example.com/app:v2", + HTTPS: true, + }).Return(&domain.Container{ID: "c1"}, nil) + + created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) + assert.False(t, created) + + route, err := configSvc.GetRoute(ctx, "app.example.com") + require.NoError(t, err) + assert.Equal(t, "new-registry.example.com/app:v2", route.Image) +} + func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) configSvc := inmocks.NewMockConfigService(t) @@ -842,6 +915,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { assert.False(t, created) } +func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) { + ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) + configSvc := inmocks.NewMockConfigService(t) + containerSvc := inmocks.NewMockContainerService(t) + blobStorage := mocks.NewMockBlobStorage(t) + handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") + + configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ + Domain: "app.example.com", + Image: "old-registry.example.com/oldapp:v1", + HTTPS: true, + }}) + + created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"}) + assert.False(t, created) +} + func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) { ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) configSvc := inmocks.NewMockConfigService(t) diff --git a/internal/usecase/container/service.go b/internal/usecase/container/service.go index f17dde71..ea656d58 100644 --- a/internal/usecase/container/service.go +++ b/internal/usecase/container/service.go @@ -33,6 +33,7 @@ import ( type Config struct { RegistryAuthEnabled bool RegistryDomain string + LegacyRegistryDomains []string RegistryPort int ServiceTokenUsername string ServiceToken string @@ -1333,21 +1334,16 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo return results } -// stripRegistryPrefix removes the configured registry domain prefix from an image reference. -// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com". +// stripRegistryPrefix removes the configured current or legacy Gordon registry +// domain prefix from an image reference. For example, +// "reg.example.com/myapp:latest" becomes "myapp:latest" when +// "reg.example.com" is a current or legacy Gordon registry domain. func (s *Service) stripRegistryPrefix(image string) string { s.mu.RLock() cfg := s.config s.mu.RUnlock() - if cfg.RegistryDomain == "" { - return image - } - prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/" - if strings.HasPrefix(image, prefix) { - return strings.TrimPrefix(image, prefix) - } - return image + return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains) } // ListAttachments returns attachments for a domain. @@ -1405,41 +1401,6 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool { return health } -// EnsureManagedContainerRestartPolicies updates restart policy for all managed containers. -func (s *Service) EnsureManagedContainerRestartPolicies(ctx context.Context) error { - ctx = zerowrap.CtxWithFields(ctx, map[string]any{ - zerowrap.FieldLayer: "usecase", - zerowrap.FieldUseCase: "EnsureManagedContainerRestartPolicies", - }) - log := zerowrap.FromCtx(ctx) - - allContainers, err := s.runtime.ListContainers(ctx, true) - if err != nil { - return log.WrapErr(err, "failed to list containers for restart policy migration") - } - - return s.ensureManagedContainerRestartPolicies(ctx, allContainers) -} - -func (s *Service) ensureManagedContainerRestartPolicies(ctx context.Context, allContainers []*domain.Container) error { - log := zerowrap.FromCtx(ctx) - - var errs []error - for _, c := range allContainers { - if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { - continue - } - if err := s.runtime.EnsureContainerRestartPolicy(ctx, c.ID, domain.RestartPolicyAlways); err != nil { - log.Warn().Err(err). - Str(zerowrap.FieldEntityID, c.ID). - Str("container_name", c.Name). - Msg("failed to ensure managed container restart policy") - errs = append(errs, fmt.Errorf("container %s: %w", c.ID, err)) - } - } - return errors.Join(errs...) -} - // SyncContainers synchronizes containers with runtime state. func (s *Service) SyncContainers(ctx context.Context) error { ctx = zerowrap.CtxWithFields(ctx, map[string]any{ @@ -1448,9 +1409,9 @@ func (s *Service) SyncContainers(ctx context.Context) error { }) log := zerowrap.FromCtx(ctx) - // List containers without holding lock to avoid blocking during Docker API call. - // Use all=true so the same runtime snapshot can migrate stopped containers and - // rebuild the running-container maps without a second Docker list call. + // List containers without holding lock to avoid blocking during the runtime call. + // Use all=true so we can rebuild the running-container maps from a single + // snapshot without a second list call. allContainers, err := s.runtime.ListContainers(ctx, true) if err != nil { return log.WrapErr(err, "failed to list containers") @@ -1823,7 +1784,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist return fmt.Errorf("image %q rejected: invalid image reference", imageRef) } - if sameRegistry(registry, cfg.RegistryDomain) { + if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) { return nil } dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry)) @@ -1975,7 +1936,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string s.mu.RLock() cfg := s.config s.mu.RUnlock() - return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true + return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true } // ensureImage ensures the image is available locally, pulling if needed. @@ -3360,21 +3321,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string { return prefix + imageRef } -// rewriteToLocalRegistry rewrites an image reference to use the local registry address. -// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest" -func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string { +// rewriteToLocalRegistry rewrites Gordon-managed image references to use the +// local registry address. Current and legacy Gordon registry domains are +// stripped before prefixing localhost:/. Bare refs are prefixed. +// External registries are preserved unchanged. +func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string { if imageRef == "" { return imageRef } localRegistry := fmt.Sprintf("localhost:%d", registryPort) localPrefix := localRegistry + "/" - imageRef = strings.TrimPrefix(imageRef, localPrefix) + if strings.HasPrefix(imageRef, localPrefix) { + return imageRef + } - registryDomain = strings.TrimSuffix(registryDomain, "/") - if registryDomain != "" { - prefix := registryDomain + "/" - imageRef = strings.TrimPrefix(imageRef, prefix) + if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { + return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains) + } + if hasExplicitRegistry(imageRef) { + return imageRef } return localPrefix + imageRef diff --git a/internal/usecase/container/service_test.go b/internal/usecase/container/service_test.go index 3bf2bd44..0e67b92e 100644 --- a/internal/usecase/container/service_test.go +++ b/internal/usecase/container/service_test.go @@ -1237,97 +1237,6 @@ func TestService_HealthCheck(t *testing.T) { assert.False(t, result["unhealthy.example.com"]) } -func TestService_EnsureManagedContainerRestartPolicies_EnsuresRoutesAttachmentsAndStoppedContainers(t *testing.T) { - runtime := mocks.NewMockContainerRuntime(t) - envLoader := mocks.NewMockEnvLoader(t) - eventBus := mocks.NewMockEventPublisher(t) - - svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil) - ctx := testContext() - - routeContainer := &domain.Container{ - ID: "route-1", - Name: "gordon-app.example.com", - Status: string(domain.ContainerStatusRunning), - Labels: map[string]string{ - domain.LabelManaged: "true", - domain.LabelDomain: "app.example.com", - }, - } - attachmentContainer := &domain.Container{ - ID: "attachment-1", - Name: "gordon-app-example-com-postgres", - Status: string(domain.ContainerStatusRunning), - Labels: map[string]string{ - domain.LabelManaged: "true", - domain.LabelAttachment: "true", - domain.LabelAttachedTo: "app.example.com", - }, - } - stoppedContainer := &domain.Container{ - ID: "stopped-1", - Name: "gordon-stopped.example.com", - Status: string(domain.ContainerStatusExited), - Labels: map[string]string{ - domain.LabelManaged: "true", - domain.LabelDomain: "stopped.example.com", - }, - } - unmanagedContainer := &domain.Container{ - ID: "other-1", - Labels: map[string]string{}, - } - - runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ - routeContainer, - attachmentContainer, - stoppedContainer, - unmanagedContainer, - }, nil).Once() - runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(nil).Once() - runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "attachment-1", domain.RestartPolicyAlways).Return(nil).Once() - runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "stopped-1", domain.RestartPolicyAlways).Return(nil).Once() - err := svc.EnsureManagedContainerRestartPolicies(ctx) - - require.NoError(t, err) - runtime.AssertNumberOfCalls(t, "EnsureContainerRestartPolicy", 3) -} - -func TestService_EnsureManagedContainerRestartPolicies_ContinuesAndReturnsError(t *testing.T) { - runtime := mocks.NewMockContainerRuntime(t) - envLoader := mocks.NewMockEnvLoader(t) - eventBus := mocks.NewMockEventPublisher(t) - - svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil) - ctx := testContext() - - routeContainer := &domain.Container{ - ID: "route-1", - Name: "gordon-app.example.com", - Labels: map[string]string{ - domain.LabelManaged: "true", - domain.LabelDomain: "app.example.com", - }, - } - routeContainer2 := &domain.Container{ - ID: "route-2", - Name: "gordon-api.example.com", - Labels: map[string]string{ - domain.LabelManaged: "true", - domain.LabelDomain: "api.example.com", - }, - } - - runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{routeContainer, routeContainer2}, nil).Once() - runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(errors.New("update failed")).Once() - runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-2", domain.RestartPolicyAlways).Return(nil).Once() - err := svc.EnsureManagedContainerRestartPolicies(ctx) - - require.Error(t, err) - assert.Contains(t, err.Error(), "route-1") - assert.Contains(t, err.Error(), "update failed") -} - func TestService_SyncContainers(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) envLoader := mocks.NewMockEnvLoader(t) @@ -1992,11 +1901,12 @@ func TestRewriteToRegistryDomain(t *testing.T) { func TestRewriteToLocalRegistry(t *testing.T) { tests := []struct { - name string - imageRef string - registryDomain string - registryPort int - wantRef string + name string + imageRef string + registryDomain string + legacyRegistryDomains []string + registryPort int + wantRef string }{ { name: "rewrites registry domain prefix", @@ -2020,17 +1930,42 @@ func TestRewriteToLocalRegistry(t *testing.T) { wantRef: "localhost:5000/myapp:latest", }, { - name: "prefixes external image path", - imageRef: "docker.io/library/nginx:latest", - registryDomain: "registry.example.com", - registryPort: 5000, - wantRef: "localhost:5000/docker.io/library/nginx:latest", + name: "rewrites legacy registry domain prefix", + imageRef: "old-registry.example.com/myapp:latest", + registryDomain: "new-registry.example.com", + legacyRegistryDomains: []string{"old-registry.example.com"}, + registryPort: 5000, + wantRef: "localhost:5000/myapp:latest", + }, + { + name: "rewrites legacy registry domain prefix with explicit port and digest", + imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef", + registryDomain: "new-registry.example.com", + legacyRegistryDomains: []string{"old-registry.example.com:5001"}, + registryPort: 5000, + wantRef: "localhost:5000/myapp@sha256:deadbeef", + }, + { + name: "external image remains external", + imageRef: "docker.io/library/nginx:latest", + registryDomain: "new-registry.example.com", + legacyRegistryDomains: []string{"old-registry.example.com"}, + registryPort: 5000, + wantRef: "docker.io/library/nginx:latest", + }, + { + name: "hostile lookalike remains external", + imageRef: "old-registry.example.com.evil/myapp:latest", + registryDomain: "new-registry.example.com", + legacyRegistryDomains: []string{"old-registry.example.com"}, + registryPort: 5000, + wantRef: "old-registry.example.com.evil/myapp:latest", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort) + gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort) assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference") }) } @@ -2116,6 +2051,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { assert.Equal(t, "container-123", result.ID) } +func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) { + runtime := mocks.NewMockContainerRuntime(t) + envLoader := mocks.NewMockEnvLoader(t) + eventBus := mocks.NewMockEventPublisher(t) + + config := Config{ + AllowedRegistries: []string{"docker.io"}, + RegistryAuthEnabled: true, + RegistryDomain: "new-registry.example.com", + LegacyRegistryDomains: []string{"old-registry.example.com"}, + RegistryPort: 5000, + InternalRegistryUsername: "internal", + InternalRegistryPassword: "secret", + ReadinessDelay: time.Millisecond, + DrainDelay: time.Millisecond, + DrainDelayConfigured: true, + } + + svc := NewService(runtime, envLoader, eventBus, nil, config, nil) + ctx := domain.WithInternalDeploy(testContext()) + + route := domain.Route{ + Domain: "test.example.com", + Image: "old-registry.example.com/myapp:latest", + } + + runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) + runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) + runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil) + runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil) + runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) + + runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil) + runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil) + envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) + runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil) + + createdContainer := &domain.Container{ + ID: "container-legacy", + Name: "gordon-test.example.com", + Status: "created", + } + runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) + runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil) + runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2) + runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{ + ID: "container-legacy", + Name: "gordon-test.example.com", + Status: "running", + Ports: []int{8080}, + }, nil) + eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) + + result, err := svc.Deploy(ctx, route) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "container-legacy", result.ID) +} + func TestService_AutoStart_StartsNewContainers(t *testing.T) { runtime := mocks.NewMockContainerRuntime(t) envLoader := mocks.NewMockEnvLoader(t) @@ -2609,10 +2604,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) func TestService_StripRegistryPrefix(t *testing.T) { tests := []struct { - name string - registryDomain string - image string - expected string + name string + registryDomain string + legacyRegistryDomains []string + image string + expected string }{ { name: "strips registry prefix", @@ -2626,6 +2622,13 @@ func TestService_StripRegistryPrefix(t *testing.T) { image: "reg.example.com/myapp:v1.0", expected: "myapp:v1.0", }, + { + name: "strips legacy registry prefix", + registryDomain: "new-reg.example.com", + legacyRegistryDomains: []string{"old-reg.example.com"}, + image: "old-reg.example.com/myapp:latest", + expected: "myapp:latest", + }, { name: "preserves image without registry prefix", registryDomain: "reg.example.com", @@ -2659,7 +2662,8 @@ func TestService_StripRegistryPrefix(t *testing.T) { eventBus := mocks.NewMockEventPublisher(t) config := Config{ - RegistryDomain: tt.registryDomain, + RegistryDomain: tt.registryDomain, + LegacyRegistryDomains: tt.legacyRegistryDomains, } svc := NewService(runtime, envLoader, eventBus, nil, config, nil)