detached ec88514

Harden registry rename and startup recovery

commit
ec88514f169f6edd32c11fa6959c3dfb0babc19b
author
brice · · 2026-05-10 13:12 UTC
parent
ad19cc0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +1062 -157
M docs/cli/serve.md +21 -8
@@ -70,7 +70,7 @@ Gordon responds to these signals:
7070 | `SIGTERM` | Graceful shutdown |
7171 | `SIGINT` | Graceful shutdown (Ctrl+C) |
7272 | `SIGUSR1` | Reload configuration |
73-| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |
73+| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |
7474
7575 ### Running with systemd
7676
@@ -106,8 +106,20 @@ sudo loginctl enable-linger $USER
106106 6. Initialize services (registry, proxy, auth)
107107 7. Register event handlers
108108 8. Start config file watcher
109-9. Sync existing containers
110-10. Start HTTP servers
109+9. Start HTTP servers
110+10. Run best-effort startup recovery (sync existing containers, then recover configured routes)
111+
112+### Startup Recovery
113+
114+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.
115+
116+1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
117+2. **Recover configured routes** - Gordon starts any configured route that has no running container.
118+3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
119+
120+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.
121+
122+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 and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed.
111123
112124 ### Shutdown Sequence
113125
@@ -177,14 +189,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177189
178190 ### Description
179191
180-**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
192+**Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery.
181193
182-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
194+**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.
183195
184-Both modes trigger:
196+Both modes use Gordon's internal deploy path:
185197
186-- Fresh image pull (always pulls latest, ignoring cache)
187-- Container redeployment for the specified route
198+- Fresh image content is pulled for the route
199+- The specified route is redeployed
200+- Configured readiness checks and drain behavior apply when needed
188201
189202 ### Examples
190203
@@ -70,7 +70,7 @@ Gordon responds to these signals:
70 | `SIGTERM` | Graceful shutdown |70 | `SIGTERM` | Graceful shutdown |
71 | `SIGINT` | Graceful shutdown (Ctrl+C) |71 | `SIGINT` | Graceful shutdown (Ctrl+C) |
72 | `SIGUSR1` | Reload configuration |72 | `SIGUSR1` | Reload configuration |
73-| `SIGUSR2` | Manual deploy (used by `gordon deploy`) |73+| `SIGUSR2` | Manual deploy request (used by local `gordon deploy`) |
74 74
75 ### Running with systemd75 ### Running with systemd
76 76
@@ -106,8 +106,20 @@ sudo loginctl enable-linger $USER
106 6. Initialize services (registry, proxy, auth)106 6. Initialize services (registry, proxy, auth)
107 7. Register event handlers107 7. Register event handlers
108 8. Start config file watcher108 8. Start config file watcher
109-9. Sync existing containers109+9. Start HTTP servers
110-10. Start HTTP servers110+10. Run best-effort startup recovery (sync existing containers, then recover configured routes)
111+
112+### Startup Recovery
113+
114+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.
115+
116+1. **Sync existing containers** - Gordon reconciles runtime state with configured routes.
117+2. **Recover configured routes** - Gordon starts any configured route that has no running container.
118+3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass.
119+
120+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.
121+
122+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 and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed.
111 123
112 ### Shutdown Sequence124 ### Shutdown Sequence
113 125
@@ -177,14 +189,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177 189
178 ### Description190 ### Description
179 191
180-**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.192+**Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery.
181 193
182-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.194+**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.
183 195
184-Both modes trigger:196+Both modes use Gordon's internal deploy path:
185 197
186-- Fresh image pull (always pulls latest, ignoring cache)198+- Fresh image content is pulled for the route
187-- Container redeployment for the specified route199+- The specified route is redeployed
200+- Configured readiness checks and drain behavior apply when needed
188 201
189 ### Examples202 ### Examples
190 203
M docs/config/index.md +2 -0
@@ -22,6 +22,8 @@ gordon_domain = "gordon.mydomain.com"
2222 ```
2323
2424 > **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
25+>
26+> 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).
2527
2628 ## Full Configuration Reference
2729
@@ -22,6 +22,8 @@ gordon_domain = "gordon.mydomain.com"
22 ```22 ```
23 23
24 > **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.24 > **Note:** `gordon_domain` is the canonical key. Migrate older `registry_domain` values before restarting.
25+>
26+> 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).
25 27
26 ## Full Configuration Reference28 ## Full Configuration Reference
27 29
M docs/config/server.md +4 -0
@@ -13,6 +13,7 @@ tls_port = 8443 # HTTPS listener port (0 = disabled)
1313 # tls_key_file = "" # Optional: PEM key for static TLS
1414 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
1515 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
16+# legacy_registry_domains = ["registry.example.com:5000"]
1617 # data_dir = "~/.gordon" # Data storage directory (default)
1718 max_blob_chunk_size = "95MB" # Max registry blob upload chunk
1819 max_blob_size = "1GB" # Max cumulative registry blob/layer upload
@@ -30,6 +31,7 @@ max_blob_size = "1GB" # Max cumulative registry blob/layer up
3031 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |
3132 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
3233 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
34+| `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). |
3335 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
3436 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
3537 | `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:
217219
218220 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
219221
222+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`.
223+
220224 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.
221225
222226 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).
@@ -13,6 +13,7 @@ tls_port = 8443 # HTTPS listener port (0 = disabled)
13 # tls_key_file = "" # Optional: PEM key for static TLS13 # tls_key_file = "" # Optional: PEM key for static TLS
14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS14 # force_https_redirect = false # Redirect all HTTP traffic to HTTPS
15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)15 gordon_domain = "gordon.mydomain.com" # Gordon domain (required)
16+# legacy_registry_domains = ["registry.example.com:5000"]
16 # data_dir = "~/.gordon" # Data storage directory (default)17 # data_dir = "~/.gordon" # Data storage directory (default)
17 max_blob_chunk_size = "95MB" # Max registry blob upload chunk18 max_blob_chunk_size = "95MB" # Max registry blob upload chunk
18 max_blob_size = "1GB" # Max cumulative registry blob/layer upload19 max_blob_size = "1GB" # Max cumulative registry blob/layer upload
@@ -30,6 +31,7 @@ max_blob_size = "1GB" # Max cumulative registry blob/layer up
30 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |31 | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy |
31 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |32 | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) |
32 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |33 | `registry_domain` | string | - | Deprecated migration key. Set `gordon_domain` instead. |
34+| `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). |
33 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |35 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
34 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |36 | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests |
35 | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk |37 | `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:
217 219
218 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.220 > **Warning:** If you are upgrading an older config, copy `server.registry_domain` to `server.gordon_domain` before restarting.
219 221
222+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`.
223+
220 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.224 Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange.
221 225
222 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).226 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).
M docs/upgrading.md +23 -0
@@ -74,6 +74,29 @@ gordon_domain = "gordon.example.com"
7474
7575 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`.
7676
77+### Staged Registry Host Rename
78+
79+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:
80+
81+```toml
82+[server]
83+gordon_domain = "gordon.example.com"
84+legacy_registry_domains = [
85+ "registry.example.com",
86+ "registry.example.com:5000",
87+]
88+```
89+
90+Recommended rollout:
91+
92+1. Set `gordon_domain` to the new host.
93+2. Add every old Gordon registry host that clients still use to `legacy_registry_domains`.
94+3. Restart Gordon.
95+4. Move Docker/Podman logins, pushes, pulls, and image references to the new host.
96+5. Remove `legacy_registry_domains` after every client has moved.
97+
98+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`.
99+
77100 ## v2.16.0 to v2.30.0
78101
79102 ### Breaking: Password Authentication Removed
@@ -74,6 +74,29 @@ gordon_domain = "gordon.example.com"
74 74
75 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`.75 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`.
76 76
77+### Staged Registry Host Rename
78+
79+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:
80+
81+```toml
82+[server]
83+gordon_domain = "gordon.example.com"
84+legacy_registry_domains = [
85+ "registry.example.com",
86+ "registry.example.com:5000",
87+]
88+```
89+
90+Recommended rollout:
91+
92+1. Set `gordon_domain` to the new host.
93+2. Add every old Gordon registry host that clients still use to `legacy_registry_domains`.
94+3. Restart Gordon.
95+4. Move Docker/Podman logins, pushes, pulls, and image references to the new host.
96+5. Remove `legacy_registry_domains` after every client has moved.
97+
98+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`.
99+
77 ## v2.16.0 to v2.30.0100 ## v2.16.0 to v2.30.0
78 101
79 ### Breaking: Password Authentication Removed102 ### Breaking: Password Authentication Removed
M gordon.toml.example +1 -0
@@ -5,6 +5,7 @@
55 port = 8088
66 registry_port = 5000
77 gordon_domain = "gordon.example.com"
8+# legacy_registry_domains = ["registry.example.com:5000"]
89 # data_dir = "~/.gordon"
910
1011 [auth]
@@ -5,6 +5,7 @@
5 port = 80885 port = 8088
6 registry_port = 50006 registry_port = 5000
7 gordon_domain = "gordon.example.com"7 gordon_domain = "gordon.example.com"
8+# legacy_registry_domains = ["registry.example.com:5000"]
8 # data_dir = "~/.gordon"9 # data_dir = "~/.gordon"
9 10
10 [auth]11 [auth]
M internal/adapters/in/cli/controlplane_local.go +1 -1
@@ -422,7 +422,7 @@ func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*r
422422 if err != nil {
423423 return nil, err
424424 }
425- container, err := l.containerSvc.Deploy(ctx, *route)
425+ container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
426426 if err != nil {
427427 return nil, err
428428 }
@@ -422,7 +422,7 @@ func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*r
422 if err != nil {422 if err != nil {
423 return nil, err423 return nil, err
424 }424 }
425- container, err := l.containerSvc.Deploy(ctx, *route)425+ container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
426 if err != nil {426 if err != nil {
427 return nil, err427 return nil, err
428 }428 }
M internal/adapters/in/cli/controlplane_local_test.go +27 -0
@@ -37,6 +37,33 @@ func TestLocalControlPlane_GetStatus(t *testing.T) {
3737 require.Equal(t, "running", status.ContainerStatus["app.local"])
3838 }
3939
40+func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) {
41+ t.Parallel()
42+
43+ configSvc := inmocks.NewMockConfigService(t)
44+ containerSvc := inmocks.NewMockContainerService(t)
45+
46+ ctx := context.Background()
47+ route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"}
48+
49+ require.False(t, domain.IsInternalDeploy(ctx))
50+
51+ configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil)
52+ containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) {
53+ require.True(t, domain.IsInternalDeploy(deployCtx))
54+ require.Equal(t, *route, deployedRoute)
55+ return &domain.Container{ID: "container-1"}, nil
56+ })
57+
58+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
59+ result, err := cp.Deploy(ctx, "app.local")
60+ require.NoError(t, err)
61+ require.NotNil(t, result)
62+ require.Equal(t, "deployed", result.Status)
63+ require.Equal(t, "app.local", result.Domain)
64+ require.Equal(t, "container-1", result.ContainerID)
65+}
66+
4067 func TestLocalControlPlane_Backups(t *testing.T) {
4168 t.Parallel()
4269
@@ -37,6 +37,33 @@ func TestLocalControlPlane_GetStatus(t *testing.T) {
37 require.Equal(t, "running", status.ContainerStatus["app.local"])37 require.Equal(t, "running", status.ContainerStatus["app.local"])
38 }38 }
39 39
40+func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) {
41+ t.Parallel()
42+
43+ configSvc := inmocks.NewMockConfigService(t)
44+ containerSvc := inmocks.NewMockContainerService(t)
45+
46+ ctx := context.Background()
47+ route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"}
48+
49+ require.False(t, domain.IsInternalDeploy(ctx))
50+
51+ configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil)
52+ containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) {
53+ require.True(t, domain.IsInternalDeploy(deployCtx))
54+ require.Equal(t, *route, deployedRoute)
55+ return &domain.Container{ID: "container-1"}, nil
56+ })
57+
58+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
59+ result, err := cp.Deploy(ctx, "app.local")
60+ require.NoError(t, err)
61+ require.NotNil(t, result)
62+ require.Equal(t, "deployed", result.Status)
63+ require.Equal(t, "app.local", result.Domain)
64+ require.Equal(t, "container-1", result.ContainerID)
65+}
66+
40 func TestLocalControlPlane_Backups(t *testing.T) {67 func TestLocalControlPlane_Backups(t *testing.T) {
41 t.Parallel()68 t.Parallel()
42 69
M internal/app/run.go +37 -41
@@ -93,22 +93,24 @@ import (
9393 // Config holds the application configuration.
9494 type Config struct {
9595 Server struct {
96- Port int `mapstructure:"port"`
97- RegistryPort int `mapstructure:"registry_port"`
98- GordonDomain string `mapstructure:"gordon_domain"`
99- TLSPort int `mapstructure:"tls_port"`
100- TLSCertFile string `mapstructure:"tls_cert_file"`
101- TLSKeyFile string `mapstructure:"tls_key_file"`
102- ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
103- DataDir string `mapstructure:"data_dir"`
104- MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
105- MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
106- MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
107- MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
108- MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
109- RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
110- ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
111- RegistryListenAddr string `mapstructure:"registry_listen_address"`
96+ Port int `mapstructure:"port"`
97+ RegistryPort int `mapstructure:"registry_port"`
98+ GordonDomain string `mapstructure:"gordon_domain"`
99+ RegistryDomain string `mapstructure:"registry_domain"`
100+ LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"`
101+ TLSPort int `mapstructure:"tls_port"`
102+ TLSCertFile string `mapstructure:"tls_cert_file"`
103+ TLSKeyFile string `mapstructure:"tls_key_file"`
104+ ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
105+ DataDir string `mapstructure:"data_dir"`
106+ MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
107+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
108+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
109+ MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
110+ MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
111+ RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
112+ ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
113+ RegistryListenAddr string `mapstructure:"registry_listen_address"`
112114 } `mapstructure:"server"`
113115
114116 Logging struct {
@@ -1356,6 +1358,14 @@ func resolveEnvDir(cfg Config) string {
13561358 return envDir
13571359 }
13581360
1361+func resolveRegistryDomains(cfg Config) (string, []string) {
1362+ registryDomain := cfg.Server.GordonDomain
1363+ if registryDomain == "" {
1364+ registryDomain = cfg.Server.RegistryDomain
1365+ }
1366+ return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
1367+}
1368+
13591369 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
13601370 // Token store is always created since tokens work in both auth modes
13611371 store, err := tokenstore.NewStore(backend, dataDir, log)
@@ -1750,9 +1760,11 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
17501760 }
17511761 // 0 means no limit (as documented in proxy.Config)
17521762
1763+ registryDomain, _ := resolveRegistryDomains(cfg)
1764+
17531765 return &proxyConfigResult{
17541766 proxyConfig: proxy.Config{
1755- RegistryDomain: cfg.Server.GordonDomain,
1767+ RegistryDomain: registryDomain,
17561768 RegistryPort: cfg.Server.RegistryPort,
17571769 MaxBodySize: maxProxyBodySize,
17581770 MaxResponseSize: maxProxyResponseSize,
@@ -1827,10 +1839,12 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
18271839 }
18281840
18291841 attachmentConfig := svc.configSvc.GetAttachmentConfig()
1842+ registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
18301843
18311844 containerConfig := container.Config{
18321845 RegistryAuthEnabled: cfg.Auth.Enabled,
1833- RegistryDomain: cfg.Server.GordonDomain,
1846+ RegistryDomain: registryDomain,
1847+ LegacyRegistryDomains: legacyRegistryDomains,
18341848 RegistryPort: cfg.Server.RegistryPort,
18351849 InternalRegistryUsername: svc.internalRegUser,
18361850 InternalRegistryPassword: svc.internalRegPass,
@@ -1958,7 +1972,8 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
19581972 }
19591973
19601974 // Auto-route handler for creating routes from image labels
1961- autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain).
1975+ registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
1976+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, registryDomain, legacyRegistryDomains...).
19621977 WithEnvExtractor(svc.runtime, svc.envDir)
19631978
19641979 // Preview handler for creating preview environments from tagged images
@@ -2013,26 +2028,6 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina
20132028 return nil
20142029 }
20152030
2016-// syncAndAutoStart syncs existing containers and auto-starts if configured.
2017-func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
2018- if err := svc.containerSvc.EnsureManagedContainerRestartPolicies(ctx); err != nil {
2019- log.Warn().Err(err).Msg("failed to migrate managed container restart policies")
2020- }
2021-
2022- if err := svc.containerSvc.SyncContainers(ctx); err != nil {
2023- log.Warn().Err(err).Msg("failed to sync existing containers")
2024- }
2025-
2026- if svc.configSvc.IsAutoRouteEnabled() {
2027- routes := svc.configSvc.GetRoutes(ctx)
2028- if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
2029- log.Warn().Err(err).Msg("failed to auto-start containers")
2030- }
2031- }
2032-
2033- // Start background monitor to restart crashed containers.
2034- svc.containerSvc.StartMonitor(ctx)
2035-}
20362031 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
20372032 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20382033 host, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -2589,8 +2584,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
25892584 defer schedulerCleanup()
25902585 }
25912586
2592- // Auto-start after servers are listening (registry port is now bound).
2593- syncAndAutoStart(ctx, svc, log)
2587+ // Recover configured routes after servers are listening (registry port is now bound).
2588+ syncAndRecoverConfiguredRoutes(ctx, svc.configSvc, svc.containerSvc, log)
25942589
25952590 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
25962591 cleanupHandlers() // Stop debounce timers before draining containers
@@ -3319,6 +3314,7 @@ func isProcessAlive(pid int) bool {
33193314 func loadConfig(v *viper.Viper, configPath string) error {
33203315 v.SetDefault("server.port", 8088)
33213316 v.SetDefault("server.registry_port", 5000)
3317+ v.SetDefault("server.legacy_registry_domains", []string{})
33223318 v.SetDefault("server.tls_port", 8443)
33233319 v.SetDefault("server.tls_cert_file", "")
33243320 v.SetDefault("server.tls_key_file", "")
@@ -93,22 +93,24 @@ import (
93 // Config holds the application configuration.93 // Config holds the application configuration.
94 type Config struct {94 type Config struct {
95 Server struct {95 Server struct {
96- Port int `mapstructure:"port"`96+ Port int `mapstructure:"port"`
97- RegistryPort int `mapstructure:"registry_port"`97+ RegistryPort int `mapstructure:"registry_port"`
98- GordonDomain string `mapstructure:"gordon_domain"`98+ GordonDomain string `mapstructure:"gordon_domain"`
99- TLSPort int `mapstructure:"tls_port"`99+ RegistryDomain string `mapstructure:"registry_domain"`
100- TLSCertFile string `mapstructure:"tls_cert_file"`100+ LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"`
101- TLSKeyFile string `mapstructure:"tls_key_file"`101+ TLSPort int `mapstructure:"tls_port"`
102- ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`102+ TLSCertFile string `mapstructure:"tls_cert_file"`
103- DataDir string `mapstructure:"data_dir"`103+ TLSKeyFile string `mapstructure:"tls_key_file"`
104- MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"104+ ForceHTTPSRedirect bool `mapstructure:"force_https_redirect"`
105- MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"105+ DataDir string `mapstructure:"data_dir"`
106- MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"106+ MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
107- MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit107+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
108- MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`108+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
109- RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`109+ MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
110- ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`110+ MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
111- RegistryListenAddr string `mapstructure:"registry_listen_address"`111+ RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
112+ ProxyAllowedIPs []string `mapstructure:"proxy_allowed_ips"`
113+ RegistryListenAddr string `mapstructure:"registry_listen_address"`
112 } `mapstructure:"server"`114 } `mapstructure:"server"`
113 115
114 Logging struct {116 Logging struct {
@@ -1356,6 +1358,14 @@ func resolveEnvDir(cfg Config) string {
1356 return envDir1358 return envDir
1357 }1359 }
1358 1360
1361+func resolveRegistryDomains(cfg Config) (string, []string) {
1362+ registryDomain := cfg.Server.GordonDomain
1363+ if registryDomain == "" {
1364+ registryDomain = cfg.Server.RegistryDomain
1365+ }
1366+ return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
1367+}
1368+
1359 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {1369 func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
1360 // Token store is always created since tokens work in both auth modes1370 // Token store is always created since tokens work in both auth modes
1361 store, err := tokenstore.NewStore(backend, dataDir, log)1371 store, err := tokenstore.NewStore(backend, dataDir, log)
@@ -1750,9 +1760,11 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1750 }1760 }
1751 // 0 means no limit (as documented in proxy.Config)1761 // 0 means no limit (as documented in proxy.Config)
1752 1762
1763+ registryDomain, _ := resolveRegistryDomains(cfg)
1764+
1753 return &proxyConfigResult{1765 return &proxyConfigResult{
1754 proxyConfig: proxy.Config{1766 proxyConfig: proxy.Config{
1755- RegistryDomain: cfg.Server.GordonDomain,1767+ RegistryDomain: registryDomain,
1756 RegistryPort: cfg.Server.RegistryPort,1768 RegistryPort: cfg.Server.RegistryPort,
1757 MaxBodySize: maxProxyBodySize,1769 MaxBodySize: maxProxyBodySize,
1758 MaxResponseSize: maxProxyResponseSize,1770 MaxResponseSize: maxProxyResponseSize,
@@ -1827,10 +1839,12 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1827 }1839 }
1828 1840
1829 attachmentConfig := svc.configSvc.GetAttachmentConfig()1841 attachmentConfig := svc.configSvc.GetAttachmentConfig()
1842+ registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
1830 1843
1831 containerConfig := container.Config{1844 containerConfig := container.Config{
1832 RegistryAuthEnabled: cfg.Auth.Enabled,1845 RegistryAuthEnabled: cfg.Auth.Enabled,
1833- RegistryDomain: cfg.Server.GordonDomain,1846+ RegistryDomain: registryDomain,
1847+ LegacyRegistryDomains: legacyRegistryDomains,
1834 RegistryPort: cfg.Server.RegistryPort,1848 RegistryPort: cfg.Server.RegistryPort,
1835 InternalRegistryUsername: svc.internalRegUser,1849 InternalRegistryUsername: svc.internalRegUser,
1836 InternalRegistryPassword: svc.internalRegPass,1850 InternalRegistryPassword: svc.internalRegPass,
@@ -1958,7 +1972,8 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func
1958 }1972 }
1959 1973
1960 // Auto-route handler for creating routes from image labels1974 // Auto-route handler for creating routes from image labels
1961- autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.GordonDomain).1975+ registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg)
1976+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, registryDomain, legacyRegistryDomains...).
1962 WithEnvExtractor(svc.runtime, svc.envDir)1977 WithEnvExtractor(svc.runtime, svc.envDir)
1963 1978
1964 // Preview handler for creating preview environments from tagged images1979 // Preview handler for creating preview environments from tagged images
@@ -2013,26 +2028,6 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina
2013 return nil2028 return nil
2014 }2029 }
2015 2030
2016-// syncAndAutoStart syncs existing containers and auto-starts if configured.
2017-func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
2018- if err := svc.containerSvc.EnsureManagedContainerRestartPolicies(ctx); err != nil {
2019- log.Warn().Err(err).Msg("failed to migrate managed container restart policies")
2020- }
2021-
2022- if err := svc.containerSvc.SyncContainers(ctx); err != nil {
2023- log.Warn().Err(err).Msg("failed to sync existing containers")
2024- }
2025-
2026- if svc.configSvc.IsAutoRouteEnabled() {
2027- routes := svc.configSvc.GetRoutes(ctx)
2028- if err := svc.containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
2029- log.Warn().Err(err).Msg("failed to auto-start containers")
2030- }
2031- }
2032-
2033- // Start background monitor to restart crashed containers.
2034- svc.containerSvc.StartMonitor(ctx)
2035-}
2036 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {2031 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
2037 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {2032 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2038 host, _, err := net.SplitHostPort(r.RemoteAddr)2033 host, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -2589,8 +2584,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2589 defer schedulerCleanup()2584 defer schedulerCleanup()
2590 }2585 }
2591 2586
2592- // Auto-start after servers are listening (registry port is now bound).2587+ // Recover configured routes after servers are listening (registry port is now bound).
2593- syncAndAutoStart(ctx, svc, log)2588+ syncAndRecoverConfiguredRoutes(ctx, svc.configSvc, svc.containerSvc, log)
2594 2589
2595 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)2590 waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log)
2596 cleanupHandlers() // Stop debounce timers before draining containers2591 cleanupHandlers() // Stop debounce timers before draining containers
@@ -3319,6 +3314,7 @@ func isProcessAlive(pid int) bool {
3319 func loadConfig(v *viper.Viper, configPath string) error {3314 func loadConfig(v *viper.Viper, configPath string) error {
3320 v.SetDefault("server.port", 8088)3315 v.SetDefault("server.port", 8088)
3321 v.SetDefault("server.registry_port", 5000)3316 v.SetDefault("server.registry_port", 5000)
3317+ v.SetDefault("server.legacy_registry_domains", []string{})
3322 v.SetDefault("server.tls_port", 8443)3318 v.SetDefault("server.tls_port", 8443)
3323 v.SetDefault("server.tls_cert_file", "")3319 v.SetDefault("server.tls_cert_file", "")
3324 v.SetDefault("server.tls_key_file", "")3320 v.SetDefault("server.tls_key_file", "")
A internal/app/startup_recovery.go +42 -0
@@ -0,0 +1,42 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/bnema/zerowrap"
8+)
9+
10+// startupConfigService defines the route configuration needed during startup
11+// recovery.
12+type startupConfigService interface {
13+ GetRoutes(ctx context.Context) []domain.Route
14+}
15+
16+// startupContainerService defines the container lifecycle operations needed
17+// during startup recovery.
18+type startupContainerService interface {
19+ SyncContainers(ctx context.Context) error
20+ AutoStart(ctx context.Context, routes []domain.Route) error
21+ StartMonitor(ctx context.Context)
22+}
23+
24+// syncAndRecoverConfiguredRoutes performs best-effort startup recovery for
25+// configured routes after listeners are ready.
26+func syncAndRecoverConfiguredRoutes(
27+ ctx context.Context,
28+ configSvc startupConfigService,
29+ containerSvc startupContainerService,
30+ log zerowrap.Logger,
31+) {
32+ defer containerSvc.StartMonitor(ctx)
33+
34+ if err := containerSvc.SyncContainers(ctx); err != nil {
35+ log.Warn().Err(err).Msg("failed to sync existing containers")
36+ }
37+
38+ routes := configSvc.GetRoutes(ctx)
39+ if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
40+ log.Warn().Err(err).Msg("failed to auto-start configured routes")
41+ }
42+}
@@ -0,0 +1,42 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/bnema/zerowrap"
8+)
9+
10+// startupConfigService defines the route configuration needed during startup
11+// recovery.
12+type startupConfigService interface {
13+ GetRoutes(ctx context.Context) []domain.Route
14+}
15+
16+// startupContainerService defines the container lifecycle operations needed
17+// during startup recovery.
18+type startupContainerService interface {
19+ SyncContainers(ctx context.Context) error
20+ AutoStart(ctx context.Context, routes []domain.Route) error
21+ StartMonitor(ctx context.Context)
22+}
23+
24+// syncAndRecoverConfiguredRoutes performs best-effort startup recovery for
25+// configured routes after listeners are ready.
26+func syncAndRecoverConfiguredRoutes(
27+ ctx context.Context,
28+ configSvc startupConfigService,
29+ containerSvc startupContainerService,
30+ log zerowrap.Logger,
31+) {
32+ defer containerSvc.StartMonitor(ctx)
33+
34+ if err := containerSvc.SyncContainers(ctx); err != nil {
35+ log.Warn().Err(err).Msg("failed to sync existing containers")
36+ }
37+
38+ routes := configSvc.GetRoutes(ctx)
39+ if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
40+ log.Warn().Err(err).Msg("failed to auto-start configured routes")
41+ }
42+}
A internal/app/startup_recovery_test.go +150 -0
@@ -0,0 +1,150 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+type startupRecoveryFakeConfigService struct {
14+ calls *[]string
15+ routes []domain.Route
16+ autoRouteEnabled bool
17+ panicOnAutoRouteCheck bool
18+}
19+
20+func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route {
21+ *f.calls = append(*f.calls, "routes")
22+ return append([]domain.Route(nil), f.routes...)
23+}
24+
25+func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool {
26+ if f.panicOnAutoRouteCheck {
27+ panic("unexpected auto-route gate during startup recovery")
28+ }
29+ if f.calls != nil {
30+ *f.calls = append(*f.calls, "auto-route-enabled")
31+ }
32+ return f.autoRouteEnabled
33+}
34+
35+type startupRecoveryFakeContainerService struct {
36+ calls *[]string
37+ syncErr error
38+ autoStartErr error
39+ autoStartCtx context.Context
40+ autoStartRoutes []domain.Route
41+ startMonitorCtx context.Context
42+}
43+
44+func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
45+ *f.calls = append(*f.calls, "sync")
46+ return f.syncErr
47+}
48+
49+func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
50+ *f.calls = append(*f.calls, "autostart")
51+ f.autoStartCtx = ctx
52+ f.autoStartRoutes = append([]domain.Route(nil), routes...)
53+ return f.autoStartErr
54+}
55+
56+func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) {
57+ *f.calls = append(*f.calls, "monitor")
58+ f.startMonitorCtx = ctx
59+}
60+
61+func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) {
62+ ctx := context.Background()
63+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
64+ calls := make([]string, 0, 4)
65+
66+ configSvc := &startupRecoveryFakeConfigService{
67+ calls: &calls,
68+ routes: routes,
69+ }
70+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
71+
72+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
73+
74+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
75+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
76+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
77+ assert.False(t, domain.IsInternalDeploy(ctx))
78+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
79+}
80+
81+func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) {
82+ ctx := context.Background()
83+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
84+ calls := make([]string, 0, 4)
85+
86+ configSvc := &startupRecoveryFakeConfigService{
87+ calls: &calls,
88+ routes: routes,
89+ }
90+ containerSvc := &startupRecoveryFakeContainerService{
91+ calls: &calls,
92+ syncErr: assert.AnError,
93+ }
94+
95+ assert.NotPanics(t, func() {
96+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
97+ })
98+
99+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
100+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
101+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
102+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
103+}
104+
105+func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) {
106+ ctx := context.Background()
107+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
108+ calls := make([]string, 0, 4)
109+
110+ configSvc := &startupRecoveryFakeConfigService{
111+ calls: &calls,
112+ routes: routes,
113+ }
114+ containerSvc := &startupRecoveryFakeContainerService{
115+ calls: &calls,
116+ autoStartErr: assert.AnError,
117+ }
118+
119+ assert.NotPanics(t, func() {
120+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
121+ })
122+
123+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
124+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
125+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
126+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
127+}
128+
129+func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) {
130+ ctx := context.Background()
131+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
132+ calls := make([]string, 0, 4)
133+
134+ configSvc := &startupRecoveryFakeConfigService{
135+ calls: &calls,
136+ routes: routes,
137+ autoRouteEnabled: false,
138+ panicOnAutoRouteCheck: true,
139+ }
140+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
141+
142+ assert.NotPanics(t, func() {
143+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
144+ })
145+
146+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
147+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
148+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
149+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
150+}
@@ -0,0 +1,150 @@
1+package app
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zerowrap"
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+type startupRecoveryFakeConfigService struct {
14+ calls *[]string
15+ routes []domain.Route
16+ autoRouteEnabled bool
17+ panicOnAutoRouteCheck bool
18+}
19+
20+func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route {
21+ *f.calls = append(*f.calls, "routes")
22+ return append([]domain.Route(nil), f.routes...)
23+}
24+
25+func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool {
26+ if f.panicOnAutoRouteCheck {
27+ panic("unexpected auto-route gate during startup recovery")
28+ }
29+ if f.calls != nil {
30+ *f.calls = append(*f.calls, "auto-route-enabled")
31+ }
32+ return f.autoRouteEnabled
33+}
34+
35+type startupRecoveryFakeContainerService struct {
36+ calls *[]string
37+ syncErr error
38+ autoStartErr error
39+ autoStartCtx context.Context
40+ autoStartRoutes []domain.Route
41+ startMonitorCtx context.Context
42+}
43+
44+func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
45+ *f.calls = append(*f.calls, "sync")
46+ return f.syncErr
47+}
48+
49+func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
50+ *f.calls = append(*f.calls, "autostart")
51+ f.autoStartCtx = ctx
52+ f.autoStartRoutes = append([]domain.Route(nil), routes...)
53+ return f.autoStartErr
54+}
55+
56+func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) {
57+ *f.calls = append(*f.calls, "monitor")
58+ f.startMonitorCtx = ctx
59+}
60+
61+func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) {
62+ ctx := context.Background()
63+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
64+ calls := make([]string, 0, 4)
65+
66+ configSvc := &startupRecoveryFakeConfigService{
67+ calls: &calls,
68+ routes: routes,
69+ }
70+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
71+
72+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
73+
74+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
75+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
76+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
77+ assert.False(t, domain.IsInternalDeploy(ctx))
78+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
79+}
80+
81+func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) {
82+ ctx := context.Background()
83+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
84+ calls := make([]string, 0, 4)
85+
86+ configSvc := &startupRecoveryFakeConfigService{
87+ calls: &calls,
88+ routes: routes,
89+ }
90+ containerSvc := &startupRecoveryFakeContainerService{
91+ calls: &calls,
92+ syncErr: assert.AnError,
93+ }
94+
95+ assert.NotPanics(t, func() {
96+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
97+ })
98+
99+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
100+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
101+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
102+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
103+}
104+
105+func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) {
106+ ctx := context.Background()
107+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
108+ calls := make([]string, 0, 4)
109+
110+ configSvc := &startupRecoveryFakeConfigService{
111+ calls: &calls,
112+ routes: routes,
113+ }
114+ containerSvc := &startupRecoveryFakeContainerService{
115+ calls: &calls,
116+ autoStartErr: assert.AnError,
117+ }
118+
119+ assert.NotPanics(t, func() {
120+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
121+ })
122+
123+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
124+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
125+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
126+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
127+}
128+
129+func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) {
130+ ctx := context.Background()
131+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
132+ calls := make([]string, 0, 4)
133+
134+ configSvc := &startupRecoveryFakeConfigService{
135+ calls: &calls,
136+ routes: routes,
137+ autoRouteEnabled: false,
138+ panicOnAutoRouteCheck: true,
139+ }
140+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
141+
142+ assert.NotPanics(t, func() {
143+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
144+ })
145+
146+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
147+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
148+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
149+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
150+}
A internal/domain/registry_image.go +133 -0
@@ -0,0 +1,133 @@
1+package domain
2+
3+import "strings"
4+
5+// KnownGordonRegistryDomains returns the normalized set of Gordon-managed
6+// registry domains, with the current domain first followed by legacy domains.
7+// Empty values are ignored, trailing slashes are removed, and duplicates are
8+// removed while preserving first-seen order.
9+func KnownGordonRegistryDomains(current string, legacy []string) []string {
10+ domains := make([]string, 0, 1+len(legacy))
11+ seen := make(map[string]struct{}, 1+len(legacy))
12+
13+ add := func(domain string) {
14+ normalized := normalizeRegistryDomain(domain)
15+ if normalized == "" {
16+ return
17+ }
18+
19+ key := strings.ToLower(normalized)
20+ if _, ok := seen[key]; ok {
21+ return
22+ }
23+
24+ seen[key] = struct{}{}
25+ domains = append(domains, normalized)
26+ }
27+
28+ add(current)
29+ for _, domain := range legacy {
30+ add(domain)
31+ }
32+
33+ return domains
34+}
35+
36+// StripKnownGordonRegistry removes the Gordon-managed registry host prefix from
37+// an image reference when it matches the current or a legacy Gordon registry
38+// domain. Bare references and external registries are returned unchanged.
39+func StripKnownGordonRegistry(imageRef string, current string, legacy []string) string {
40+ host, remainder, ok := splitRegistryImageRef(imageRef)
41+ if !ok {
42+ return imageRef
43+ }
44+ if !isKnownGordonRegistryDomain(host, current, legacy) {
45+ return imageRef
46+ }
47+ return remainder
48+}
49+
50+// ExtractGordonRepoName strips a known Gordon registry host, then removes any
51+// tag or digest suffix, returning the repository name portion of the image
52+// reference.
53+func ExtractGordonRepoName(imageRef string, current string, legacy []string) string {
54+ repo := StripKnownGordonRegistry(imageRef, current, legacy)
55+
56+ if idx := strings.Index(repo, "@"); idx != -1 {
57+ repo = repo[:idx]
58+ }
59+ if idx := strings.LastIndex(repo, ":"); idx != -1 {
60+ slashIdx := strings.LastIndex(repo, "/")
61+ if idx > slashIdx {
62+ repo = repo[:idx]
63+ }
64+ }
65+
66+ return repo
67+}
68+
69+// CanonicalizeGordonImageRef rewrites Gordon-managed image references to use
70+// the current Gordon registry domain. External registries and bare references
71+// are returned unchanged. If the current domain is empty, the original image
72+// reference is returned unchanged.
73+func CanonicalizeGordonImageRef(imageRef, current string, legacy []string) string {
74+ currentDomain := normalizeRegistryDomain(current)
75+ if currentDomain == "" {
76+ return imageRef
77+ }
78+
79+ host, remainder, ok := splitRegistryImageRef(imageRef)
80+ if !ok {
81+ return imageRef
82+ }
83+ if !isKnownGordonRegistryDomain(host, currentDomain, legacy) {
84+ return imageRef
85+ }
86+
87+ return currentDomain + "/" + remainder
88+}
89+
90+// IsGordonRegistryImageRef reports whether an image reference is explicitly
91+// qualified with the current or a legacy Gordon registry domain.
92+func IsGordonRegistryImageRef(imageRef, current string, legacy []string) bool {
93+ host, _, ok := splitRegistryImageRef(imageRef)
94+ if !ok {
95+ return false
96+ }
97+ return isKnownGordonRegistryDomain(host, current, legacy)
98+}
99+
100+func normalizeRegistryDomain(domain string) string {
101+ domain = strings.TrimSpace(domain)
102+ domain = strings.TrimRight(domain, "/")
103+ return domain
104+}
105+
106+func isKnownGordonRegistryDomain(host, current string, legacy []string) bool {
107+ host = normalizeRegistryDomain(host)
108+ if host == "" {
109+ return false
110+ }
111+
112+ for _, domain := range KnownGordonRegistryDomains(current, legacy) {
113+ if strings.EqualFold(domain, host) {
114+ return true
115+ }
116+ }
117+ return false
118+}
119+
120+func splitRegistryImageRef(imageRef string) (host string, remainder string, ok bool) {
121+ host, remainder, ok = strings.Cut(imageRef, "/")
122+ if !ok || host == "" || remainder == "" {
123+ return "", "", false
124+ }
125+ if !looksLikeRegistryHost(host) {
126+ return "", "", false
127+ }
128+ return host, remainder, true
129+}
130+
131+func looksLikeRegistryHost(host string) bool {
132+ return strings.Contains(host, ".") || strings.Contains(host, ":") || strings.EqualFold(host, "localhost")
133+}
@@ -0,0 +1,133 @@
1+package domain
2+
3+import "strings"
4+
5+// KnownGordonRegistryDomains returns the normalized set of Gordon-managed
6+// registry domains, with the current domain first followed by legacy domains.
7+// Empty values are ignored, trailing slashes are removed, and duplicates are
8+// removed while preserving first-seen order.
9+func KnownGordonRegistryDomains(current string, legacy []string) []string {
10+ domains := make([]string, 0, 1+len(legacy))
11+ seen := make(map[string]struct{}, 1+len(legacy))
12+
13+ add := func(domain string) {
14+ normalized := normalizeRegistryDomain(domain)
15+ if normalized == "" {
16+ return
17+ }
18+
19+ key := strings.ToLower(normalized)
20+ if _, ok := seen[key]; ok {
21+ return
22+ }
23+
24+ seen[key] = struct{}{}
25+ domains = append(domains, normalized)
26+ }
27+
28+ add(current)
29+ for _, domain := range legacy {
30+ add(domain)
31+ }
32+
33+ return domains
34+}
35+
36+// StripKnownGordonRegistry removes the Gordon-managed registry host prefix from
37+// an image reference when it matches the current or a legacy Gordon registry
38+// domain. Bare references and external registries are returned unchanged.
39+func StripKnownGordonRegistry(imageRef string, current string, legacy []string) string {
40+ host, remainder, ok := splitRegistryImageRef(imageRef)
41+ if !ok {
42+ return imageRef
43+ }
44+ if !isKnownGordonRegistryDomain(host, current, legacy) {
45+ return imageRef
46+ }
47+ return remainder
48+}
49+
50+// ExtractGordonRepoName strips a known Gordon registry host, then removes any
51+// tag or digest suffix, returning the repository name portion of the image
52+// reference.
53+func ExtractGordonRepoName(imageRef string, current string, legacy []string) string {
54+ repo := StripKnownGordonRegistry(imageRef, current, legacy)
55+
56+ if idx := strings.Index(repo, "@"); idx != -1 {
57+ repo = repo[:idx]
58+ }
59+ if idx := strings.LastIndex(repo, ":"); idx != -1 {
60+ slashIdx := strings.LastIndex(repo, "/")
61+ if idx > slashIdx {
62+ repo = repo[:idx]
63+ }
64+ }
65+
66+ return repo
67+}
68+
69+// CanonicalizeGordonImageRef rewrites Gordon-managed image references to use
70+// the current Gordon registry domain. External registries and bare references
71+// are returned unchanged. If the current domain is empty, the original image
72+// reference is returned unchanged.
73+func CanonicalizeGordonImageRef(imageRef, current string, legacy []string) string {
74+ currentDomain := normalizeRegistryDomain(current)
75+ if currentDomain == "" {
76+ return imageRef
77+ }
78+
79+ host, remainder, ok := splitRegistryImageRef(imageRef)
80+ if !ok {
81+ return imageRef
82+ }
83+ if !isKnownGordonRegistryDomain(host, currentDomain, legacy) {
84+ return imageRef
85+ }
86+
87+ return currentDomain + "/" + remainder
88+}
89+
90+// IsGordonRegistryImageRef reports whether an image reference is explicitly
91+// qualified with the current or a legacy Gordon registry domain.
92+func IsGordonRegistryImageRef(imageRef, current string, legacy []string) bool {
93+ host, _, ok := splitRegistryImageRef(imageRef)
94+ if !ok {
95+ return false
96+ }
97+ return isKnownGordonRegistryDomain(host, current, legacy)
98+}
99+
100+func normalizeRegistryDomain(domain string) string {
101+ domain = strings.TrimSpace(domain)
102+ domain = strings.TrimRight(domain, "/")
103+ return domain
104+}
105+
106+func isKnownGordonRegistryDomain(host, current string, legacy []string) bool {
107+ host = normalizeRegistryDomain(host)
108+ if host == "" {
109+ return false
110+ }
111+
112+ for _, domain := range KnownGordonRegistryDomains(current, legacy) {
113+ if strings.EqualFold(domain, host) {
114+ return true
115+ }
116+ }
117+ return false
118+}
119+
120+func splitRegistryImageRef(imageRef string) (host string, remainder string, ok bool) {
121+ host, remainder, ok = strings.Cut(imageRef, "/")
122+ if !ok || host == "" || remainder == "" {
123+ return "", "", false
124+ }
125+ if !looksLikeRegistryHost(host) {
126+ return "", "", false
127+ }
128+ return host, remainder, true
129+}
130+
131+func looksLikeRegistryHost(host string) bool {
132+ return strings.Contains(host, ".") || strings.Contains(host, ":") || strings.EqualFold(host, "localhost")
133+}
A internal/domain/registry_image_test.go +136 -0
@@ -0,0 +1,136 @@
1+package domain_test
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
11+ got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{
12+ "",
13+ " old-registry.example.com/ ",
14+ "old-registry.example.com:5000///",
15+ "new-registry.example.com",
16+ "old-registry.example.com",
17+ })
18+
19+ want := []string{
20+ "new-registry.example.com",
21+ "old-registry.example.com",
22+ "old-registry.example.com:5000",
23+ }
24+
25+ if !reflect.DeepEqual(got, want) {
26+ t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want)
27+ }
28+}
29+
30+func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
31+ current := "new-registry.example.com/"
32+ legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"}
33+
34+ tests := []struct {
35+ name string
36+ imageRef string
37+ want string
38+ }{
39+ {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"},
40+ {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"},
41+ {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"},
42+ {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"},
43+ {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"},
44+ {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"},
45+ {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"},
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
51+ if got != tt.want {
52+ t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want)
53+ }
54+ })
55+ }
56+}
57+
58+func TestRegistryImageExtractGordonRepoName(t *testing.T) {
59+ current := "new-registry.example.com/"
60+ legacy := []string{"old-registry.example.com/"}
61+
62+ tests := []struct {
63+ name string
64+ imageRef string
65+ want string
66+ }{
67+ {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"},
68+ {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"},
69+ {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"},
70+ {name: "bare image", imageRef: "app:latest", want: "app"},
71+ {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
72+ {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
78+ if got != tt.want {
79+ t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want)
80+ }
81+ })
82+ }
83+}
84+
85+func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
86+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
87+
88+ tests := []struct {
89+ name string
90+ imageRef string
91+ current string
92+ want string
93+ }{
94+ {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
95+ {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
96+ {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"},
97+ {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"},
98+ {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"},
99+ }
100+
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
104+ if got != tt.want {
105+ t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want)
106+ }
107+ })
108+ }
109+}
110+
111+func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
112+ current := "new-registry.example.com/"
113+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
114+
115+ tests := []struct {
116+ name string
117+ imageRef string
118+ want bool
119+ }{
120+ {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true},
121+ {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true},
122+ {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true},
123+ {name: "bare ref", imageRef: "app:latest", want: false},
124+ {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false},
125+ {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
131+ if got != tt.want {
132+ t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want)
133+ }
134+ })
135+ }
136+}
@@ -0,0 +1,136 @@
1+package domain_test
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
11+ got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{
12+ "",
13+ " old-registry.example.com/ ",
14+ "old-registry.example.com:5000///",
15+ "new-registry.example.com",
16+ "old-registry.example.com",
17+ })
18+
19+ want := []string{
20+ "new-registry.example.com",
21+ "old-registry.example.com",
22+ "old-registry.example.com:5000",
23+ }
24+
25+ if !reflect.DeepEqual(got, want) {
26+ t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want)
27+ }
28+}
29+
30+func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
31+ current := "new-registry.example.com/"
32+ legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"}
33+
34+ tests := []struct {
35+ name string
36+ imageRef string
37+ want string
38+ }{
39+ {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"},
40+ {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"},
41+ {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"},
42+ {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"},
43+ {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"},
44+ {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"},
45+ {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"},
46+ }
47+
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
51+ if got != tt.want {
52+ t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want)
53+ }
54+ })
55+ }
56+}
57+
58+func TestRegistryImageExtractGordonRepoName(t *testing.T) {
59+ current := "new-registry.example.com/"
60+ legacy := []string{"old-registry.example.com/"}
61+
62+ tests := []struct {
63+ name string
64+ imageRef string
65+ want string
66+ }{
67+ {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"},
68+ {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"},
69+ {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"},
70+ {name: "bare image", imageRef: "app:latest", want: "app"},
71+ {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
72+ {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
73+ }
74+
75+ for _, tt := range tests {
76+ t.Run(tt.name, func(t *testing.T) {
77+ got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
78+ if got != tt.want {
79+ t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want)
80+ }
81+ })
82+ }
83+}
84+
85+func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
86+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
87+
88+ tests := []struct {
89+ name string
90+ imageRef string
91+ current string
92+ want string
93+ }{
94+ {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
95+ {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
96+ {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"},
97+ {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"},
98+ {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"},
99+ }
100+
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
104+ if got != tt.want {
105+ t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want)
106+ }
107+ })
108+ }
109+}
110+
111+func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
112+ current := "new-registry.example.com/"
113+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
114+
115+ tests := []struct {
116+ name string
117+ imageRef string
118+ want bool
119+ }{
120+ {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true},
121+ {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true},
122+ {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true},
123+ {name: "bare ref", imageRef: "app:latest", want: false},
124+ {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false},
125+ {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false},
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
131+ if got != tt.want {
132+ t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want)
133+ }
134+ })
135+ }
136+}
M internal/usecase/auto/validation.go +10 -20
@@ -1,6 +1,10 @@
11 package auto
22
3-import "strings"
3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
48
59 // MatchesDomainAllowlist reports whether domain matches any of the given patterns.
610 // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to
@@ -36,23 +40,9 @@ func MatchesDomainAllowlist(domain string, patterns []string) bool {
3640 return false
3741 }
3842
39-// ExtractRepoName strips the registry domain, tag, and digest from an image
40-// reference and returns the bare repository name (e.g. "org/myapp").
41-func ExtractRepoName(imageRef, registryDomain string) string {
42- imageRef = strings.ToLower(imageRef)
43- if idx := strings.Index(imageRef, "@"); idx != -1 {
44- imageRef = imageRef[:idx]
45- }
46- if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
47- slashIdx := strings.LastIndex(imageRef, "/")
48- if idx > slashIdx {
49- imageRef = imageRef[:idx]
50- }
51- }
52- registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
53- if registryPrefix != "" {
54- prefix := registryPrefix + "/"
55- imageRef = strings.TrimPrefix(imageRef, prefix)
56- }
57- return imageRef
43+// ExtractRepoName strips the current or legacy Gordon registry domain, tag,
44+// and digest from an image reference and returns the bare repository name
45+// (e.g. "org/myapp").
46+func ExtractRepoName(imageRef, registryDomain string, legacyRegistryDomains ...string) string {
47+ return strings.ToLower(domain.ExtractGordonRepoName(imageRef, registryDomain, legacyRegistryDomains))
5848 }
@@ -1,6 +1,10 @@
1 package auto1 package auto
2 2
3-import "strings"3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
4 8
5 // MatchesDomainAllowlist reports whether domain matches any of the given patterns.9 // MatchesDomainAllowlist reports whether domain matches any of the given patterns.
6 // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to10 // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to
@@ -36,23 +40,9 @@ func MatchesDomainAllowlist(domain string, patterns []string) bool {
36 return false40 return false
37 }41 }
38 42
39-// ExtractRepoName strips the registry domain, tag, and digest from an image43+// ExtractRepoName strips the current or legacy Gordon registry domain, tag,
40-// reference and returns the bare repository name (e.g. "org/myapp").44+// and digest from an image reference and returns the bare repository name
41-func ExtractRepoName(imageRef, registryDomain string) string {45+// (e.g. "org/myapp").
42- imageRef = strings.ToLower(imageRef)46+func ExtractRepoName(imageRef, registryDomain string, legacyRegistryDomains ...string) string {
43- if idx := strings.Index(imageRef, "@"); idx != -1 {47+ return strings.ToLower(domain.ExtractGordonRepoName(imageRef, registryDomain, legacyRegistryDomains))
44- imageRef = imageRef[:idx]
45- }
46- if idx := strings.LastIndex(imageRef, ":"); idx != -1 {
47- slashIdx := strings.LastIndex(imageRef, "/")
48- if idx > slashIdx {
49- imageRef = imageRef[:idx]
50- }
51- }
52- registryPrefix := strings.ToLower(strings.TrimSuffix(registryDomain, "/"))
53- if registryPrefix != "" {
54- prefix := registryPrefix + "/"
55- imageRef = strings.TrimPrefix(imageRef, prefix)
56- }
57- return imageRef
58 }48 }
M internal/usecase/auto/validation_test.go +16 -0
@@ -57,3 +57,19 @@ func TestExtractRepoName(t *testing.T) {
5757 })
5858 }
5959 }
60+
61+func TestExtractRepoName_TreatsLegacyAndCurrentGordonRegistryHostsAsSameRepo(t *testing.T) {
62+ current := "new-registry.example.com"
63+ legacy := []string{"old-registry.example.com"}
64+
65+ oldRepo := ExtractRepoName("old-registry.example.com/app:latest", current, legacy...)
66+ newRepo := ExtractRepoName("new-registry.example.com/app:latest", current, legacy...)
67+
68+ assert.Equal(t, "app", oldRepo)
69+ assert.Equal(t, oldRepo, newRepo)
70+}
71+
72+func TestExtractRepoName_HandlesPortsAndDigests(t *testing.T) {
73+ assert.Equal(t, "org/app", ExtractRepoName("new-registry.example.com:5000/org/app:latest", "new-registry.example.com:5000"))
74+ 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"))
75+}
@@ -57,3 +57,19 @@ func TestExtractRepoName(t *testing.T) {
57 })57 })
58 }58 }
59 }59 }
60+
61+func TestExtractRepoName_TreatsLegacyAndCurrentGordonRegistryHostsAsSameRepo(t *testing.T) {
62+ current := "new-registry.example.com"
63+ legacy := []string{"old-registry.example.com"}
64+
65+ oldRepo := ExtractRepoName("old-registry.example.com/app:latest", current, legacy...)
66+ newRepo := ExtractRepoName("new-registry.example.com/app:latest", current, legacy...)
67+
68+ assert.Equal(t, "app", oldRepo)
69+ assert.Equal(t, oldRepo, newRepo)
70+}
71+
72+func TestExtractRepoName_HandlesPortsAndDigests(t *testing.T) {
73+ assert.Equal(t, "org/app", ExtractRepoName("new-registry.example.com:5000/org/app:latest", "new-registry.example.com:5000"))
74+ 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"))
75+}
M internal/usecase/config/service.go +45 -23
@@ -30,6 +30,7 @@ type Config struct {
3030 ServerPort int
3131 RegistryPort int
3232 RegistryDomain string
33+ LegacyRegistryDomains []string
3334 DataDir string
3435 AutoRouteEnabled bool
3536 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
@@ -171,10 +172,13 @@ func (s *Service) loadConfigValues() Config {
171172 previewSep = "--"
172173 }
173174
175+ legacyRegistryDomains := append([]string{}, s.viper.GetStringSlice("server.legacy_registry_domains")...)
176+
174177 return Config{
175178 ServerPort: s.viper.GetInt("server.port"),
176179 RegistryPort: s.viper.GetInt("server.registry_port"),
177180 RegistryDomain: registryDomain,
181+ LegacyRegistryDomains: legacyRegistryDomains,
178182 DataDir: s.viper.GetString("server.data_dir"),
179183 AutoRouteEnabled: autoEnabled,
180184 AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
@@ -423,7 +427,7 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
423427 if strings.HasPrefix(domainName, "http://") {
424428 continue
425429 }
426- if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {
430+ if matchesImageName(imageName, route.Image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) {
427431 routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
428432 }
429433 }
@@ -439,7 +443,7 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri
439443 var targets []string
440444 for target, images := range s.config.Attachments {
441445 for _, image := range images {
442- if matchesImageName(imageName, image, s.config.RegistryDomain) {
446+ if matchesImageName(imageName, image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) {
443447 targets = append(targets, target)
444448 break
445449 }
@@ -449,10 +453,10 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri
449453 return targets
450454 }
451455
452-func matchesImageName(inputImage, candidateImage, registryDomain string) bool {
453- normalizedInput := NormalizeRegistryImage(inputImage, registryDomain)
456+func matchesImageName(inputImage, candidateImage, registryDomain string, legacyRegistryDomains []string) bool {
457+ normalizedInput := NormalizeRegistryImage(inputImage, registryDomain, legacyRegistryDomains)
454458 inputName, inputHasTag := splitImageNameTag(normalizedInput)
455- normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain)
459+ normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain, legacyRegistryDomains)
456460
457461 if inputHasTag {
458462 return strings.EqualFold(normalizedCandidate, normalizedInput)
@@ -470,19 +474,10 @@ func splitImageNameTag(image string) (name string, hasTag bool) {
470474 return image, false
471475 }
472476
473-// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
474-func NormalizeRegistryImage(imageName, registryDomain string) string {
475- registryDomain = strings.TrimSuffix(registryDomain, "/")
476- if registryDomain == "" {
477- return imageName
478- }
479-
480- prefix := registryDomain + "/"
481- if strings.HasPrefix(imageName, prefix) {
482- return strings.TrimPrefix(imageName, prefix)
483- }
484-
485- return imageName
477+// NormalizeRegistryImage strips the current or legacy Gordon registry domain
478+// prefix from an image name for comparison.
479+func NormalizeRegistryImage(imageName, registryDomain string, legacyRegistryDomains []string) string {
480+ return domain.StripKnownGordonRegistry(imageName, registryDomain, legacyRegistryDomains)
486481 }
487482
488483 // NormalizeBootstrapImage converts a user-supplied image argument into
@@ -816,7 +811,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config)
816811
817812 delete(configMap, "routes")
818813 configMap["external_routes"] = snapshotConfig.ExternalRoutes
819- configMap["attachments"] = snapshotConfig.Attachments
814+ configMap["attachments"] = canonicalAttachmentsForSave(snapshotConfig.Attachments, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains)
820815 configMap["network_groups"] = snapshotConfig.NetworkGroups
821816 applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
822817
@@ -825,7 +820,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config)
825820 return fmt.Errorf("failed to marshal config: %w", err)
826821 }
827822
828- routes, err := canonicalRoutesForSave(snapshotConfig.Routes)
823+ routes, err := canonicalRoutesForSave(snapshotConfig.Routes, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains)
829824 if err != nil {
830825 return err
831826 }
@@ -879,12 +874,39 @@ func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) {
879874 }
880875 }
881876
882-func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) {
877+func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain string, legacyRegistryDomains []string) map[string][]string {
878+ if attachments == nil {
879+ return nil
880+ }
881+
882+ result := make(map[string][]string, len(attachments))
883+ for target, images := range attachments {
884+ if images == nil {
885+ result[target] = nil
886+ continue
887+ }
888+
889+ canonicalImages := make([]string, len(images))
890+ for i, image := range images {
891+ canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains)
892+ }
893+ result[target] = canonicalImages
894+ }
895+
896+ return result
897+}
898+
899+func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) {
883900 result := make(map[string]routeConfig)
884901 if routes == nil {
885902 return result, nil
886903 }
887904
905+ canonicalizeRoute := func(route routeConfig) routeConfig {
906+ route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains)
907+ return route
908+ }
909+
888910 for key, route := range routes {
889911 if strings.HasPrefix(key, "http://") {
890912 continue
@@ -892,7 +914,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
892914 if !domain.IsValidRouteDomain(key) {
893915 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
894916 }
895- result[key] = route
917+ result[key] = canonicalizeRoute(route)
896918 }
897919
898920 for key, route := range routes {
@@ -908,7 +930,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
908930 continue
909931 }
910932 route.HTTPS = false
911- result[domainName] = route
933+ result[domainName] = canonicalizeRoute(route)
912934 }
913935
914936 return result, nil
@@ -30,6 +30,7 @@ type Config struct {
30 ServerPort int30 ServerPort int
31 RegistryPort int31 RegistryPort int
32 RegistryDomain string32 RegistryDomain string
33+ LegacyRegistryDomains []string
33 DataDir string34 DataDir string
34 AutoRouteEnabled bool35 AutoRouteEnabled bool
35 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`36 AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
@@ -171,10 +172,13 @@ func (s *Service) loadConfigValues() Config {
171 previewSep = "--"172 previewSep = "--"
172 }173 }
173 174
175+ legacyRegistryDomains := append([]string{}, s.viper.GetStringSlice("server.legacy_registry_domains")...)
176+
174 return Config{177 return Config{
175 ServerPort: s.viper.GetInt("server.port"),178 ServerPort: s.viper.GetInt("server.port"),
176 RegistryPort: s.viper.GetInt("server.registry_port"),179 RegistryPort: s.viper.GetInt("server.registry_port"),
177 RegistryDomain: registryDomain,180 RegistryDomain: registryDomain,
181+ LegacyRegistryDomains: legacyRegistryDomains,
178 DataDir: s.viper.GetString("server.data_dir"),182 DataDir: s.viper.GetString("server.data_dir"),
179 AutoRouteEnabled: autoEnabled,183 AutoRouteEnabled: autoEnabled,
180 AutoRouteAllowedDomains: append([]string{}, allowedDomains...),184 AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
@@ -423,7 +427,7 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai
423 if strings.HasPrefix(domainName, "http://") {427 if strings.HasPrefix(domainName, "http://") {
424 continue428 continue
425 }429 }
426- if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {430+ if matchesImageName(imageName, route.Image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) {
427 routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})431 routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
428 }432 }
429 }433 }
@@ -439,7 +443,7 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri
439 var targets []string443 var targets []string
440 for target, images := range s.config.Attachments {444 for target, images := range s.config.Attachments {
441 for _, image := range images {445 for _, image := range images {
442- if matchesImageName(imageName, image, s.config.RegistryDomain) {446+ if matchesImageName(imageName, image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) {
443 targets = append(targets, target)447 targets = append(targets, target)
444 break448 break
445 }449 }
@@ -449,10 +453,10 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri
449 return targets453 return targets
450 }454 }
451 455
452-func matchesImageName(inputImage, candidateImage, registryDomain string) bool {456+func matchesImageName(inputImage, candidateImage, registryDomain string, legacyRegistryDomains []string) bool {
453- normalizedInput := NormalizeRegistryImage(inputImage, registryDomain)457+ normalizedInput := NormalizeRegistryImage(inputImage, registryDomain, legacyRegistryDomains)
454 inputName, inputHasTag := splitImageNameTag(normalizedInput)458 inputName, inputHasTag := splitImageNameTag(normalizedInput)
455- normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain)459+ normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain, legacyRegistryDomains)
456 460
457 if inputHasTag {461 if inputHasTag {
458 return strings.EqualFold(normalizedCandidate, normalizedInput)462 return strings.EqualFold(normalizedCandidate, normalizedInput)
@@ -470,19 +474,10 @@ func splitImageNameTag(image string) (name string, hasTag bool) {
470 return image, false474 return image, false
471 }475 }
472 476
473-// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.477+// NormalizeRegistryImage strips the current or legacy Gordon registry domain
474-func NormalizeRegistryImage(imageName, registryDomain string) string {478+// prefix from an image name for comparison.
475- registryDomain = strings.TrimSuffix(registryDomain, "/")479+func NormalizeRegistryImage(imageName, registryDomain string, legacyRegistryDomains []string) string {
476- if registryDomain == "" {480+ return domain.StripKnownGordonRegistry(imageName, registryDomain, legacyRegistryDomains)
477- return imageName
478- }
479-
480- prefix := registryDomain + "/"
481- if strings.HasPrefix(imageName, prefix) {
482- return strings.TrimPrefix(imageName, prefix)
483- }
484-
485- return imageName
486 }481 }
487 482
488 // NormalizeBootstrapImage converts a user-supplied image argument into483 // NormalizeBootstrapImage converts a user-supplied image argument into
@@ -816,7 +811,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config)
816 811
817 delete(configMap, "routes")812 delete(configMap, "routes")
818 configMap["external_routes"] = snapshotConfig.ExternalRoutes813 configMap["external_routes"] = snapshotConfig.ExternalRoutes
819- configMap["attachments"] = snapshotConfig.Attachments814+ configMap["attachments"] = canonicalAttachmentsForSave(snapshotConfig.Attachments, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains)
820 configMap["network_groups"] = snapshotConfig.NetworkGroups815 configMap["network_groups"] = snapshotConfig.NetworkGroups
821 applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)816 applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
822 817
@@ -825,7 +820,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config)
825 return fmt.Errorf("failed to marshal config: %w", err)820 return fmt.Errorf("failed to marshal config: %w", err)
826 }821 }
827 822
828- routes, err := canonicalRoutesForSave(snapshotConfig.Routes)823+ routes, err := canonicalRoutesForSave(snapshotConfig.Routes, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains)
829 if err != nil {824 if err != nil {
830 return err825 return err
831 }826 }
@@ -879,12 +874,39 @@ func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) {
879 }874 }
880 }875 }
881 876
882-func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) {877+func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain string, legacyRegistryDomains []string) map[string][]string {
878+ if attachments == nil {
879+ return nil
880+ }
881+
882+ result := make(map[string][]string, len(attachments))
883+ for target, images := range attachments {
884+ if images == nil {
885+ result[target] = nil
886+ continue
887+ }
888+
889+ canonicalImages := make([]string, len(images))
890+ for i, image := range images {
891+ canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains)
892+ }
893+ result[target] = canonicalImages
894+ }
895+
896+ return result
897+}
898+
899+func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) {
883 result := make(map[string]routeConfig)900 result := make(map[string]routeConfig)
884 if routes == nil {901 if routes == nil {
885 return result, nil902 return result, nil
886 }903 }
887 904
905+ canonicalizeRoute := func(route routeConfig) routeConfig {
906+ route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains)
907+ return route
908+ }
909+
888 for key, route := range routes {910 for key, route := range routes {
889 if strings.HasPrefix(key, "http://") {911 if strings.HasPrefix(key, "http://") {
890 continue912 continue
@@ -892,7 +914,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
892 if !domain.IsValidRouteDomain(key) {914 if !domain.IsValidRouteDomain(key) {
893 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)915 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
894 }916 }
895- result[key] = route917+ result[key] = canonicalizeRoute(route)
896 }918 }
897 919
898 for key, route := range routes {920 for key, route := range routes {
@@ -908,7 +930,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
908 continue930 continue
909 }931 }
910 route.HTTPS = false932 route.HTTPS = false
911- result[domainName] = route933+ result[domainName] = canonicalizeRoute(route)
912 }934 }
913 935
914 return result, nil936 return result, nil
M internal/usecase/config/service_test.go +226 -17
@@ -217,6 +217,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test
217217 assert.True(t, routes[0].HTTPS)
218218 }
219219
220+func TestCanonicalRoutesForSave(t *testing.T) {
221+ routes := map[string]routeConfig{
222+ "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true},
223+ "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true},
224+ "bare.example.com": {Image: "worker:v2", HTTPS: true},
225+ "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true},
226+ "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true},
227+ }
228+
229+ savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"})
230+ require.NoError(t, err)
231+
232+ assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com"))
233+ assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com"))
234+ assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com"))
235+ assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com"))
236+ assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com"))
237+
238+ assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image)
239+ assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image)
240+}
241+
220242 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221243 tmpDir := t.TempDir()
222244 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -242,9 +264,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
242264 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243265 tmpDir := t.TempDir()
244266 configFile := filepath.Join(tmpDir, "gordon.toml")
245- initialConfig := `[routes]
246-"secure.example.com" = "secure:v1"
247-"http://insecure.example.com" = "insecure:v1"
267+ initialConfig := `[server]
268+gordon_domain = "registry.example.com"
269+legacy_registry_domains = ["old-registry.example.com"]
270+
271+[routes]
272+"secure.example.com" = "old-registry.example.com/secure:v1"
273+"http://insecure.example.com" = "old-registry.example.com/insecure:v1"
248274 `
249275 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250276 require.NoError(t, err)
@@ -266,13 +292,118 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
266292 content, err := os.ReadFile(configFile)
267293 require.NoError(t, err)
268294 text := string(content)
269- assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`)
270- assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`)
295+ assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`)
296+ assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`)
271297 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272298 assert.NotContains(t, text, "http://insecure.example.com")
273299 assert.Contains(t, text, "[routes]")
274300 }
275301
302+func TestService_SaveCanonicalizesRouteImages(t *testing.T) {
303+ tmpDir := t.TempDir()
304+ configFile := filepath.Join(tmpDir, "gordon.toml")
305+ initialConfig := `[server]
306+gordon_domain = "registry.example.com"
307+legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"]
308+
309+[routes]
310+"legacy.example.com" = "old-registry.example.com/app:latest"
311+"legacy-port.example.com" = "old-registry.example.com:5000/api:v2"
312+"current.example.com" = "registry.example.com/web:v3"
313+"bare.example.com" = "worker:v4"
314+"external.example.com" = "docker.io/library/nginx:latest"
315+`
316+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
317+ require.NoError(t, err)
318+
319+ v := viper.New()
320+ v.SetConfigFile(configFile)
321+ require.NoError(t, v.ReadInConfig())
322+
323+ eventBus := mocks.NewMockEventPublisher(t)
324+ svc := NewService(v, eventBus)
325+ ctx := testContext()
326+
327+ require.NoError(t, svc.Load(ctx))
328+ assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image)
329+
330+ require.NoError(t, svc.Save(ctx))
331+
332+ content, err := os.ReadFile(configFile)
333+ require.NoError(t, err)
334+ text := string(content)
335+ assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`)
336+ assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`)
337+ assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`)
338+ assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`)
339+ assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`)
340+}
341+
342+func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) {
343+ tmpDir := t.TempDir()
344+ configFile := filepath.Join(tmpDir, "gordon.toml")
345+ initialConfig := `[server]
346+ gordon_domain = "registry.example.com"
347+ legacy_registry_domains = ["old-registry.example.com"]
348+
349+ [attachments]
350+ "app.example.com" = [
351+ "old-registry.example.com/postgres:18",
352+ "registry.example.com/redis:7",
353+ "rabbitmq:3",
354+ "docker.io/library/nginx:latest",
355+ ]
356+ `
357+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
358+ require.NoError(t, err)
359+
360+ v := viper.New()
361+ v.SetConfigFile(configFile)
362+ require.NoError(t, v.ReadInConfig())
363+
364+ eventBus := mocks.NewMockEventPublisher(t)
365+ svc := NewService(v, eventBus)
366+ ctx := testContext()
367+
368+ require.NoError(t, svc.Load(ctx))
369+ require.Equal(t, []string{
370+ "old-registry.example.com/postgres:18",
371+ "registry.example.com/redis:7",
372+ "rabbitmq:3",
373+ "docker.io/library/nginx:latest",
374+ }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs")
375+
376+ require.NoError(t, svc.Save(ctx))
377+ require.Equal(t, []string{
378+ "old-registry.example.com/postgres:18",
379+ "registry.example.com/redis:7",
380+ "rabbitmq:3",
381+ "docker.io/library/nginx:latest",
382+ }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs")
383+
384+ saved := viper.New()
385+ saved.SetConfigFile(configFile)
386+ require.NoError(t, saved.ReadInConfig())
387+
388+ var attachments map[string][]string
389+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
390+ assert.Equal(t, []string{
391+ "registry.example.com/postgres:18",
392+ "registry.example.com/redis:7",
393+ "rabbitmq:3",
394+ "docker.io/library/nginx:latest",
395+ }, attachments["app.example.com"])
396+
397+ content, err := os.ReadFile(configFile)
398+ require.NoError(t, err)
399+ text := string(content)
400+ assert.Contains(t, text, `registry.example.com/postgres:18`)
401+ assert.Contains(t, text, `registry.example.com/redis:7`)
402+ assert.Contains(t, text, `rabbitmq:3`)
403+ assert.Contains(t, text, `docker.io/library/nginx:latest`)
404+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
405+}
406+
276407 func TestService_Reload(t *testing.T) {
277408 t.Run("success - picks up config file changes", func(t *testing.T) {
278409 // Create temp config file
@@ -1382,6 +1513,47 @@ func TestService_AddAttachment(t *testing.T) {
13821513 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
13831514 })
13841515
1516+ t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) {
1517+ tmpDir := t.TempDir()
1518+ configFile := filepath.Join(tmpDir, "gordon.toml")
1519+ initialConfig := `[server]
1520+ gordon_domain = "registry.example.com"
1521+ legacy_registry_domains = ["old-registry.example.com"]
1522+
1523+ [attachments]
1524+ `
1525+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1526+ require.NoError(t, err)
1527+
1528+ v := viper.New()
1529+ v.SetConfigFile(configFile)
1530+ require.NoError(t, v.ReadInConfig())
1531+
1532+ eventBus := mocks.NewMockEventPublisher(t)
1533+ svc := NewService(v, eventBus)
1534+ ctx := testContext()
1535+ require.NoError(t, svc.Load(ctx))
1536+
1537+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1538+ require.NoError(t, err)
1539+
1540+ require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1541+
1542+ saved := viper.New()
1543+ saved.SetConfigFile(configFile)
1544+ require.NoError(t, saved.ReadInConfig())
1545+
1546+ var attachments map[string][]string
1547+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
1548+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"])
1549+
1550+ content, err := os.ReadFile(configFile)
1551+ require.NoError(t, err)
1552+ text := string(content)
1553+ assert.Contains(t, text, `registry.example.com/postgres:18`)
1554+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
1555+ })
1556+
13851557 t.Run("duplicate attachment", func(t *testing.T) {
13861558 v := viper.New()
13871559 v.Set("attachments", map[string]interface{}{
@@ -1700,23 +1872,25 @@ func TestSplitImageNameTag(t *testing.T) {
17001872
17011873 func TestNormalizeRegistryImage(t *testing.T) {
17021874 tests := []struct {
1703- name string
1704- imageName string
1705- registryDomain string
1706- expected string
1875+ name string
1876+ imageName string
1877+ registryDomain string
1878+ legacyRegistryDomains []string
1879+ expected string
17071880 }{
1708- {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},
1709- {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},
1710- {"empty registry domain", "myapp:latest", "", "myapp:latest"},
1711- {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},
1712- {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},
1713- {"bare name no registry", "myapp", "reg.example.com", "myapp"},
1714- {"exact registry without image", "reg.example.com/", "reg.example.com", ""},
1881+ {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"},
1882+ {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"},
1883+ {"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"},
1884+ {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"},
1885+ {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"},
1886+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"},
1887+ {"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"},
1888+ {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"},
17151889 }
17161890
17171891 for _, tt := range tests {
17181892 t.Run(tt.name, func(t *testing.T) {
1719- result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
1893+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains)
17201894 assert.Equal(t, tt.expected, result)
17211895 })
17221896 }
@@ -1869,6 +2043,24 @@ func TestService_FindRoutesByImage(t *testing.T) {
18692043 assert.Equal(t, "app.example.com", routes[0].Domain)
18702044 })
18712045
2046+ t.Run("matches current image when route stores legacy registry host", func(t *testing.T) {
2047+ v := viper.New()
2048+ v.Set("server.gordon_domain", "new-registry.example.com")
2049+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2050+ v.Set("routes", map[string]interface{}{
2051+ "app.example.com": "old-registry.example.com/myapp:latest",
2052+ })
2053+
2054+ eventBus := mocks.NewMockEventPublisher(t)
2055+ svc := NewService(v, eventBus)
2056+ ctx := testContext()
2057+ _ = svc.Load(ctx)
2058+
2059+ routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest")
2060+ assert.Len(t, routes, 1)
2061+ assert.Equal(t, "app.example.com", routes[0].Domain)
2062+ })
2063+
18722064 t.Run("multiple routes for same image", func(t *testing.T) {
18732065 v := viper.New()
18742066 v.Set("routes", map[string]interface{}{
@@ -1980,6 +2172,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) {
19802172 assert.Equal(t, []string{"backend"}, targets)
19812173 })
19822174
2175+ t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) {
2176+ v := viper.New()
2177+ v.Set("server.gordon_domain", "new-registry.example.com")
2178+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2179+ v.Set("attachments", map[string]interface{}{
2180+ "backend": []interface{}{"old-registry.example.com/postgres:18"},
2181+ })
2182+
2183+ eventBus := mocks.NewMockEventPublisher(t)
2184+ svc := NewService(v, eventBus)
2185+ ctx := testContext()
2186+ _ = svc.Load(ctx)
2187+
2188+ targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18")
2189+ assert.Equal(t, []string{"backend"}, targets)
2190+ })
2191+
19832192 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
19842193 v := viper.New()
19852194 v.Set("attachments", map[string]interface{}{
@@ -217,6 +217,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test
217 assert.True(t, routes[0].HTTPS)217 assert.True(t, routes[0].HTTPS)
218 }218 }
219 219
220+func TestCanonicalRoutesForSave(t *testing.T) {
221+ routes := map[string]routeConfig{
222+ "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true},
223+ "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true},
224+ "bare.example.com": {Image: "worker:v2", HTTPS: true},
225+ "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true},
226+ "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true},
227+ }
228+
229+ savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"})
230+ require.NoError(t, err)
231+
232+ assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com"))
233+ assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com"))
234+ assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com"))
235+ assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com"))
236+ assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com"))
237+
238+ assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image)
239+ assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image)
240+}
241+
220 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {242 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221 tmpDir := t.TempDir()243 tmpDir := t.TempDir()
222 configFile := filepath.Join(tmpDir, "gordon.toml")244 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -242,9 +264,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
242 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {264 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243 tmpDir := t.TempDir()265 tmpDir := t.TempDir()
244 configFile := filepath.Join(tmpDir, "gordon.toml")266 configFile := filepath.Join(tmpDir, "gordon.toml")
245- initialConfig := `[routes]267+ initialConfig := `[server]
246-"secure.example.com" = "secure:v1"268+gordon_domain = "registry.example.com"
247-"http://insecure.example.com" = "insecure:v1"269+legacy_registry_domains = ["old-registry.example.com"]
270+
271+[routes]
272+"secure.example.com" = "old-registry.example.com/secure:v1"
273+"http://insecure.example.com" = "old-registry.example.com/insecure:v1"
248 `274 `
249 err := os.WriteFile(configFile, []byte(initialConfig), 0600)275 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250 require.NoError(t, err)276 require.NoError(t, err)
@@ -266,13 +292,118 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
266 content, err := os.ReadFile(configFile)292 content, err := os.ReadFile(configFile)
267 require.NoError(t, err)293 require.NoError(t, err)
268 text := string(content)294 text := string(content)
269- assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`)295+ assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`)
270- assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`)296+ assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`)
271 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)297 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272 assert.NotContains(t, text, "http://insecure.example.com")298 assert.NotContains(t, text, "http://insecure.example.com")
273 assert.Contains(t, text, "[routes]")299 assert.Contains(t, text, "[routes]")
274 }300 }
275 301
302+func TestService_SaveCanonicalizesRouteImages(t *testing.T) {
303+ tmpDir := t.TempDir()
304+ configFile := filepath.Join(tmpDir, "gordon.toml")
305+ initialConfig := `[server]
306+gordon_domain = "registry.example.com"
307+legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"]
308+
309+[routes]
310+"legacy.example.com" = "old-registry.example.com/app:latest"
311+"legacy-port.example.com" = "old-registry.example.com:5000/api:v2"
312+"current.example.com" = "registry.example.com/web:v3"
313+"bare.example.com" = "worker:v4"
314+"external.example.com" = "docker.io/library/nginx:latest"
315+`
316+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
317+ require.NoError(t, err)
318+
319+ v := viper.New()
320+ v.SetConfigFile(configFile)
321+ require.NoError(t, v.ReadInConfig())
322+
323+ eventBus := mocks.NewMockEventPublisher(t)
324+ svc := NewService(v, eventBus)
325+ ctx := testContext()
326+
327+ require.NoError(t, svc.Load(ctx))
328+ assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image)
329+
330+ require.NoError(t, svc.Save(ctx))
331+
332+ content, err := os.ReadFile(configFile)
333+ require.NoError(t, err)
334+ text := string(content)
335+ assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`)
336+ assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`)
337+ assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`)
338+ assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`)
339+ assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`)
340+}
341+
342+func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) {
343+ tmpDir := t.TempDir()
344+ configFile := filepath.Join(tmpDir, "gordon.toml")
345+ initialConfig := `[server]
346+ gordon_domain = "registry.example.com"
347+ legacy_registry_domains = ["old-registry.example.com"]
348+
349+ [attachments]
350+ "app.example.com" = [
351+ "old-registry.example.com/postgres:18",
352+ "registry.example.com/redis:7",
353+ "rabbitmq:3",
354+ "docker.io/library/nginx:latest",
355+ ]
356+ `
357+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
358+ require.NoError(t, err)
359+
360+ v := viper.New()
361+ v.SetConfigFile(configFile)
362+ require.NoError(t, v.ReadInConfig())
363+
364+ eventBus := mocks.NewMockEventPublisher(t)
365+ svc := NewService(v, eventBus)
366+ ctx := testContext()
367+
368+ require.NoError(t, svc.Load(ctx))
369+ require.Equal(t, []string{
370+ "old-registry.example.com/postgres:18",
371+ "registry.example.com/redis:7",
372+ "rabbitmq:3",
373+ "docker.io/library/nginx:latest",
374+ }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs")
375+
376+ require.NoError(t, svc.Save(ctx))
377+ require.Equal(t, []string{
378+ "old-registry.example.com/postgres:18",
379+ "registry.example.com/redis:7",
380+ "rabbitmq:3",
381+ "docker.io/library/nginx:latest",
382+ }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs")
383+
384+ saved := viper.New()
385+ saved.SetConfigFile(configFile)
386+ require.NoError(t, saved.ReadInConfig())
387+
388+ var attachments map[string][]string
389+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
390+ assert.Equal(t, []string{
391+ "registry.example.com/postgres:18",
392+ "registry.example.com/redis:7",
393+ "rabbitmq:3",
394+ "docker.io/library/nginx:latest",
395+ }, attachments["app.example.com"])
396+
397+ content, err := os.ReadFile(configFile)
398+ require.NoError(t, err)
399+ text := string(content)
400+ assert.Contains(t, text, `registry.example.com/postgres:18`)
401+ assert.Contains(t, text, `registry.example.com/redis:7`)
402+ assert.Contains(t, text, `rabbitmq:3`)
403+ assert.Contains(t, text, `docker.io/library/nginx:latest`)
404+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
405+}
406+
276 func TestService_Reload(t *testing.T) {407 func TestService_Reload(t *testing.T) {
277 t.Run("success - picks up config file changes", func(t *testing.T) {408 t.Run("success - picks up config file changes", func(t *testing.T) {
278 // Create temp config file409 // Create temp config file
@@ -1382,6 +1513,47 @@ func TestService_AddAttachment(t *testing.T) {
1382 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])1513 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
1383 })1514 })
1384 1515
1516+ t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) {
1517+ tmpDir := t.TempDir()
1518+ configFile := filepath.Join(tmpDir, "gordon.toml")
1519+ initialConfig := `[server]
1520+ gordon_domain = "registry.example.com"
1521+ legacy_registry_domains = ["old-registry.example.com"]
1522+
1523+ [attachments]
1524+ `
1525+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1526+ require.NoError(t, err)
1527+
1528+ v := viper.New()
1529+ v.SetConfigFile(configFile)
1530+ require.NoError(t, v.ReadInConfig())
1531+
1532+ eventBus := mocks.NewMockEventPublisher(t)
1533+ svc := NewService(v, eventBus)
1534+ ctx := testContext()
1535+ require.NoError(t, svc.Load(ctx))
1536+
1537+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1538+ require.NoError(t, err)
1539+
1540+ require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1541+
1542+ saved := viper.New()
1543+ saved.SetConfigFile(configFile)
1544+ require.NoError(t, saved.ReadInConfig())
1545+
1546+ var attachments map[string][]string
1547+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
1548+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"])
1549+
1550+ content, err := os.ReadFile(configFile)
1551+ require.NoError(t, err)
1552+ text := string(content)
1553+ assert.Contains(t, text, `registry.example.com/postgres:18`)
1554+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
1555+ })
1556+
1385 t.Run("duplicate attachment", func(t *testing.T) {1557 t.Run("duplicate attachment", func(t *testing.T) {
1386 v := viper.New()1558 v := viper.New()
1387 v.Set("attachments", map[string]interface{}{1559 v.Set("attachments", map[string]interface{}{
@@ -1700,23 +1872,25 @@ func TestSplitImageNameTag(t *testing.T) {
1700 1872
1701 func TestNormalizeRegistryImage(t *testing.T) {1873 func TestNormalizeRegistryImage(t *testing.T) {
1702 tests := []struct {1874 tests := []struct {
1703- name string1875+ name string
1704- imageName string1876+ imageName string
1705- registryDomain string1877+ registryDomain string
1706- expected string1878+ legacyRegistryDomains []string
1879+ expected string
1707 }{1880 }{
1708- {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},1881+ {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"},
1709- {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"},1882+ {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"},
1710- {"empty registry domain", "myapp:latest", "", "myapp:latest"},1883+ {"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"},
1711- {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"},1884+ {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"},
1712- {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"},1885+ {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"},
1713- {"bare name no registry", "myapp", "reg.example.com", "myapp"},1886+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"},
1714- {"exact registry without image", "reg.example.com/", "reg.example.com", ""},1887+ {"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"},
1888+ {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"},
1715 }1889 }
1716 1890
1717 for _, tt := range tests {1891 for _, tt := range tests {
1718 t.Run(tt.name, func(t *testing.T) {1892 t.Run(tt.name, func(t *testing.T) {
1719- result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)1893+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains)
1720 assert.Equal(t, tt.expected, result)1894 assert.Equal(t, tt.expected, result)
1721 })1895 })
1722 }1896 }
@@ -1869,6 +2043,24 @@ func TestService_FindRoutesByImage(t *testing.T) {
1869 assert.Equal(t, "app.example.com", routes[0].Domain)2043 assert.Equal(t, "app.example.com", routes[0].Domain)
1870 })2044 })
1871 2045
2046+ t.Run("matches current image when route stores legacy registry host", func(t *testing.T) {
2047+ v := viper.New()
2048+ v.Set("server.gordon_domain", "new-registry.example.com")
2049+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2050+ v.Set("routes", map[string]interface{}{
2051+ "app.example.com": "old-registry.example.com/myapp:latest",
2052+ })
2053+
2054+ eventBus := mocks.NewMockEventPublisher(t)
2055+ svc := NewService(v, eventBus)
2056+ ctx := testContext()
2057+ _ = svc.Load(ctx)
2058+
2059+ routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest")
2060+ assert.Len(t, routes, 1)
2061+ assert.Equal(t, "app.example.com", routes[0].Domain)
2062+ })
2063+
1872 t.Run("multiple routes for same image", func(t *testing.T) {2064 t.Run("multiple routes for same image", func(t *testing.T) {
1873 v := viper.New()2065 v := viper.New()
1874 v.Set("routes", map[string]interface{}{2066 v.Set("routes", map[string]interface{}{
@@ -1980,6 +2172,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) {
1980 assert.Equal(t, []string{"backend"}, targets)2172 assert.Equal(t, []string{"backend"}, targets)
1981 })2173 })
1982 2174
2175+ t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) {
2176+ v := viper.New()
2177+ v.Set("server.gordon_domain", "new-registry.example.com")
2178+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2179+ v.Set("attachments", map[string]interface{}{
2180+ "backend": []interface{}{"old-registry.example.com/postgres:18"},
2181+ })
2182+
2183+ eventBus := mocks.NewMockEventPublisher(t)
2184+ svc := NewService(v, eventBus)
2185+ ctx := testContext()
2186+ _ = svc.Load(ctx)
2187+
2188+ targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18")
2189+ assert.Equal(t, []string{"backend"}, targets)
2190+ })
2191+
1983 t.Run("shared attachment used by multiple targets", func(t *testing.T) {2192 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
1984 v := viper.New()2193 v := viper.New()
1985 v.Set("attachments", map[string]interface{}{2194 v.Set("attachments", map[string]interface{}{
M internal/usecase/container/autoroute.go +16 -13
@@ -24,13 +24,14 @@ type EnvFileExtractor interface {
2424
2525 // AutoRouteHandler handles image.pushed events for auto-route from labels.
2626 type AutoRouteHandler struct {
27- configSvc in.ConfigService
28- containerSvc in.ContainerService
29- blobStorage out.BlobStorage
30- extractor EnvFileExtractor
31- registryDomain string
32- envDir string
33- ctx context.Context
27+ configSvc in.ConfigService
28+ containerSvc in.ContainerService
29+ blobStorage out.BlobStorage
30+ extractor EnvFileExtractor
31+ registryDomain string
32+ legacyRegistryDomains []string
33+ envDir string
34+ ctx context.Context
3435 }
3536
3637 // NewAutoRouteHandler creates a new AutoRouteHandler.
@@ -40,13 +41,15 @@ func NewAutoRouteHandler(
4041 containerSvc in.ContainerService,
4142 blobStorage out.BlobStorage,
4243 registryDomain string,
44+ legacyRegistryDomains ...string,
4345 ) *AutoRouteHandler {
4446 return &AutoRouteHandler{
45- configSvc: configSvc,
46- containerSvc: containerSvc,
47- blobStorage: blobStorage,
48- registryDomain: registryDomain,
49- ctx: ctx,
47+ configSvc: configSvc,
48+ containerSvc: containerSvc,
49+ blobStorage: blobStorage,
50+ registryDomain: registryDomain,
51+ legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...),
52+ ctx: ctx,
5053 }
5154 }
5255
@@ -212,7 +215,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
212215 for _, existing := range existingRoutes {
213216 if existing.Domain == routeDomain {
214217 route.HTTPS = existing.HTTPS
215- if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
218+ if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) {
216219 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
217220 return false
218221 }
@@ -24,13 +24,14 @@ type EnvFileExtractor interface {
24 24
25 // AutoRouteHandler handles image.pushed events for auto-route from labels.25 // AutoRouteHandler handles image.pushed events for auto-route from labels.
26 type AutoRouteHandler struct {26 type AutoRouteHandler struct {
27- configSvc in.ConfigService27+ configSvc in.ConfigService
28- containerSvc in.ContainerService28+ containerSvc in.ContainerService
29- blobStorage out.BlobStorage29+ blobStorage out.BlobStorage
30- extractor EnvFileExtractor30+ extractor EnvFileExtractor
31- registryDomain string31+ registryDomain string
32- envDir string32+ legacyRegistryDomains []string
33- ctx context.Context33+ envDir string
34+ ctx context.Context
34 }35 }
35 36
36 // NewAutoRouteHandler creates a new AutoRouteHandler.37 // NewAutoRouteHandler creates a new AutoRouteHandler.
@@ -40,13 +41,15 @@ func NewAutoRouteHandler(
40 containerSvc in.ContainerService,41 containerSvc in.ContainerService,
41 blobStorage out.BlobStorage,42 blobStorage out.BlobStorage,
42 registryDomain string,43 registryDomain string,
44+ legacyRegistryDomains ...string,
43 ) *AutoRouteHandler {45 ) *AutoRouteHandler {
44 return &AutoRouteHandler{46 return &AutoRouteHandler{
45- configSvc: configSvc,47+ configSvc: configSvc,
46- containerSvc: containerSvc,48+ containerSvc: containerSvc,
47- blobStorage: blobStorage,49+ blobStorage: blobStorage,
48- registryDomain: registryDomain,50+ registryDomain: registryDomain,
49- ctx: ctx,51+ legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...),
52+ ctx: ctx,
50 }53 }
51 }54 }
52 55
@@ -212,7 +215,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
212 for _, existing := range existingRoutes {215 for _, existing := range existingRoutes {
213 if existing.Domain == routeDomain {216 if existing.Domain == routeDomain {
214 route.HTTPS = existing.HTTPS217 route.HTTPS = existing.HTTPS
215- if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {218+ if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) {
216 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")219 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
217 return false220 return false
218 }221 }
M internal/usecase/container/autoroute_test.go +44 -0
@@ -829,6 +829,33 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
829829 assert.False(t, created)
830830 }
831831
832+func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) {
833+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834+ configSvc := inmocks.NewMockConfigService(t)
835+ containerSvc := inmocks.NewMockContainerService(t)
836+ blobStorage := mocks.NewMockBlobStorage(t)
837+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
838+
839+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
840+ Domain: "app.example.com",
841+ Image: "old-registry.example.com/app:v1",
842+ HTTPS: true,
843+ }})
844+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
845+ Domain: "app.example.com",
846+ Image: "new-registry.example.com/app:v2",
847+ HTTPS: true,
848+ }).Return(nil)
849+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
850+ Domain: "app.example.com",
851+ Image: "new-registry.example.com/app:v2",
852+ HTTPS: true,
853+ }).Return(&domain.Container{ID: "c1"}, nil)
854+
855+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
856+ assert.False(t, created)
857+}
858+
832859 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
833860 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834861 configSvc := inmocks.NewMockConfigService(t)
@@ -842,6 +869,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
842869 assert.False(t, created)
843870 }
844871
872+func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) {
873+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
874+ configSvc := inmocks.NewMockConfigService(t)
875+ containerSvc := inmocks.NewMockContainerService(t)
876+ blobStorage := mocks.NewMockBlobStorage(t)
877+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
878+
879+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
880+ Domain: "app.example.com",
881+ Image: "old-registry.example.com/oldapp:v1",
882+ HTTPS: true,
883+ }})
884+
885+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"})
886+ assert.False(t, created)
887+}
888+
845889 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
846890 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
847891 configSvc := inmocks.NewMockConfigService(t)
@@ -829,6 +829,33 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
829 assert.False(t, created)829 assert.False(t, created)
830 }830 }
831 831
832+func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) {
833+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834+ configSvc := inmocks.NewMockConfigService(t)
835+ containerSvc := inmocks.NewMockContainerService(t)
836+ blobStorage := mocks.NewMockBlobStorage(t)
837+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
838+
839+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
840+ Domain: "app.example.com",
841+ Image: "old-registry.example.com/app:v1",
842+ HTTPS: true,
843+ }})
844+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
845+ Domain: "app.example.com",
846+ Image: "new-registry.example.com/app:v2",
847+ HTTPS: true,
848+ }).Return(nil)
849+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
850+ Domain: "app.example.com",
851+ Image: "new-registry.example.com/app:v2",
852+ HTTPS: true,
853+ }).Return(&domain.Container{ID: "c1"}, nil)
854+
855+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
856+ assert.False(t, created)
857+}
858+
832 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {859 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
833 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())860 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834 configSvc := inmocks.NewMockConfigService(t)861 configSvc := inmocks.NewMockConfigService(t)
@@ -842,6 +869,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
842 assert.False(t, created)869 assert.False(t, created)
843 }870 }
844 871
872+func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) {
873+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
874+ configSvc := inmocks.NewMockConfigService(t)
875+ containerSvc := inmocks.NewMockContainerService(t)
876+ blobStorage := mocks.NewMockBlobStorage(t)
877+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
878+
879+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
880+ Domain: "app.example.com",
881+ Image: "old-registry.example.com/oldapp:v1",
882+ HTTPS: true,
883+ }})
884+
885+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"})
886+ assert.False(t, created)
887+}
888+
845 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {889 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
846 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())890 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
847 configSvc := inmocks.NewMockConfigService(t)891 configSvc := inmocks.NewMockConfigService(t)
M internal/usecase/container/service.go +17 -18
@@ -33,6 +33,7 @@ import (
3333 type Config struct {
3434 RegistryAuthEnabled bool
3535 RegistryDomain string
36+ LegacyRegistryDomains []string
3637 RegistryPort int
3738 ServiceTokenUsername string
3839 ServiceToken string
@@ -1340,14 +1341,7 @@ func (s *Service) stripRegistryPrefix(image string) string {
13401341 cfg := s.config
13411342 s.mu.RUnlock()
13421343
1343- if cfg.RegistryDomain == "" {
1344- return image
1345- }
1346- prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
1347- if strings.HasPrefix(image, prefix) {
1348- return strings.TrimPrefix(image, prefix)
1349- }
1350- return image
1344+ return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains)
13511345 }
13521346
13531347 // ListAttachments returns attachments for a domain.
@@ -1823,7 +1817,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist
18231817 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)
18241818 }
18251819
1826- if sameRegistry(registry, cfg.RegistryDomain) {
1820+ if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) {
18271821 return nil
18281822 }
18291823 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))
@@ -1975,7 +1969,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
19751969 s.mu.RLock()
19761970 cfg := s.config
19771971 s.mu.RUnlock()
1978- return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
1972+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true
19791973 }
19801974
19811975 // ensureImage ensures the image is available locally, pulling if needed.
@@ -3360,21 +3354,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
33603354 return prefix + imageRef
33613355 }
33623356
3363-// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
3364-// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
3365-func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
3357+// rewriteToLocalRegistry rewrites Gordon-managed image references to use the
3358+// local registry address. Current and legacy Gordon registry domains are
3359+// stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed.
3360+// External registries are preserved unchanged.
3361+func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string {
33663362 if imageRef == "" {
33673363 return imageRef
33683364 }
33693365
33703366 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
33713367 localPrefix := localRegistry + "/"
3372- imageRef = strings.TrimPrefix(imageRef, localPrefix)
3368+ if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok {
3369+ return localPrefix + remainder
3370+ }
33733371
3374- registryDomain = strings.TrimSuffix(registryDomain, "/")
3375- if registryDomain != "" {
3376- prefix := registryDomain + "/"
3377- imageRef = strings.TrimPrefix(imageRef, prefix)
3372+ if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) {
3373+ return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains)
3374+ }
3375+ if hasExplicitRegistry(imageRef) {
3376+ return imageRef
33783377 }
33793378
33803379 return localPrefix + imageRef
@@ -33,6 +33,7 @@ import (
33 type Config struct {33 type Config struct {
34 RegistryAuthEnabled bool34 RegistryAuthEnabled bool
35 RegistryDomain string35 RegistryDomain string
36+ LegacyRegistryDomains []string
36 RegistryPort int37 RegistryPort int
37 ServiceTokenUsername string38 ServiceTokenUsername string
38 ServiceToken string39 ServiceToken string
@@ -1340,14 +1341,7 @@ func (s *Service) stripRegistryPrefix(image string) string {
1340 cfg := s.config1341 cfg := s.config
1341 s.mu.RUnlock()1342 s.mu.RUnlock()
1342 1343
1343- if cfg.RegistryDomain == "" {1344+ return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains)
1344- return image
1345- }
1346- prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
1347- if strings.HasPrefix(image, prefix) {
1348- return strings.TrimPrefix(image, prefix)
1349- }
1350- return image
1351 }1345 }
1352 1346
1353 // ListAttachments returns attachments for a domain.1347 // ListAttachments returns attachments for a domain.
@@ -1823,7 +1817,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist
1823 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)1817 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)
1824 }1818 }
1825 1819
1826- if sameRegistry(registry, cfg.RegistryDomain) {1820+ if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) {
1827 return nil1821 return nil
1828 }1822 }
1829 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))1823 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))
@@ -1975,7 +1969,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
1975 s.mu.RLock()1969 s.mu.RLock()
1976 cfg := s.config1970 cfg := s.config
1977 s.mu.RUnlock()1971 s.mu.RUnlock()
1978- return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true1972+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true
1979 }1973 }
1980 1974
1981 // ensureImage ensures the image is available locally, pulling if needed.1975 // ensureImage ensures the image is available locally, pulling if needed.
@@ -3360,21 +3354,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
3360 return prefix + imageRef3354 return prefix + imageRef
3361 }3355 }
3362 3356
3363-// rewriteToLocalRegistry rewrites an image reference to use the local registry address.3357+// rewriteToLocalRegistry rewrites Gordon-managed image references to use the
3364-// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"3358+// local registry address. Current and legacy Gordon registry domains are
3365-func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {3359+// stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed.
3360+// External registries are preserved unchanged.
3361+func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string {
3366 if imageRef == "" {3362 if imageRef == "" {
3367 return imageRef3363 return imageRef
3368 }3364 }
3369 3365
3370 localRegistry := fmt.Sprintf("localhost:%d", registryPort)3366 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
3371 localPrefix := localRegistry + "/"3367 localPrefix := localRegistry + "/"
3372- imageRef = strings.TrimPrefix(imageRef, localPrefix)3368+ if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok {
3369+ return localPrefix + remainder
3370+ }
3373 3371
3374- registryDomain = strings.TrimSuffix(registryDomain, "/")3372+ if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) {
3375- if registryDomain != "" {3373+ return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains)
3376- prefix := registryDomain + "/"3374+ }
3377- imageRef = strings.TrimPrefix(imageRef, prefix)3375+ if hasExplicitRegistry(imageRef) {
3376+ return imageRef
3378 }3377 }
3379 3378
3380 return localPrefix + imageRef3379 return localPrefix + imageRef
M internal/usecase/container/service_test.go +111 -16
@@ -1992,11 +1992,12 @@ func TestRewriteToRegistryDomain(t *testing.T) {
19921992
19931993 func TestRewriteToLocalRegistry(t *testing.T) {
19941994 tests := []struct {
1995- name string
1996- imageRef string
1997- registryDomain string
1998- registryPort int
1999- wantRef string
1995+ name string
1996+ imageRef string
1997+ registryDomain string
1998+ legacyRegistryDomains []string
1999+ registryPort int
2000+ wantRef string
20002001 }{
20012002 {
20022003 name: "rewrites registry domain prefix",
@@ -2020,17 +2021,42 @@ func TestRewriteToLocalRegistry(t *testing.T) {
20202021 wantRef: "localhost:5000/myapp:latest",
20212022 },
20222023 {
2023- name: "prefixes external image path",
2024- imageRef: "docker.io/library/nginx:latest",
2025- registryDomain: "registry.example.com",
2026- registryPort: 5000,
2027- wantRef: "localhost:5000/docker.io/library/nginx:latest",
2024+ name: "rewrites legacy registry domain prefix",
2025+ imageRef: "old-registry.example.com/myapp:latest",
2026+ registryDomain: "new-registry.example.com",
2027+ legacyRegistryDomains: []string{"old-registry.example.com"},
2028+ registryPort: 5000,
2029+ wantRef: "localhost:5000/myapp:latest",
2030+ },
2031+ {
2032+ name: "rewrites legacy registry domain prefix with explicit port and digest",
2033+ imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef",
2034+ registryDomain: "new-registry.example.com",
2035+ legacyRegistryDomains: []string{"old-registry.example.com:5001"},
2036+ registryPort: 5000,
2037+ wantRef: "localhost:5000/myapp@sha256:deadbeef",
2038+ },
2039+ {
2040+ name: "external image remains external",
2041+ imageRef: "docker.io/library/nginx:latest",
2042+ registryDomain: "new-registry.example.com",
2043+ legacyRegistryDomains: []string{"old-registry.example.com"},
2044+ registryPort: 5000,
2045+ wantRef: "docker.io/library/nginx:latest",
2046+ },
2047+ {
2048+ name: "hostile lookalike remains external",
2049+ imageRef: "old-registry.example.com.evil/myapp:latest",
2050+ registryDomain: "new-registry.example.com",
2051+ legacyRegistryDomains: []string{"old-registry.example.com"},
2052+ registryPort: 5000,
2053+ wantRef: "old-registry.example.com.evil/myapp:latest",
20282054 },
20292055 }
20302056
20312057 for _, tt := range tests {
20322058 t.Run(tt.name, func(t *testing.T) {
2033- gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
2059+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
20342060 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
20352061 })
20362062 }
@@ -2116,6 +2142,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
21162142 assert.Equal(t, "container-123", result.ID)
21172143 }
21182144
2145+func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2146+ runtime := mocks.NewMockContainerRuntime(t)
2147+ envLoader := mocks.NewMockEnvLoader(t)
2148+ eventBus := mocks.NewMockEventPublisher(t)
2149+
2150+ config := Config{
2151+ AllowedRegistries: []string{"docker.io"},
2152+ RegistryAuthEnabled: true,
2153+ RegistryDomain: "new-registry.example.com",
2154+ LegacyRegistryDomains: []string{"old-registry.example.com"},
2155+ RegistryPort: 5000,
2156+ InternalRegistryUsername: "internal",
2157+ InternalRegistryPassword: "secret",
2158+ ReadinessDelay: time.Millisecond,
2159+ DrainDelay: time.Millisecond,
2160+ DrainDelayConfigured: true,
2161+ }
2162+
2163+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2164+ ctx := domain.WithInternalDeploy(testContext())
2165+
2166+ route := domain.Route{
2167+ Domain: "test.example.com",
2168+ Image: "old-registry.example.com/myapp:latest",
2169+ }
2170+
2171+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2172+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2173+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2174+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2175+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2176+
2177+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2178+ runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2179+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2180+ runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2181+
2182+ createdContainer := &domain.Container{
2183+ ID: "container-legacy",
2184+ Name: "gordon-test.example.com",
2185+ Status: "created",
2186+ }
2187+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2188+ runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2189+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2190+ runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2191+ ID: "container-legacy",
2192+ Name: "gordon-test.example.com",
2193+ Status: "running",
2194+ Ports: []int{8080},
2195+ }, nil)
2196+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2197+
2198+ result, err := svc.Deploy(ctx, route)
2199+
2200+ assert.NoError(t, err)
2201+ assert.NotNil(t, result)
2202+ assert.Equal(t, "container-legacy", result.ID)
2203+}
2204+
21192205 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
21202206 runtime := mocks.NewMockContainerRuntime(t)
21212207 envLoader := mocks.NewMockEnvLoader(t)
@@ -2609,10 +2695,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
26092695
26102696 func TestService_StripRegistryPrefix(t *testing.T) {
26112697 tests := []struct {
2612- name string
2613- registryDomain string
2614- image string
2615- expected string
2698+ name string
2699+ registryDomain string
2700+ legacyRegistryDomains []string
2701+ image string
2702+ expected string
26162703 }{
26172704 {
26182705 name: "strips registry prefix",
@@ -2626,6 +2713,13 @@ func TestService_StripRegistryPrefix(t *testing.T) {
26262713 image: "reg.example.com/myapp:v1.0",
26272714 expected: "myapp:v1.0",
26282715 },
2716+ {
2717+ name: "strips legacy registry prefix",
2718+ registryDomain: "new-reg.example.com",
2719+ legacyRegistryDomains: []string{"old-reg.example.com"},
2720+ image: "old-reg.example.com/myapp:latest",
2721+ expected: "myapp:latest",
2722+ },
26292723 {
26302724 name: "preserves image without registry prefix",
26312725 registryDomain: "reg.example.com",
@@ -2659,7 +2753,8 @@ func TestService_StripRegistryPrefix(t *testing.T) {
26592753 eventBus := mocks.NewMockEventPublisher(t)
26602754
26612755 config := Config{
2662- RegistryDomain: tt.registryDomain,
2756+ RegistryDomain: tt.registryDomain,
2757+ LegacyRegistryDomains: tt.legacyRegistryDomains,
26632758 }
26642759 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
26652760
@@ -1992,11 +1992,12 @@ func TestRewriteToRegistryDomain(t *testing.T) {
1992 1992
1993 func TestRewriteToLocalRegistry(t *testing.T) {1993 func TestRewriteToLocalRegistry(t *testing.T) {
1994 tests := []struct {1994 tests := []struct {
1995- name string1995+ name string
1996- imageRef string1996+ imageRef string
1997- registryDomain string1997+ registryDomain string
1998- registryPort int1998+ legacyRegistryDomains []string
1999- wantRef string1999+ registryPort int
2000+ wantRef string
2000 }{2001 }{
2001 {2002 {
2002 name: "rewrites registry domain prefix",2003 name: "rewrites registry domain prefix",
@@ -2020,17 +2021,42 @@ func TestRewriteToLocalRegistry(t *testing.T) {
2020 wantRef: "localhost:5000/myapp:latest",2021 wantRef: "localhost:5000/myapp:latest",
2021 },2022 },
2022 {2023 {
2023- name: "prefixes external image path",2024+ name: "rewrites legacy registry domain prefix",
2024- imageRef: "docker.io/library/nginx:latest",2025+ imageRef: "old-registry.example.com/myapp:latest",
2025- registryDomain: "registry.example.com",2026+ registryDomain: "new-registry.example.com",
2026- registryPort: 5000,2027+ legacyRegistryDomains: []string{"old-registry.example.com"},
2027- wantRef: "localhost:5000/docker.io/library/nginx:latest",2028+ registryPort: 5000,
2029+ wantRef: "localhost:5000/myapp:latest",
2030+ },
2031+ {
2032+ name: "rewrites legacy registry domain prefix with explicit port and digest",
2033+ imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef",
2034+ registryDomain: "new-registry.example.com",
2035+ legacyRegistryDomains: []string{"old-registry.example.com:5001"},
2036+ registryPort: 5000,
2037+ wantRef: "localhost:5000/myapp@sha256:deadbeef",
2038+ },
2039+ {
2040+ name: "external image remains external",
2041+ imageRef: "docker.io/library/nginx:latest",
2042+ registryDomain: "new-registry.example.com",
2043+ legacyRegistryDomains: []string{"old-registry.example.com"},
2044+ registryPort: 5000,
2045+ wantRef: "docker.io/library/nginx:latest",
2046+ },
2047+ {
2048+ name: "hostile lookalike remains external",
2049+ imageRef: "old-registry.example.com.evil/myapp:latest",
2050+ registryDomain: "new-registry.example.com",
2051+ legacyRegistryDomains: []string{"old-registry.example.com"},
2052+ registryPort: 5000,
2053+ wantRef: "old-registry.example.com.evil/myapp:latest",
2028 },2054 },
2029 }2055 }
2030 2056
2031 for _, tt := range tests {2057 for _, tt := range tests {
2032 t.Run(tt.name, func(t *testing.T) {2058 t.Run(tt.name, func(t *testing.T) {
2033- gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)2059+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
2034 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")2060 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2035 })2061 })
2036 }2062 }
@@ -2116,6 +2142,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
2116 assert.Equal(t, "container-123", result.ID)2142 assert.Equal(t, "container-123", result.ID)
2117 }2143 }
2118 2144
2145+func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2146+ runtime := mocks.NewMockContainerRuntime(t)
2147+ envLoader := mocks.NewMockEnvLoader(t)
2148+ eventBus := mocks.NewMockEventPublisher(t)
2149+
2150+ config := Config{
2151+ AllowedRegistries: []string{"docker.io"},
2152+ RegistryAuthEnabled: true,
2153+ RegistryDomain: "new-registry.example.com",
2154+ LegacyRegistryDomains: []string{"old-registry.example.com"},
2155+ RegistryPort: 5000,
2156+ InternalRegistryUsername: "internal",
2157+ InternalRegistryPassword: "secret",
2158+ ReadinessDelay: time.Millisecond,
2159+ DrainDelay: time.Millisecond,
2160+ DrainDelayConfigured: true,
2161+ }
2162+
2163+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2164+ ctx := domain.WithInternalDeploy(testContext())
2165+
2166+ route := domain.Route{
2167+ Domain: "test.example.com",
2168+ Image: "old-registry.example.com/myapp:latest",
2169+ }
2170+
2171+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2172+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2173+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2174+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2175+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2176+
2177+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2178+ runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2179+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2180+ runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2181+
2182+ createdContainer := &domain.Container{
2183+ ID: "container-legacy",
2184+ Name: "gordon-test.example.com",
2185+ Status: "created",
2186+ }
2187+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2188+ runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2189+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2190+ runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2191+ ID: "container-legacy",
2192+ Name: "gordon-test.example.com",
2193+ Status: "running",
2194+ Ports: []int{8080},
2195+ }, nil)
2196+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2197+
2198+ result, err := svc.Deploy(ctx, route)
2199+
2200+ assert.NoError(t, err)
2201+ assert.NotNil(t, result)
2202+ assert.Equal(t, "container-legacy", result.ID)
2203+}
2204+
2119 func TestService_AutoStart_StartsNewContainers(t *testing.T) {2205 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2120 runtime := mocks.NewMockContainerRuntime(t)2206 runtime := mocks.NewMockContainerRuntime(t)
2121 envLoader := mocks.NewMockEnvLoader(t)2207 envLoader := mocks.NewMockEnvLoader(t)
@@ -2609,10 +2695,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
2609 2695
2610 func TestService_StripRegistryPrefix(t *testing.T) {2696 func TestService_StripRegistryPrefix(t *testing.T) {
2611 tests := []struct {2697 tests := []struct {
2612- name string2698+ name string
2613- registryDomain string2699+ registryDomain string
2614- image string2700+ legacyRegistryDomains []string
2615- expected string2701+ image string
2702+ expected string
2616 }{2703 }{
2617 {2704 {
2618 name: "strips registry prefix",2705 name: "strips registry prefix",
@@ -2626,6 +2713,13 @@ func TestService_StripRegistryPrefix(t *testing.T) {
2626 image: "reg.example.com/myapp:v1.0",2713 image: "reg.example.com/myapp:v1.0",
2627 expected: "myapp:v1.0",2714 expected: "myapp:v1.0",
2628 },2715 },
2716+ {
2717+ name: "strips legacy registry prefix",
2718+ registryDomain: "new-reg.example.com",
2719+ legacyRegistryDomains: []string{"old-reg.example.com"},
2720+ image: "old-reg.example.com/myapp:latest",
2721+ expected: "myapp:latest",
2722+ },
2629 {2723 {
2630 name: "preserves image without registry prefix",2724 name: "preserves image without registry prefix",
2631 registryDomain: "reg.example.com",2725 registryDomain: "reg.example.com",
@@ -2659,7 +2753,8 @@ func TestService_StripRegistryPrefix(t *testing.T) {
2659 eventBus := mocks.NewMockEventPublisher(t)2753 eventBus := mocks.NewMockEventPublisher(t)
2660 2754
2661 config := Config{2755 config := Config{
2662- RegistryDomain: tt.registryDomain,2756+ RegistryDomain: tt.registryDomain,
2757+ LegacyRegistryDomains: tt.legacyRegistryDomains,
2663 }2758 }
2664 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)2759 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2665 2760

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close