detached 8d0a092

Merge pull request #197 from bnema/feat/registry-rename-startup-recovery-hardening

Harden registry rename and startup recovery
commit
8d0a09205265e6123a15005e843b00a76bb96a94
author
bnema · · 2026-05-10 17:10 UTC
committer
GitHub · · 2026-05-10 17:10 UTC
merge of
99756e8 + 29c9fdd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev16
24 files · +1198 -521
M docs/cli/serve.md +23 -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,22 @@ 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 runs `AutoStart` for 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.
123+
124+Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
111125
112126 ### Shutdown Sequence
113127
@@ -177,14 +191,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177191
178192 ### Description
179193
180-**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
194+**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route.
181195
182-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.
196+**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.
183197
184-Both modes trigger:
198+Both local and remote manual deploys use Gordon's explicit deploy path:
185199
186-- Fresh image pull (always pulls latest, ignoring cache)
187-- Container redeployment for the specified route
200+- Fresh image content is pulled for the route
201+- The specified route is redeployed
202+- Configured readiness checks and drain behavior apply when needed
188203
189204 ### Examples
190205
@@ -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,22 @@ 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 runs `AutoStart` for 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.
123+
124+Startup recovery is intentionally narrower than a manual deploy. It only starts routes that are missing a running container, skips readiness checks during boot, and does not perform drain/replacement logic for routes that are already running.
111 125
112 ### Shutdown Sequence126 ### Shutdown Sequence
113 127
@@ -177,14 +191,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177 191
178 ### Description192 ### Description
179 193
180-**Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.194+**Local mode:** On the Gordon host, Gordon uses the explicit deploy path for the selected route. When the CLI cannot execute that path directly, it falls back to queueing the request with `SIGUSR2` for the running server. This is different from startup recovery: startup recovery uses `AutoStart`, while a manual deploy performs an explicit redeploy for the selected route.
181 195
182-**Remote mode:** Calls the remote Gordon Admin API to trigger deployment.196+**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 197
184-Both modes trigger:198+Both local and remote manual deploys use Gordon's explicit deploy path:
185 199
186-- Fresh image pull (always pulls latest, ignoring cache)200+- Fresh image content is pulled for the route
187-- Container redeployment for the specified route201+- The specified route is redeployed
202+- Configured readiness checks and drain behavior apply when needed
188 203
189 ### Examples204 ### Examples
190 205
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/adapters/out/docker/runtime.go +0 -36
@@ -363,42 +363,6 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
363363 return nil
364364 }
365365
366-// EnsureContainerRestartPolicy updates a container restart policy when it does not match.
367-func (r *Runtime) EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error {
368- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369- zerowrap.FieldLayer: "adapter",
370- zerowrap.FieldAdapter: "docker",
371- zerowrap.FieldAction: "EnsureContainerRestartPolicy",
372- zerowrap.FieldEntityID: containerID,
373- "restart_policy": policy,
374- })
375- log := zerowrap.FromCtx(ctx)
376-
377- inspect, err := r.client.ContainerInspect(ctx, containerID)
378- if err != nil {
379- return log.WrapErr(err, "failed to inspect container")
380- }
381- if inspect.HostConfig == nil {
382- return fmt.Errorf("container inspect response missing host config: %s", containerID)
383- }
384- if string(inspect.HostConfig.RestartPolicy.Name) == policy {
385- return nil
386- }
387-
388- updateConfig := container.UpdateConfig{
389- Resources: inspect.HostConfig.Resources,
390- RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(policy)},
391- }
392- resp, err := r.client.ContainerUpdate(ctx, containerID, updateConfig)
393- if err != nil {
394- return log.WrapErr(err, "failed to update container restart policy")
395- }
396- for _, warning := range resp.Warnings {
397- log.Warn().Str("warning", warning).Msg("container restart policy update warning")
398- }
399- return nil
400-}
401-
402366 // ListContainers lists containers.
403367 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
404368 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -363,42 +363,6 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
363 return nil363 return nil
364 }364 }
365 365
366-// EnsureContainerRestartPolicy updates a container restart policy when it does not match.
367-func (r *Runtime) EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error {
368- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369- zerowrap.FieldLayer: "adapter",
370- zerowrap.FieldAdapter: "docker",
371- zerowrap.FieldAction: "EnsureContainerRestartPolicy",
372- zerowrap.FieldEntityID: containerID,
373- "restart_policy": policy,
374- })
375- log := zerowrap.FromCtx(ctx)
376-
377- inspect, err := r.client.ContainerInspect(ctx, containerID)
378- if err != nil {
379- return log.WrapErr(err, "failed to inspect container")
380- }
381- if inspect.HostConfig == nil {
382- return fmt.Errorf("container inspect response missing host config: %s", containerID)
383- }
384- if string(inspect.HostConfig.RestartPolicy.Name) == policy {
385- return nil
386- }
387-
388- updateConfig := container.UpdateConfig{
389- Resources: inspect.HostConfig.Resources,
390- RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(policy)},
391- }
392- resp, err := r.client.ContainerUpdate(ctx, containerID, updateConfig)
393- if err != nil {
394- return log.WrapErr(err, "failed to update container restart policy")
395- }
396- for _, warning := range resp.Warnings {
397- log.Warn().Str("warning", warning).Msg("container restart policy update warning")
398- }
399- return nil
400-}
401-
402 // ListContainers lists containers.366 // ListContainers lists containers.
403 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {367 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
404 ctx = zerowrap.CtxWithFields(ctx, map[string]any{368 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
D internal/adapters/out/docker/runtime_restart_policy_test.go +0 -129
@@ -1,129 +0,0 @@
1-package docker
2-
3-import (
4- "context"
5- "encoding/json"
6- "net/http"
7- "net/http/httptest"
8- "strings"
9- "testing"
10-
11- "github.com/docker/docker/client"
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/gordon/internal/domain"
16-)
17-
18-func TestRuntime_EnsureContainerRestartPolicy_SkipsWhenAlreadySet(t *testing.T) {
19- var updateCalled bool
20- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21- switch {
22- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
23- w.Header().Set("Content-Type", "application/json")
24- _, _ = w.Write([]byte(`{
25- "Id": "abc123",
26- "HostConfig": {
27- "RestartPolicy": {
28- "Name": "always"
29- }
30- }
31- }`))
32- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
33- updateCalled = true
34- w.WriteHeader(http.StatusInternalServerError)
35- default:
36- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
37- }
38- }))
39- defer server.Close()
40-
41- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
42- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
43- require.NoError(t, err)
44- assert.False(t, updateCalled, "update should not be called when restart policy is already set")
45-}
46-
47-func TestRuntime_EnsureContainerRestartPolicy_UpdatesAndPreservesResources(t *testing.T) {
48- var updateBody map[string]any
49- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50- switch {
51- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
52- w.Header().Set("Content-Type", "application/json")
53- _, _ = w.Write([]byte(`{
54- "Id": "abc123",
55- "HostConfig": {
56- "RestartPolicy": {
57- "Name": ""
58- },
59- "Memory": 268435456,
60- "NanoCpus": 500000000,
61- "PidsLimit": 128,
62- "Ulimits": [
63- {
64- "Name": "nofile",
65- "Soft": 65536,
66- "Hard": 65536
67- }
68- ]
69- }
70- }`))
71- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
72- require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody))
73- w.Header().Set("Content-Type", "application/json")
74- _, _ = w.Write([]byte(`{"Warnings":null}`))
75- default:
76- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
77- }
78- }))
79- defer server.Close()
80-
81- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
82- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
83- require.NoError(t, err)
84- require.NotNil(t, updateBody)
85- assert.Equal(t, domain.RestartPolicyAlways, updateBody["RestartPolicy"].(map[string]any)["Name"])
86- assert.Equal(t, float64(268435456), updateBody["Memory"])
87- assert.Equal(t, float64(500000000), updateBody["NanoCpus"])
88- assert.Equal(t, float64(128), updateBody["PidsLimit"])
89- ulimits, ok := updateBody["Ulimits"].([]any)
90- require.True(t, ok, "Ulimits should be an array")
91- require.Greater(t, len(ulimits), 0)
92- firstUlimit, ok := ulimits[0].(map[string]any)
93- require.True(t, ok, "first Ulimit should be a map")
94- assert.Equal(t, "nofile", firstUlimit["Name"])
95- assert.Equal(t, float64(65536), firstUlimit["Soft"])
96- assert.Equal(t, float64(65536), firstUlimit["Hard"])
97-}
98-
99-func TestRuntime_EnsureContainerRestartPolicy_ReturnsErrorWhenHostConfigMissing(t *testing.T) {
100- var updateCalled bool
101- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102- switch {
103- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
104- w.Header().Set("Content-Type", "application/json")
105- _, _ = w.Write([]byte(`{
106- "Id": "abc123"
107- }`))
108- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
109- updateCalled = true
110- w.WriteHeader(http.StatusInternalServerError)
111- default:
112- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
113- }
114- }))
115- defer server.Close()
116-
117- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
118- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
119- require.Error(t, err)
120- assert.Contains(t, err.Error(), "missing host config")
121- assert.False(t, updateCalled, "update should not be called when host config is missing")
122-}
123-
124-func newRuntimeForRestartPolicyHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
125- host := strings.TrimPrefix(server.URL, "http://")
126- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
127- require.NoError(t, err)
128- return NewRuntimeWithClient(cli)
129-}
@@ -1,129 +0,0 @@
1-package docker
2-
3-import (
4- "context"
5- "encoding/json"
6- "net/http"
7- "net/http/httptest"
8- "strings"
9- "testing"
10-
11- "github.com/docker/docker/client"
12- "github.com/stretchr/testify/assert"
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/gordon/internal/domain"
16-)
17-
18-func TestRuntime_EnsureContainerRestartPolicy_SkipsWhenAlreadySet(t *testing.T) {
19- var updateCalled bool
20- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21- switch {
22- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
23- w.Header().Set("Content-Type", "application/json")
24- _, _ = w.Write([]byte(`{
25- "Id": "abc123",
26- "HostConfig": {
27- "RestartPolicy": {
28- "Name": "always"
29- }
30- }
31- }`))
32- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
33- updateCalled = true
34- w.WriteHeader(http.StatusInternalServerError)
35- default:
36- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
37- }
38- }))
39- defer server.Close()
40-
41- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
42- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
43- require.NoError(t, err)
44- assert.False(t, updateCalled, "update should not be called when restart policy is already set")
45-}
46-
47-func TestRuntime_EnsureContainerRestartPolicy_UpdatesAndPreservesResources(t *testing.T) {
48- var updateBody map[string]any
49- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50- switch {
51- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
52- w.Header().Set("Content-Type", "application/json")
53- _, _ = w.Write([]byte(`{
54- "Id": "abc123",
55- "HostConfig": {
56- "RestartPolicy": {
57- "Name": ""
58- },
59- "Memory": 268435456,
60- "NanoCpus": 500000000,
61- "PidsLimit": 128,
62- "Ulimits": [
63- {
64- "Name": "nofile",
65- "Soft": 65536,
66- "Hard": 65536
67- }
68- ]
69- }
70- }`))
71- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
72- require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody))
73- w.Header().Set("Content-Type", "application/json")
74- _, _ = w.Write([]byte(`{"Warnings":null}`))
75- default:
76- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
77- }
78- }))
79- defer server.Close()
80-
81- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
82- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
83- require.NoError(t, err)
84- require.NotNil(t, updateBody)
85- assert.Equal(t, domain.RestartPolicyAlways, updateBody["RestartPolicy"].(map[string]any)["Name"])
86- assert.Equal(t, float64(268435456), updateBody["Memory"])
87- assert.Equal(t, float64(500000000), updateBody["NanoCpus"])
88- assert.Equal(t, float64(128), updateBody["PidsLimit"])
89- ulimits, ok := updateBody["Ulimits"].([]any)
90- require.True(t, ok, "Ulimits should be an array")
91- require.Greater(t, len(ulimits), 0)
92- firstUlimit, ok := ulimits[0].(map[string]any)
93- require.True(t, ok, "first Ulimit should be a map")
94- assert.Equal(t, "nofile", firstUlimit["Name"])
95- assert.Equal(t, float64(65536), firstUlimit["Soft"])
96- assert.Equal(t, float64(65536), firstUlimit["Hard"])
97-}
98-
99-func TestRuntime_EnsureContainerRestartPolicy_ReturnsErrorWhenHostConfigMissing(t *testing.T) {
100- var updateCalled bool
101- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102- switch {
103- case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
104- w.Header().Set("Content-Type", "application/json")
105- _, _ = w.Write([]byte(`{
106- "Id": "abc123"
107- }`))
108- case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
109- updateCalled = true
110- w.WriteHeader(http.StatusInternalServerError)
111- default:
112- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
113- }
114- }))
115- defer server.Close()
116-
117- runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
118- err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
119- require.Error(t, err)
120- assert.Contains(t, err.Error(), "missing host config")
121- assert.False(t, updateCalled, "update should not be called when host config is missing")
122-}
123-
124-func newRuntimeForRestartPolicyHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
125- host := strings.TrimPrefix(server.URL, "http://")
126- cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
127- require.NoError(t, err)
128- return NewRuntimeWithClient(cli)
129-}
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 +44 -0
@@ -0,0 +1,44 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/zerowrap"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// startupConfigService defines the route configuration needed during startup
12+// recovery. It intentionally excludes auto-route settings because reboot
13+// recovery always works from configured routes.
14+type startupConfigService interface {
15+ GetRoutes(ctx context.Context) []domain.Route
16+}
17+
18+// startupContainerService defines the container lifecycle operations needed
19+// during startup recovery.
20+type startupContainerService interface {
21+ SyncContainers(ctx context.Context) error
22+ AutoStart(ctx context.Context, routes []domain.Route) error
23+ StartMonitor(ctx context.Context)
24+}
25+
26+// syncAndRecoverConfiguredRoutes performs best-effort startup recovery for
27+// configured routes after listeners are ready.
28+func syncAndRecoverConfiguredRoutes(
29+ ctx context.Context,
30+ configSvc startupConfigService,
31+ containerSvc startupContainerService,
32+ log zerowrap.Logger,
33+) {
34+ defer containerSvc.StartMonitor(ctx)
35+
36+ if err := containerSvc.SyncContainers(ctx); err != nil {
37+ log.Warn().Err(err).Msg("failed to sync existing containers")
38+ }
39+
40+ routes := configSvc.GetRoutes(ctx)
41+ if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
42+ log.Warn().Err(err).Msg("failed to auto-start configured routes")
43+ }
44+}
@@ -0,0 +1,44 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/zerowrap"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// startupConfigService defines the route configuration needed during startup
12+// recovery. It intentionally excludes auto-route settings because reboot
13+// recovery always works from configured routes.
14+type startupConfigService interface {
15+ GetRoutes(ctx context.Context) []domain.Route
16+}
17+
18+// startupContainerService defines the container lifecycle operations needed
19+// during startup recovery.
20+type startupContainerService interface {
21+ SyncContainers(ctx context.Context) error
22+ AutoStart(ctx context.Context, routes []domain.Route) error
23+ StartMonitor(ctx context.Context)
24+}
25+
26+// syncAndRecoverConfiguredRoutes performs best-effort startup recovery for
27+// configured routes after listeners are ready.
28+func syncAndRecoverConfiguredRoutes(
29+ ctx context.Context,
30+ configSvc startupConfigService,
31+ containerSvc startupContainerService,
32+ log zerowrap.Logger,
33+) {
34+ defer containerSvc.StartMonitor(ctx)
35+
36+ if err := containerSvc.SyncContainers(ctx); err != nil {
37+ log.Warn().Err(err).Msg("failed to sync existing containers")
38+ }
39+
40+ routes := configSvc.GetRoutes(ctx)
41+ if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil {
42+ log.Warn().Err(err).Msg("failed to auto-start configured routes")
43+ }
44+}
A internal/app/startup_recovery_test.go +119 -0
@@ -0,0 +1,119 @@
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+}
17+
18+var _ startupConfigService = (*startupRecoveryFakeConfigService)(nil)
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+type startupRecoveryFakeContainerService struct {
26+ calls *[]string
27+ syncErr error
28+ autoStartErr error
29+ autoStartCtx context.Context
30+ autoStartRoutes []domain.Route
31+ startMonitorCtx context.Context
32+}
33+
34+var _ startupContainerService = (*startupRecoveryFakeContainerService)(nil)
35+
36+func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
37+ *f.calls = append(*f.calls, "sync")
38+ return f.syncErr
39+}
40+
41+func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
42+ *f.calls = append(*f.calls, "autostart")
43+ f.autoStartCtx = ctx
44+ f.autoStartRoutes = append([]domain.Route(nil), routes...)
45+ return f.autoStartErr
46+}
47+
48+func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) {
49+ *f.calls = append(*f.calls, "monitor")
50+ f.startMonitorCtx = ctx
51+}
52+
53+func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) {
54+ ctx := context.Background()
55+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
56+ calls := make([]string, 0, 4)
57+
58+ configSvc := &startupRecoveryFakeConfigService{
59+ calls: &calls,
60+ routes: routes,
61+ }
62+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
63+
64+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
65+
66+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
67+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
68+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
69+ assert.False(t, domain.IsInternalDeploy(ctx))
70+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
71+}
72+
73+func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) {
74+ ctx := context.Background()
75+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
76+ calls := make([]string, 0, 4)
77+
78+ configSvc := &startupRecoveryFakeConfigService{
79+ calls: &calls,
80+ routes: routes,
81+ }
82+ containerSvc := &startupRecoveryFakeContainerService{
83+ calls: &calls,
84+ syncErr: assert.AnError,
85+ }
86+
87+ assert.NotPanics(t, func() {
88+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
89+ })
90+
91+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
92+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
93+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
94+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
95+}
96+
97+func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) {
98+ ctx := context.Background()
99+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
100+ calls := make([]string, 0, 4)
101+
102+ configSvc := &startupRecoveryFakeConfigService{
103+ calls: &calls,
104+ routes: routes,
105+ }
106+ containerSvc := &startupRecoveryFakeContainerService{
107+ calls: &calls,
108+ autoStartErr: assert.AnError,
109+ }
110+
111+ assert.NotPanics(t, func() {
112+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
113+ })
114+
115+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
116+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
117+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
118+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
119+}
@@ -0,0 +1,119 @@
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+}
17+
18+var _ startupConfigService = (*startupRecoveryFakeConfigService)(nil)
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+type startupRecoveryFakeContainerService struct {
26+ calls *[]string
27+ syncErr error
28+ autoStartErr error
29+ autoStartCtx context.Context
30+ autoStartRoutes []domain.Route
31+ startMonitorCtx context.Context
32+}
33+
34+var _ startupContainerService = (*startupRecoveryFakeContainerService)(nil)
35+
36+func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error {
37+ *f.calls = append(*f.calls, "sync")
38+ return f.syncErr
39+}
40+
41+func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error {
42+ *f.calls = append(*f.calls, "autostart")
43+ f.autoStartCtx = ctx
44+ f.autoStartRoutes = append([]domain.Route(nil), routes...)
45+ return f.autoStartErr
46+}
47+
48+func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) {
49+ *f.calls = append(*f.calls, "monitor")
50+ f.startMonitorCtx = ctx
51+}
52+
53+func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) {
54+ ctx := context.Background()
55+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
56+ calls := make([]string, 0, 4)
57+
58+ configSvc := &startupRecoveryFakeConfigService{
59+ calls: &calls,
60+ routes: routes,
61+ }
62+ containerSvc := &startupRecoveryFakeContainerService{calls: &calls}
63+
64+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
65+
66+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
67+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
68+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
69+ assert.False(t, domain.IsInternalDeploy(ctx))
70+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
71+}
72+
73+func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) {
74+ ctx := context.Background()
75+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
76+ calls := make([]string, 0, 4)
77+
78+ configSvc := &startupRecoveryFakeConfigService{
79+ calls: &calls,
80+ routes: routes,
81+ }
82+ containerSvc := &startupRecoveryFakeContainerService{
83+ calls: &calls,
84+ syncErr: assert.AnError,
85+ }
86+
87+ assert.NotPanics(t, func() {
88+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
89+ })
90+
91+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
92+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
93+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
94+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
95+}
96+
97+func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) {
98+ ctx := context.Background()
99+ routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}}
100+ calls := make([]string, 0, 4)
101+
102+ configSvc := &startupRecoveryFakeConfigService{
103+ calls: &calls,
104+ routes: routes,
105+ }
106+ containerSvc := &startupRecoveryFakeContainerService{
107+ calls: &calls,
108+ autoStartErr: assert.AnError,
109+ }
110+
111+ assert.NotPanics(t, func() {
112+ syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default())
113+ })
114+
115+ assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls)
116+ assert.Equal(t, routes, containerSvc.autoStartRoutes)
117+ assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx))
118+ assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx))
119+}
M internal/boundaries/out/mocks/mock_container_runtime.go +0 -63
@@ -428,69 +428,6 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn
428428 return _c
429429 }
430430
431-// EnsureContainerRestartPolicy provides a mock function for the type MockContainerRuntime
432-func (_mock *MockContainerRuntime) EnsureContainerRestartPolicy(ctx context.Context, containerID string, policy string) error {
433- ret := _mock.Called(ctx, containerID, policy)
434-
435- if len(ret) == 0 {
436- panic("no return value specified for EnsureContainerRestartPolicy")
437- }
438-
439- var r0 error
440- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
441- r0 = returnFunc(ctx, containerID, policy)
442- } else {
443- r0 = ret.Error(0)
444- }
445- return r0
446-}
447-
448-// MockContainerRuntime_EnsureContainerRestartPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureContainerRestartPolicy'
449-type MockContainerRuntime_EnsureContainerRestartPolicy_Call struct {
450- *mock.Call
451-}
452-
453-// EnsureContainerRestartPolicy is a helper method to define mock.On call
454-// - ctx context.Context
455-// - containerID string
456-// - policy string
457-func (_e *MockContainerRuntime_Expecter) EnsureContainerRestartPolicy(ctx interface{}, containerID interface{}, policy interface{}) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
458- return &MockContainerRuntime_EnsureContainerRestartPolicy_Call{Call: _e.mock.On("EnsureContainerRestartPolicy", ctx, containerID, policy)}
459-}
460-
461-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Run(run func(ctx context.Context, containerID string, policy string)) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
462- _c.Call.Run(func(args mock.Arguments) {
463- var arg0 context.Context
464- if args[0] != nil {
465- arg0 = args[0].(context.Context)
466- }
467- var arg1 string
468- if args[1] != nil {
469- arg1 = args[1].(string)
470- }
471- var arg2 string
472- if args[2] != nil {
473- arg2 = args[2].(string)
474- }
475- run(
476- arg0,
477- arg1,
478- arg2,
479- )
480- })
481- return _c
482-}
483-
484-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Return(err error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
485- _c.Call.Return(err)
486- return _c
487-}
488-
489-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) RunAndReturn(run func(ctx context.Context, containerID string, policy string) error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
490- _c.Call.Return(run)
491- return _c
492-}
493-
494431 // ExecInContainer provides a mock function for the type MockContainerRuntime
495432 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
496433 ret := _mock.Called(ctx, containerID, cmd)
@@ -428,69 +428,6 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn
428 return _c428 return _c
429 }429 }
430 430
431-// EnsureContainerRestartPolicy provides a mock function for the type MockContainerRuntime
432-func (_mock *MockContainerRuntime) EnsureContainerRestartPolicy(ctx context.Context, containerID string, policy string) error {
433- ret := _mock.Called(ctx, containerID, policy)
434-
435- if len(ret) == 0 {
436- panic("no return value specified for EnsureContainerRestartPolicy")
437- }
438-
439- var r0 error
440- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
441- r0 = returnFunc(ctx, containerID, policy)
442- } else {
443- r0 = ret.Error(0)
444- }
445- return r0
446-}
447-
448-// MockContainerRuntime_EnsureContainerRestartPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureContainerRestartPolicy'
449-type MockContainerRuntime_EnsureContainerRestartPolicy_Call struct {
450- *mock.Call
451-}
452-
453-// EnsureContainerRestartPolicy is a helper method to define mock.On call
454-// - ctx context.Context
455-// - containerID string
456-// - policy string
457-func (_e *MockContainerRuntime_Expecter) EnsureContainerRestartPolicy(ctx interface{}, containerID interface{}, policy interface{}) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
458- return &MockContainerRuntime_EnsureContainerRestartPolicy_Call{Call: _e.mock.On("EnsureContainerRestartPolicy", ctx, containerID, policy)}
459-}
460-
461-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Run(run func(ctx context.Context, containerID string, policy string)) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
462- _c.Call.Run(func(args mock.Arguments) {
463- var arg0 context.Context
464- if args[0] != nil {
465- arg0 = args[0].(context.Context)
466- }
467- var arg1 string
468- if args[1] != nil {
469- arg1 = args[1].(string)
470- }
471- var arg2 string
472- if args[2] != nil {
473- arg2 = args[2].(string)
474- }
475- run(
476- arg0,
477- arg1,
478- arg2,
479- )
480- })
481- return _c
482-}
483-
484-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Return(err error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
485- _c.Call.Return(err)
486- return _c
487-}
488-
489-func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) RunAndReturn(run func(ctx context.Context, containerID string, policy string) error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
490- _c.Call.Return(run)
491- return _c
492-}
493-
494 // ExecInContainer provides a mock function for the type MockContainerRuntime431 // ExecInContainer provides a mock function for the type MockContainerRuntime
495 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {432 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
496 ret := _mock.Called(ctx, containerID, cmd)433 ret := _mock.Called(ctx, containerID, cmd)
M internal/boundaries/out/runtime.go +0 -1
@@ -20,7 +20,6 @@ type ContainerRuntime interface {
2020 RestartContainer(ctx context.Context, containerID string) error
2121 RemoveContainer(ctx context.Context, containerID string, force bool) error
2222 RenameContainer(ctx context.Context, containerID, newName string) error
23- EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error
2423
2524 // Container inspection
2625 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
@@ -20,7 +20,6 @@ type ContainerRuntime interface {
20 RestartContainer(ctx context.Context, containerID string) error20 RestartContainer(ctx context.Context, containerID string) error
21 RemoveContainer(ctx context.Context, containerID string, force bool) error21 RemoveContainer(ctx context.Context, containerID string, force bool) error
22 RenameContainer(ctx context.Context, containerID, newName string) error22 RenameContainer(ctx context.Context, containerID, newName string) error
23- EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error
24 23
25 // Container inspection24 // Container inspection
26 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)25 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
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 +127 -0
@@ -0,0 +1,127 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
12+ got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{
13+ "",
14+ " old-registry.example.com/ ",
15+ "old-registry.example.com:5000///",
16+ "new-registry.example.com",
17+ "old-registry.example.com",
18+ })
19+
20+ want := []string{
21+ "new-registry.example.com",
22+ "old-registry.example.com",
23+ "old-registry.example.com:5000",
24+ }
25+
26+ assert.Equal(t, want, got)
27+}
28+
29+func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
30+ current := "new-registry.example.com/"
31+ legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"}
32+
33+ tests := []struct {
34+ name string
35+ imageRef string
36+ want string
37+ }{
38+ {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"},
39+ {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"},
40+ {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"},
41+ {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"},
42+ {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"},
43+ {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"},
44+ {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"},
45+ }
46+
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestRegistryImageExtractGordonRepoName(t *testing.T) {
56+ current := "new-registry.example.com/"
57+ legacy := []string{"old-registry.example.com/"}
58+
59+ tests := []struct {
60+ name string
61+ imageRef string
62+ want string
63+ }{
64+ {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"},
65+ {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"},
66+ {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"},
67+ {name: "bare image", imageRef: "app:latest", want: "app"},
68+ {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
69+ {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
75+ assert.Equal(t, tt.want, got)
76+ })
77+ }
78+}
79+
80+func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
81+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
82+
83+ tests := []struct {
84+ name string
85+ imageRef string
86+ current string
87+ want string
88+ }{
89+ {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
90+ {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
91+ {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"},
92+ {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"},
93+ {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"},
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
99+ assert.Equal(t, tt.want, got)
100+ })
101+ }
102+}
103+
104+func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
105+ current := "new-registry.example.com/"
106+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
107+
108+ tests := []struct {
109+ name string
110+ imageRef string
111+ want bool
112+ }{
113+ {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true},
114+ {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true},
115+ {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true},
116+ {name: "bare ref", imageRef: "app:latest", want: false},
117+ {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false},
118+ {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false},
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
124+ assert.Equal(t, tt.want, got)
125+ })
126+ }
127+}
@@ -0,0 +1,127 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) {
12+ got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{
13+ "",
14+ " old-registry.example.com/ ",
15+ "old-registry.example.com:5000///",
16+ "new-registry.example.com",
17+ "old-registry.example.com",
18+ })
19+
20+ want := []string{
21+ "new-registry.example.com",
22+ "old-registry.example.com",
23+ "old-registry.example.com:5000",
24+ }
25+
26+ assert.Equal(t, want, got)
27+}
28+
29+func TestRegistryImageStripKnownGordonRegistry(t *testing.T) {
30+ current := "new-registry.example.com/"
31+ legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"}
32+
33+ tests := []struct {
34+ name string
35+ imageRef string
36+ want string
37+ }{
38+ {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"},
39+ {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"},
40+ {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"},
41+ {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"},
42+ {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"},
43+ {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"},
44+ {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"},
45+ }
46+
47+ for _, tt := range tests {
48+ t.Run(tt.name, func(t *testing.T) {
49+ got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy)
50+ assert.Equal(t, tt.want, got)
51+ })
52+ }
53+}
54+
55+func TestRegistryImageExtractGordonRepoName(t *testing.T) {
56+ current := "new-registry.example.com/"
57+ legacy := []string{"old-registry.example.com/"}
58+
59+ tests := []struct {
60+ name string
61+ imageRef string
62+ want string
63+ }{
64+ {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"},
65+ {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"},
66+ {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"},
67+ {name: "bare image", imageRef: "app:latest", want: "app"},
68+ {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
69+ {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"},
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy)
75+ assert.Equal(t, tt.want, got)
76+ })
77+ }
78+}
79+
80+func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) {
81+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
82+
83+ tests := []struct {
84+ name string
85+ imageRef string
86+ current string
87+ want string
88+ }{
89+ {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
90+ {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"},
91+ {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"},
92+ {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"},
93+ {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"},
94+ }
95+
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy)
99+ assert.Equal(t, tt.want, got)
100+ })
101+ }
102+}
103+
104+func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) {
105+ current := "new-registry.example.com/"
106+ legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"}
107+
108+ tests := []struct {
109+ name string
110+ imageRef string
111+ want bool
112+ }{
113+ {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true},
114+ {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true},
115+ {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true},
116+ {name: "bare ref", imageRef: "app:latest", want: false},
117+ {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false},
118+ {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false},
119+ }
120+
121+ for _, tt := range tests {
122+ t.Run(tt.name, func(t *testing.T) {
123+ got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy)
124+ assert.Equal(t, tt.want, got)
125+ })
126+ }
127+}
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 +60 -24
@@ -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,45 @@ 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, 0, len(images))
890+ seen := make(map[string]struct{}, len(images))
891+ for _, image := range images {
892+ canonicalImage := domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains)
893+ if _, ok := seen[canonicalImage]; ok {
894+ continue
895+ }
896+ seen[canonicalImage] = struct{}{}
897+ canonicalImages = append(canonicalImages, canonicalImage)
898+ }
899+ result[target] = canonicalImages
900+ }
901+
902+ return result
903+}
904+
905+func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) {
883906 result := make(map[string]routeConfig)
884907 if routes == nil {
885908 return result, nil
886909 }
887910
911+ canonicalizeRoute := func(route routeConfig) routeConfig {
912+ route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains)
913+ return route
914+ }
915+
888916 for key, route := range routes {
889917 if strings.HasPrefix(key, "http://") {
890918 continue
@@ -892,7 +920,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
892920 if !domain.IsValidRouteDomain(key) {
893921 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
894922 }
895- result[key] = route
923+ result[key] = canonicalizeRoute(route)
896924 }
897925
898926 for key, route := range routes {
@@ -908,7 +936,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
908936 continue
909937 }
910938 route.HTTPS = false
911- result[domainName] = route
939+ result[domainName] = canonicalizeRoute(route)
912940 }
913941
914942 return result, nil
@@ -1121,6 +1149,13 @@ func (s *Service) GetRegistryDomain() string {
11211149 return s.config.RegistryDomain
11221150 }
11231151
1152+// GetLegacyRegistryDomains returns the configured legacy registry domains.
1153+func (s *Service) GetLegacyRegistryDomains() []string {
1154+ s.mu.RLock()
1155+ defer s.mu.RUnlock()
1156+ return append([]string{}, s.config.LegacyRegistryDomains...)
1157+}
1158+
11241159 // GetDataDir returns the configured data directory.
11251160 func (s *Service) GetDataDir() string {
11261161 s.mu.RLock()
@@ -1249,8 +1284,9 @@ func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string
12491284
12501285 // Check if already exists
12511286 existing := s.config.Attachments[domainOrGroup]
1287+ canonicalImage := domain.CanonicalizeGordonImageRef(image, s.config.RegistryDomain, s.config.LegacyRegistryDomains)
12521288 for _, img := range existing {
1253- if img == image {
1289+ if img == image || domain.CanonicalizeGordonImageRef(img, s.config.RegistryDomain, s.config.LegacyRegistryDomains) == canonicalImage {
12541290 s.mu.Unlock()
12551291 return domain.ErrAttachmentExists
12561292 }
@@ -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,45 @@ 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, 0, len(images))
890+ seen := make(map[string]struct{}, len(images))
891+ for _, image := range images {
892+ canonicalImage := domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains)
893+ if _, ok := seen[canonicalImage]; ok {
894+ continue
895+ }
896+ seen[canonicalImage] = struct{}{}
897+ canonicalImages = append(canonicalImages, canonicalImage)
898+ }
899+ result[target] = canonicalImages
900+ }
901+
902+ return result
903+}
904+
905+func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) {
883 result := make(map[string]routeConfig)906 result := make(map[string]routeConfig)
884 if routes == nil {907 if routes == nil {
885 return result, nil908 return result, nil
886 }909 }
887 910
911+ canonicalizeRoute := func(route routeConfig) routeConfig {
912+ route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains)
913+ return route
914+ }
915+
888 for key, route := range routes {916 for key, route := range routes {
889 if strings.HasPrefix(key, "http://") {917 if strings.HasPrefix(key, "http://") {
890 continue918 continue
@@ -892,7 +920,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
892 if !domain.IsValidRouteDomain(key) {920 if !domain.IsValidRouteDomain(key) {
893 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)921 return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
894 }922 }
895- result[key] = route923+ result[key] = canonicalizeRoute(route)
896 }924 }
897 925
898 for key, route := range routes {926 for key, route := range routes {
@@ -908,7 +936,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf
908 continue936 continue
909 }937 }
910 route.HTTPS = false938 route.HTTPS = false
911- result[domainName] = route939+ result[domainName] = canonicalizeRoute(route)
912 }940 }
913 941
914 return result, nil942 return result, nil
@@ -1121,6 +1149,13 @@ func (s *Service) GetRegistryDomain() string {
1121 return s.config.RegistryDomain1149 return s.config.RegistryDomain
1122 }1150 }
1123 1151
1152+// GetLegacyRegistryDomains returns the configured legacy registry domains.
1153+func (s *Service) GetLegacyRegistryDomains() []string {
1154+ s.mu.RLock()
1155+ defer s.mu.RUnlock()
1156+ return append([]string{}, s.config.LegacyRegistryDomains...)
1157+}
1158+
1124 // GetDataDir returns the configured data directory.1159 // GetDataDir returns the configured data directory.
1125 func (s *Service) GetDataDir() string {1160 func (s *Service) GetDataDir() string {
1126 s.mu.RLock()1161 s.mu.RLock()
@@ -1249,8 +1284,9 @@ func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string
1249 1284
1250 // Check if already exists1285 // Check if already exists
1251 existing := s.config.Attachments[domainOrGroup]1286 existing := s.config.Attachments[domainOrGroup]
1287+ canonicalImage := domain.CanonicalizeGordonImageRef(image, s.config.RegistryDomain, s.config.LegacyRegistryDomains)
1252 for _, img := range existing {1288 for _, img := range existing {
1253- if img == image {1289+ if img == image || domain.CanonicalizeGordonImageRef(img, s.config.RegistryDomain, s.config.LegacyRegistryDomains) == canonicalImage {
1254 s.mu.Unlock()1290 s.mu.Unlock()
1255 return domain.ErrAttachmentExists1291 return domain.ErrAttachmentExists
1256 }1292 }
M internal/usecase/config/service_test.go +312 -17
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
89 "testing"
910
1011 "github.com/bnema/zerowrap"
@@ -217,6 +218,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test
217218 assert.True(t, routes[0].HTTPS)
218219 }
219220
221+func TestCanonicalRoutesForSave(t *testing.T) {
222+ routes := map[string]routeConfig{
223+ "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true},
224+ "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true},
225+ "bare.example.com": {Image: "worker:v2", HTTPS: true},
226+ "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true},
227+ "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true},
228+ }
229+
230+ savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"})
231+ require.NoError(t, err)
232+
233+ assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com"))
234+ assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com"))
235+ assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com"))
236+ assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com"))
237+ assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com"))
238+
239+ assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image)
240+ assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image)
241+}
242+
220243 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221244 tmpDir := t.TempDir()
222245 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -242,9 +265,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
242265 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243266 tmpDir := t.TempDir()
244267 configFile := filepath.Join(tmpDir, "gordon.toml")
245- initialConfig := `[routes]
246-"secure.example.com" = "secure:v1"
247-"http://insecure.example.com" = "insecure:v1"
268+ initialConfig := `[server]
269+gordon_domain = "registry.example.com"
270+legacy_registry_domains = ["old-registry.example.com"]
271+
272+[routes]
273+"secure.example.com" = "old-registry.example.com/secure:v1"
274+"http://insecure.example.com" = "old-registry.example.com/insecure:v1"
248275 `
249276 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250277 require.NoError(t, err)
@@ -266,13 +293,147 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
266293 content, err := os.ReadFile(configFile)
267294 require.NoError(t, err)
268295 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 }`)
296+ assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`)
297+ assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`)
271298 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272299 assert.NotContains(t, text, "http://insecure.example.com")
273300 assert.Contains(t, text, "[routes]")
274301 }
275302
303+func TestService_SaveCanonicalizesRouteImages(t *testing.T) {
304+ tmpDir := t.TempDir()
305+ configFile := filepath.Join(tmpDir, "gordon.toml")
306+ initialConfig := `[server]
307+gordon_domain = "registry.example.com"
308+legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"]
309+
310+[routes]
311+"legacy.example.com" = "old-registry.example.com/app:latest"
312+"legacy-port.example.com" = "old-registry.example.com:5000/api:v2"
313+"current.example.com" = "registry.example.com/web:v3"
314+"bare.example.com" = "worker:v4"
315+"external.example.com" = "docker.io/library/nginx:latest"
316+`
317+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
318+ require.NoError(t, err)
319+
320+ v := viper.New()
321+ v.SetConfigFile(configFile)
322+ require.NoError(t, v.ReadInConfig())
323+
324+ eventBus := mocks.NewMockEventPublisher(t)
325+ svc := NewService(v, eventBus)
326+ ctx := testContext()
327+
328+ require.NoError(t, svc.Load(ctx))
329+ assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image)
330+
331+ require.NoError(t, svc.Save(ctx))
332+
333+ content, err := os.ReadFile(configFile)
334+ require.NoError(t, err)
335+ text := string(content)
336+ assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`)
337+ assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`)
338+ assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`)
339+ assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`)
340+ assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`)
341+}
342+
343+func TestCanonicalAttachmentsForSave(t *testing.T) {
344+ attachments := map[string][]string{
345+ "app.example.com": {
346+ "old-registry.example.com/postgres:18",
347+ "registry.example.com/postgres:18",
348+ "rabbitmq:3",
349+ "docker.io/library/nginx:latest",
350+ },
351+ }
352+
353+ saved := canonicalAttachmentsForSave(attachments, "registry.example.com", []string{"old-registry.example.com"})
354+
355+ assert.Equal(t, []string{
356+ "registry.example.com/postgres:18",
357+ "rabbitmq:3",
358+ "docker.io/library/nginx:latest",
359+ }, saved["app.example.com"])
360+ assert.Equal(t, []string{
361+ "old-registry.example.com/postgres:18",
362+ "registry.example.com/postgres:18",
363+ "rabbitmq:3",
364+ "docker.io/library/nginx:latest",
365+ }, attachments["app.example.com"])
366+}
367+
368+func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) {
369+ tmpDir := t.TempDir()
370+ configFile := filepath.Join(tmpDir, "gordon.toml")
371+ initialConfig := `[server]
372+ gordon_domain = "registry.example.com"
373+ legacy_registry_domains = ["old-registry.example.com"]
374+
375+ [attachments]
376+ "app.example.com" = [
377+ "old-registry.example.com/postgres:18",
378+ "registry.example.com/postgres:18",
379+ "registry.example.com/redis:7",
380+ "rabbitmq:3",
381+ "docker.io/library/nginx:latest",
382+ ]
383+ `
384+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
385+ require.NoError(t, err)
386+
387+ v := viper.New()
388+ v.SetConfigFile(configFile)
389+ require.NoError(t, v.ReadInConfig())
390+
391+ eventBus := mocks.NewMockEventPublisher(t)
392+ svc := NewService(v, eventBus)
393+ ctx := testContext()
394+
395+ require.NoError(t, svc.Load(ctx))
396+ require.Equal(t, []string{
397+ "old-registry.example.com/postgres:18",
398+ "registry.example.com/postgres:18",
399+ "registry.example.com/redis:7",
400+ "rabbitmq:3",
401+ "docker.io/library/nginx:latest",
402+ }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs")
403+
404+ require.NoError(t, svc.Save(ctx))
405+ require.Equal(t, []string{
406+ "old-registry.example.com/postgres:18",
407+ "registry.example.com/postgres:18",
408+ "registry.example.com/redis:7",
409+ "rabbitmq:3",
410+ "docker.io/library/nginx:latest",
411+ }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs")
412+
413+ saved := viper.New()
414+ saved.SetConfigFile(configFile)
415+ require.NoError(t, saved.ReadInConfig())
416+
417+ var attachments map[string][]string
418+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
419+ assert.Equal(t, []string{
420+ "registry.example.com/postgres:18",
421+ "registry.example.com/redis:7",
422+ "rabbitmq:3",
423+ "docker.io/library/nginx:latest",
424+ }, attachments["app.example.com"])
425+
426+ content, err := os.ReadFile(configFile)
427+ require.NoError(t, err)
428+ text := string(content)
429+ assert.Contains(t, text, `registry.example.com/postgres:18`)
430+ assert.Equal(t, 1, strings.Count(text, `registry.example.com/postgres:18`))
431+ assert.Contains(t, text, `registry.example.com/redis:7`)
432+ assert.Contains(t, text, `rabbitmq:3`)
433+ assert.Contains(t, text, `docker.io/library/nginx:latest`)
434+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
435+}
436+
276437 func TestService_Reload(t *testing.T) {
277438 t.Run("success - picks up config file changes", func(t *testing.T) {
278439 // Create temp config file
@@ -1382,6 +1543,103 @@ func TestService_AddAttachment(t *testing.T) {
13821543 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
13831544 })
13841545
1546+ t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) {
1547+ tmpDir := t.TempDir()
1548+ configFile := filepath.Join(tmpDir, "gordon.toml")
1549+ initialConfig := `[server]
1550+ gordon_domain = "registry.example.com"
1551+ legacy_registry_domains = ["old-registry.example.com"]
1552+
1553+ [attachments]
1554+ `
1555+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1556+ require.NoError(t, err)
1557+
1558+ v := viper.New()
1559+ v.SetConfigFile(configFile)
1560+ require.NoError(t, v.ReadInConfig())
1561+
1562+ eventBus := mocks.NewMockEventPublisher(t)
1563+ svc := NewService(v, eventBus)
1564+ ctx := testContext()
1565+ require.NoError(t, svc.Load(ctx))
1566+
1567+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1568+ require.NoError(t, err)
1569+
1570+ require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1571+
1572+ saved := viper.New()
1573+ saved.SetConfigFile(configFile)
1574+ require.NoError(t, saved.ReadInConfig())
1575+
1576+ var attachments map[string][]string
1577+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
1578+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"])
1579+
1580+ content, err := os.ReadFile(configFile)
1581+ require.NoError(t, err)
1582+ text := string(content)
1583+ assert.Contains(t, text, `registry.example.com/postgres:18`)
1584+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
1585+ })
1586+
1587+ t.Run("rejects canonical duplicate when existing legacy ref matches current ref", func(t *testing.T) {
1588+ tmpDir := t.TempDir()
1589+ configFile := filepath.Join(tmpDir, "gordon.toml")
1590+ initialConfig := `[server]
1591+ gordon_domain = "registry.example.com"
1592+ legacy_registry_domains = ["old-registry.example.com"]
1593+
1594+ [attachments]
1595+ "app.example.com" = ["old-registry.example.com/postgres:18"]
1596+ `
1597+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1598+ require.NoError(t, err)
1599+
1600+ v := viper.New()
1601+ v.SetConfigFile(configFile)
1602+ require.NoError(t, v.ReadInConfig())
1603+
1604+ eventBus := mocks.NewMockEventPublisher(t)
1605+ svc := NewService(v, eventBus)
1606+ ctx := testContext()
1607+ require.NoError(t, svc.Load(ctx))
1608+
1609+ err = svc.AddAttachment(ctx, "app.example.com", "registry.example.com/postgres:18")
1610+
1611+ assert.ErrorIs(t, err, domain.ErrAttachmentExists)
1612+ assert.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1613+ })
1614+
1615+ t.Run("rejects canonical duplicate when existing current ref matches legacy ref", func(t *testing.T) {
1616+ tmpDir := t.TempDir()
1617+ configFile := filepath.Join(tmpDir, "gordon.toml")
1618+ initialConfig := `[server]
1619+ gordon_domain = "registry.example.com"
1620+ legacy_registry_domains = ["old-registry.example.com"]
1621+
1622+ [attachments]
1623+ "app.example.com" = ["registry.example.com/postgres:18"]
1624+ `
1625+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1626+ require.NoError(t, err)
1627+
1628+ v := viper.New()
1629+ v.SetConfigFile(configFile)
1630+ require.NoError(t, v.ReadInConfig())
1631+
1632+ eventBus := mocks.NewMockEventPublisher(t)
1633+ svc := NewService(v, eventBus)
1634+ ctx := testContext()
1635+ require.NoError(t, svc.Load(ctx))
1636+
1637+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1638+
1639+ assert.ErrorIs(t, err, domain.ErrAttachmentExists)
1640+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1641+ })
1642+
13851643 t.Run("duplicate attachment", func(t *testing.T) {
13861644 v := viper.New()
13871645 v.Set("attachments", map[string]interface{}{
@@ -1700,23 +1958,25 @@ func TestSplitImageNameTag(t *testing.T) {
17001958
17011959 func TestNormalizeRegistryImage(t *testing.T) {
17021960 tests := []struct {
1703- name string
1704- imageName string
1705- registryDomain string
1706- expected string
1961+ name string
1962+ imageName string
1963+ registryDomain string
1964+ legacyRegistryDomains []string
1965+ expected string
17071966 }{
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", ""},
1967+ {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"},
1968+ {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"},
1969+ {"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"},
1970+ {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"},
1971+ {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"},
1972+ {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"},
1973+ {"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"},
1974+ {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"},
17151975 }
17161976
17171977 for _, tt := range tests {
17181978 t.Run(tt.name, func(t *testing.T) {
1719- result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)
1979+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains)
17201980 assert.Equal(t, tt.expected, result)
17211981 })
17221982 }
@@ -1869,6 +2129,24 @@ func TestService_FindRoutesByImage(t *testing.T) {
18692129 assert.Equal(t, "app.example.com", routes[0].Domain)
18702130 })
18712131
2132+ t.Run("matches current image when route stores legacy registry host", func(t *testing.T) {
2133+ v := viper.New()
2134+ v.Set("server.gordon_domain", "new-registry.example.com")
2135+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2136+ v.Set("routes", map[string]interface{}{
2137+ "app.example.com": "old-registry.example.com/myapp:latest",
2138+ })
2139+
2140+ eventBus := mocks.NewMockEventPublisher(t)
2141+ svc := NewService(v, eventBus)
2142+ ctx := testContext()
2143+ require.NoError(t, svc.Load(ctx))
2144+
2145+ routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest")
2146+ assert.Len(t, routes, 1)
2147+ assert.Equal(t, "app.example.com", routes[0].Domain)
2148+ })
2149+
18722150 t.Run("multiple routes for same image", func(t *testing.T) {
18732151 v := viper.New()
18742152 v.Set("routes", map[string]interface{}{
@@ -1980,6 +2258,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) {
19802258 assert.Equal(t, []string{"backend"}, targets)
19812259 })
19822260
2261+ t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) {
2262+ v := viper.New()
2263+ v.Set("server.gordon_domain", "new-registry.example.com")
2264+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2265+ v.Set("attachments", map[string]interface{}{
2266+ "backend": []interface{}{"old-registry.example.com/postgres:18"},
2267+ })
2268+
2269+ eventBus := mocks.NewMockEventPublisher(t)
2270+ svc := NewService(v, eventBus)
2271+ ctx := testContext()
2272+ require.NoError(t, svc.Load(ctx))
2273+
2274+ targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18")
2275+ assert.Equal(t, []string{"backend"}, targets)
2276+ })
2277+
19832278 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
19842279 v := viper.New()
19852280 v.Set("attachments", map[string]interface{}{
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
8 "testing"9 "testing"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
@@ -217,6 +218,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test
217 assert.True(t, routes[0].HTTPS)218 assert.True(t, routes[0].HTTPS)
218 }219 }
219 220
221+func TestCanonicalRoutesForSave(t *testing.T) {
222+ routes := map[string]routeConfig{
223+ "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true},
224+ "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true},
225+ "bare.example.com": {Image: "worker:v2", HTTPS: true},
226+ "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true},
227+ "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true},
228+ }
229+
230+ savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"})
231+ require.NoError(t, err)
232+
233+ assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com"))
234+ assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com"))
235+ assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com"))
236+ assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com"))
237+ assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com"))
238+
239+ assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image)
240+ assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image)
241+}
242+
220 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {243 func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
221 tmpDir := t.TempDir()244 tmpDir := t.TempDir()
222 configFile := filepath.Join(tmpDir, "gordon.toml")245 configFile := filepath.Join(tmpDir, "gordon.toml")
@@ -242,9 +265,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) {
242 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {265 func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
243 tmpDir := t.TempDir()266 tmpDir := t.TempDir()
244 configFile := filepath.Join(tmpDir, "gordon.toml")267 configFile := filepath.Join(tmpDir, "gordon.toml")
245- initialConfig := `[routes]268+ initialConfig := `[server]
246-"secure.example.com" = "secure:v1"269+gordon_domain = "registry.example.com"
247-"http://insecure.example.com" = "insecure:v1"270+legacy_registry_domains = ["old-registry.example.com"]
271+
272+[routes]
273+"secure.example.com" = "old-registry.example.com/secure:v1"
274+"http://insecure.example.com" = "old-registry.example.com/insecure:v1"
248 `275 `
249 err := os.WriteFile(configFile, []byte(initialConfig), 0600)276 err := os.WriteFile(configFile, []byte(initialConfig), 0600)
250 require.NoError(t, err)277 require.NoError(t, err)
@@ -266,13 +293,147 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) {
266 content, err := os.ReadFile(configFile)293 content, err := os.ReadFile(configFile)
267 require.NoError(t, err)294 require.NoError(t, err)
268 text := string(content)295 text := string(content)
269- assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`)296+ 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 }`)297+ 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 }`)298 assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`)
272 assert.NotContains(t, text, "http://insecure.example.com")299 assert.NotContains(t, text, "http://insecure.example.com")
273 assert.Contains(t, text, "[routes]")300 assert.Contains(t, text, "[routes]")
274 }301 }
275 302
303+func TestService_SaveCanonicalizesRouteImages(t *testing.T) {
304+ tmpDir := t.TempDir()
305+ configFile := filepath.Join(tmpDir, "gordon.toml")
306+ initialConfig := `[server]
307+gordon_domain = "registry.example.com"
308+legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"]
309+
310+[routes]
311+"legacy.example.com" = "old-registry.example.com/app:latest"
312+"legacy-port.example.com" = "old-registry.example.com:5000/api:v2"
313+"current.example.com" = "registry.example.com/web:v3"
314+"bare.example.com" = "worker:v4"
315+"external.example.com" = "docker.io/library/nginx:latest"
316+`
317+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
318+ require.NoError(t, err)
319+
320+ v := viper.New()
321+ v.SetConfigFile(configFile)
322+ require.NoError(t, v.ReadInConfig())
323+
324+ eventBus := mocks.NewMockEventPublisher(t)
325+ svc := NewService(v, eventBus)
326+ ctx := testContext()
327+
328+ require.NoError(t, svc.Load(ctx))
329+ assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image)
330+
331+ require.NoError(t, svc.Save(ctx))
332+
333+ content, err := os.ReadFile(configFile)
334+ require.NoError(t, err)
335+ text := string(content)
336+ assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`)
337+ assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`)
338+ assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`)
339+ assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`)
340+ assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`)
341+}
342+
343+func TestCanonicalAttachmentsForSave(t *testing.T) {
344+ attachments := map[string][]string{
345+ "app.example.com": {
346+ "old-registry.example.com/postgres:18",
347+ "registry.example.com/postgres:18",
348+ "rabbitmq:3",
349+ "docker.io/library/nginx:latest",
350+ },
351+ }
352+
353+ saved := canonicalAttachmentsForSave(attachments, "registry.example.com", []string{"old-registry.example.com"})
354+
355+ assert.Equal(t, []string{
356+ "registry.example.com/postgres:18",
357+ "rabbitmq:3",
358+ "docker.io/library/nginx:latest",
359+ }, saved["app.example.com"])
360+ assert.Equal(t, []string{
361+ "old-registry.example.com/postgres:18",
362+ "registry.example.com/postgres:18",
363+ "rabbitmq:3",
364+ "docker.io/library/nginx:latest",
365+ }, attachments["app.example.com"])
366+}
367+
368+func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) {
369+ tmpDir := t.TempDir()
370+ configFile := filepath.Join(tmpDir, "gordon.toml")
371+ initialConfig := `[server]
372+ gordon_domain = "registry.example.com"
373+ legacy_registry_domains = ["old-registry.example.com"]
374+
375+ [attachments]
376+ "app.example.com" = [
377+ "old-registry.example.com/postgres:18",
378+ "registry.example.com/postgres:18",
379+ "registry.example.com/redis:7",
380+ "rabbitmq:3",
381+ "docker.io/library/nginx:latest",
382+ ]
383+ `
384+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
385+ require.NoError(t, err)
386+
387+ v := viper.New()
388+ v.SetConfigFile(configFile)
389+ require.NoError(t, v.ReadInConfig())
390+
391+ eventBus := mocks.NewMockEventPublisher(t)
392+ svc := NewService(v, eventBus)
393+ ctx := testContext()
394+
395+ require.NoError(t, svc.Load(ctx))
396+ require.Equal(t, []string{
397+ "old-registry.example.com/postgres:18",
398+ "registry.example.com/postgres:18",
399+ "registry.example.com/redis:7",
400+ "rabbitmq:3",
401+ "docker.io/library/nginx:latest",
402+ }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs")
403+
404+ require.NoError(t, svc.Save(ctx))
405+ require.Equal(t, []string{
406+ "old-registry.example.com/postgres:18",
407+ "registry.example.com/postgres:18",
408+ "registry.example.com/redis:7",
409+ "rabbitmq:3",
410+ "docker.io/library/nginx:latest",
411+ }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs")
412+
413+ saved := viper.New()
414+ saved.SetConfigFile(configFile)
415+ require.NoError(t, saved.ReadInConfig())
416+
417+ var attachments map[string][]string
418+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
419+ assert.Equal(t, []string{
420+ "registry.example.com/postgres:18",
421+ "registry.example.com/redis:7",
422+ "rabbitmq:3",
423+ "docker.io/library/nginx:latest",
424+ }, attachments["app.example.com"])
425+
426+ content, err := os.ReadFile(configFile)
427+ require.NoError(t, err)
428+ text := string(content)
429+ assert.Contains(t, text, `registry.example.com/postgres:18`)
430+ assert.Equal(t, 1, strings.Count(text, `registry.example.com/postgres:18`))
431+ assert.Contains(t, text, `registry.example.com/redis:7`)
432+ assert.Contains(t, text, `rabbitmq:3`)
433+ assert.Contains(t, text, `docker.io/library/nginx:latest`)
434+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
435+}
436+
276 func TestService_Reload(t *testing.T) {437 func TestService_Reload(t *testing.T) {
277 t.Run("success - picks up config file changes", func(t *testing.T) {438 t.Run("success - picks up config file changes", func(t *testing.T) {
278 // Create temp config file439 // Create temp config file
@@ -1382,6 +1543,103 @@ func TestService_AddAttachment(t *testing.T) {
1382 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])1543 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"])
1383 })1544 })
1384 1545
1546+ t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) {
1547+ tmpDir := t.TempDir()
1548+ configFile := filepath.Join(tmpDir, "gordon.toml")
1549+ initialConfig := `[server]
1550+ gordon_domain = "registry.example.com"
1551+ legacy_registry_domains = ["old-registry.example.com"]
1552+
1553+ [attachments]
1554+ `
1555+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1556+ require.NoError(t, err)
1557+
1558+ v := viper.New()
1559+ v.SetConfigFile(configFile)
1560+ require.NoError(t, v.ReadInConfig())
1561+
1562+ eventBus := mocks.NewMockEventPublisher(t)
1563+ svc := NewService(v, eventBus)
1564+ ctx := testContext()
1565+ require.NoError(t, svc.Load(ctx))
1566+
1567+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1568+ require.NoError(t, err)
1569+
1570+ require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1571+
1572+ saved := viper.New()
1573+ saved.SetConfigFile(configFile)
1574+ require.NoError(t, saved.ReadInConfig())
1575+
1576+ var attachments map[string][]string
1577+ require.NoError(t, saved.UnmarshalKey("attachments", &attachments))
1578+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"])
1579+
1580+ content, err := os.ReadFile(configFile)
1581+ require.NoError(t, err)
1582+ text := string(content)
1583+ assert.Contains(t, text, `registry.example.com/postgres:18`)
1584+ assert.NotContains(t, text, `old-registry.example.com/postgres:18`)
1585+ })
1586+
1587+ t.Run("rejects canonical duplicate when existing legacy ref matches current ref", func(t *testing.T) {
1588+ tmpDir := t.TempDir()
1589+ configFile := filepath.Join(tmpDir, "gordon.toml")
1590+ initialConfig := `[server]
1591+ gordon_domain = "registry.example.com"
1592+ legacy_registry_domains = ["old-registry.example.com"]
1593+
1594+ [attachments]
1595+ "app.example.com" = ["old-registry.example.com/postgres:18"]
1596+ `
1597+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1598+ require.NoError(t, err)
1599+
1600+ v := viper.New()
1601+ v.SetConfigFile(configFile)
1602+ require.NoError(t, v.ReadInConfig())
1603+
1604+ eventBus := mocks.NewMockEventPublisher(t)
1605+ svc := NewService(v, eventBus)
1606+ ctx := testContext()
1607+ require.NoError(t, svc.Load(ctx))
1608+
1609+ err = svc.AddAttachment(ctx, "app.example.com", "registry.example.com/postgres:18")
1610+
1611+ assert.ErrorIs(t, err, domain.ErrAttachmentExists)
1612+ assert.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1613+ })
1614+
1615+ t.Run("rejects canonical duplicate when existing current ref matches legacy ref", func(t *testing.T) {
1616+ tmpDir := t.TempDir()
1617+ configFile := filepath.Join(tmpDir, "gordon.toml")
1618+ initialConfig := `[server]
1619+ gordon_domain = "registry.example.com"
1620+ legacy_registry_domains = ["old-registry.example.com"]
1621+
1622+ [attachments]
1623+ "app.example.com" = ["registry.example.com/postgres:18"]
1624+ `
1625+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
1626+ require.NoError(t, err)
1627+
1628+ v := viper.New()
1629+ v.SetConfigFile(configFile)
1630+ require.NoError(t, v.ReadInConfig())
1631+
1632+ eventBus := mocks.NewMockEventPublisher(t)
1633+ svc := NewService(v, eventBus)
1634+ ctx := testContext()
1635+ require.NoError(t, svc.Load(ctx))
1636+
1637+ err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18")
1638+
1639+ assert.ErrorIs(t, err, domain.ErrAttachmentExists)
1640+ assert.Equal(t, []string{"registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"])
1641+ })
1642+
1385 t.Run("duplicate attachment", func(t *testing.T) {1643 t.Run("duplicate attachment", func(t *testing.T) {
1386 v := viper.New()1644 v := viper.New()
1387 v.Set("attachments", map[string]interface{}{1645 v.Set("attachments", map[string]interface{}{
@@ -1700,23 +1958,25 @@ func TestSplitImageNameTag(t *testing.T) {
1700 1958
1701 func TestNormalizeRegistryImage(t *testing.T) {1959 func TestNormalizeRegistryImage(t *testing.T) {
1702 tests := []struct {1960 tests := []struct {
1703- name string1961+ name string
1704- imageName string1962+ imageName string
1705- registryDomain string1963+ registryDomain string
1706- expected string1964+ legacyRegistryDomains []string
1965+ expected string
1707 }{1966 }{
1708- {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"},1967+ {"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"},1968+ {"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"},1969+ {"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"},1970+ {"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"},1971+ {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"},
1713- {"bare name no registry", "myapp", "reg.example.com", "myapp"},1972+ {"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", ""},1973+ {"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"},
1974+ {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"},
1715 }1975 }
1716 1976
1717 for _, tt := range tests {1977 for _, tt := range tests {
1718 t.Run(tt.name, func(t *testing.T) {1978 t.Run(tt.name, func(t *testing.T) {
1719- result := NormalizeRegistryImage(tt.imageName, tt.registryDomain)1979+ result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains)
1720 assert.Equal(t, tt.expected, result)1980 assert.Equal(t, tt.expected, result)
1721 })1981 })
1722 }1982 }
@@ -1869,6 +2129,24 @@ func TestService_FindRoutesByImage(t *testing.T) {
1869 assert.Equal(t, "app.example.com", routes[0].Domain)2129 assert.Equal(t, "app.example.com", routes[0].Domain)
1870 })2130 })
1871 2131
2132+ t.Run("matches current image when route stores legacy registry host", func(t *testing.T) {
2133+ v := viper.New()
2134+ v.Set("server.gordon_domain", "new-registry.example.com")
2135+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2136+ v.Set("routes", map[string]interface{}{
2137+ "app.example.com": "old-registry.example.com/myapp:latest",
2138+ })
2139+
2140+ eventBus := mocks.NewMockEventPublisher(t)
2141+ svc := NewService(v, eventBus)
2142+ ctx := testContext()
2143+ require.NoError(t, svc.Load(ctx))
2144+
2145+ routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest")
2146+ assert.Len(t, routes, 1)
2147+ assert.Equal(t, "app.example.com", routes[0].Domain)
2148+ })
2149+
1872 t.Run("multiple routes for same image", func(t *testing.T) {2150 t.Run("multiple routes for same image", func(t *testing.T) {
1873 v := viper.New()2151 v := viper.New()
1874 v.Set("routes", map[string]interface{}{2152 v.Set("routes", map[string]interface{}{
@@ -1980,6 +2258,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) {
1980 assert.Equal(t, []string{"backend"}, targets)2258 assert.Equal(t, []string{"backend"}, targets)
1981 })2259 })
1982 2260
2261+ t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) {
2262+ v := viper.New()
2263+ v.Set("server.gordon_domain", "new-registry.example.com")
2264+ v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"})
2265+ v.Set("attachments", map[string]interface{}{
2266+ "backend": []interface{}{"old-registry.example.com/postgres:18"},
2267+ })
2268+
2269+ eventBus := mocks.NewMockEventPublisher(t)
2270+ svc := NewService(v, eventBus)
2271+ ctx := testContext()
2272+ require.NoError(t, svc.Load(ctx))
2273+
2274+ targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18")
2275+ assert.Equal(t, []string{"backend"}, targets)
2276+ })
2277+
1983 t.Run("shared attachment used by multiple targets", func(t *testing.T) {2278 t.Run("shared attachment used by multiple targets", func(t *testing.T) {
1984 v := viper.New()2279 v := viper.New()
1985 v.Set("attachments", map[string]interface{}{2280 v.Set("attachments", map[string]interface{}{
M internal/usecase/container/autoroute.go +34 -16
@@ -22,15 +22,21 @@ type EnvFileExtractor interface {
2222 ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error)
2323 }
2424
25+type registryDomainsProvider interface {
26+ GetRegistryDomain() string
27+ GetLegacyRegistryDomains() []string
28+}
29+
2530 // AutoRouteHandler handles image.pushed events for auto-route from labels.
2631 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
32+ configSvc in.ConfigService
33+ containerSvc in.ContainerService
34+ blobStorage out.BlobStorage
35+ extractor EnvFileExtractor
36+ registryDomain string
37+ legacyRegistryDomains []string
38+ envDir string
39+ ctx context.Context
3440 }
3541
3642 // NewAutoRouteHandler creates a new AutoRouteHandler.
@@ -40,13 +46,15 @@ func NewAutoRouteHandler(
4046 containerSvc in.ContainerService,
4147 blobStorage out.BlobStorage,
4248 registryDomain string,
49+ legacyRegistryDomains ...string,
4350 ) *AutoRouteHandler {
4451 return &AutoRouteHandler{
45- configSvc: configSvc,
46- containerSvc: containerSvc,
47- blobStorage: blobStorage,
48- registryDomain: registryDomain,
49- ctx: ctx,
52+ configSvc: configSvc,
53+ containerSvc: containerSvc,
54+ blobStorage: blobStorage,
55+ registryDomain: registryDomain,
56+ legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...),
57+ ctx: ctx,
5058 }
5159 }
5260
@@ -130,15 +138,24 @@ func (h *AutoRouteHandler) buildImageName(name, reference string) string {
130138 return fmt.Sprintf("%s:%s", name, reference)
131139 }
132140
141+func (h *AutoRouteHandler) registryDomains() (string, []string) {
142+ provider, ok := h.configSvc.(registryDomainsProvider)
143+ if !ok {
144+ return h.registryDomain, append([]string(nil), h.legacyRegistryDomains...)
145+ }
146+ return provider.GetRegistryDomain(), provider.GetLegacyRegistryDomains()
147+}
148+
133149 // buildFullImageRef constructs a fully qualified image reference with registry domain.
134150 // This is needed for Docker operations since images are stored with the registry prefix.
135151 func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
136- if h.registryDomain == "" {
152+ registryDomain, _ := h.registryDomains()
153+ if registryDomain == "" {
137154 return imageName
138155 }
139156
140157 // Don't prefix if already has registry domain
141- if strings.HasPrefix(imageName, h.registryDomain+"/") {
158+ if strings.HasPrefix(imageName, registryDomain+"/") {
142159 return imageName
143160 }
144161
@@ -151,7 +168,7 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
151168 return imageName
152169 }
153170
154- return fmt.Sprintf("%s/%s", h.registryDomain, imageName)
171+ return fmt.Sprintf("%s/%s", registryDomain, imageName)
155172 }
156173
157174 // processRoutes processes each domain and creates/updates routes.
@@ -208,11 +225,12 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
208225 HTTPS: true,
209226 }
210227
228+ registryDomain, legacyRegistryDomains := h.registryDomains()
211229 existingRoutes := h.configSvc.GetRoutes(ctx)
212230 for _, existing := range existingRoutes {
213231 if existing.Domain == routeDomain {
214232 route.HTTPS = existing.HTTPS
215- if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {
233+ if auto.ExtractRepoName(existing.Image, registryDomain, legacyRegistryDomains...) != auto.ExtractRepoName(imageName, registryDomain, legacyRegistryDomains...) {
216234 log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch")
217235 return false
218236 }
@@ -22,15 +22,21 @@ type EnvFileExtractor interface {
22 ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error)22 ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error)
23 }23 }
24 24
25+type registryDomainsProvider interface {
26+ GetRegistryDomain() string
27+ GetLegacyRegistryDomains() []string
28+}
29+
25 // AutoRouteHandler handles image.pushed events for auto-route from labels.30 // AutoRouteHandler handles image.pushed events for auto-route from labels.
26 type AutoRouteHandler struct {31 type AutoRouteHandler struct {
27- configSvc in.ConfigService32+ configSvc in.ConfigService
28- containerSvc in.ContainerService33+ containerSvc in.ContainerService
29- blobStorage out.BlobStorage34+ blobStorage out.BlobStorage
30- extractor EnvFileExtractor35+ extractor EnvFileExtractor
31- registryDomain string36+ registryDomain string
32- envDir string37+ legacyRegistryDomains []string
33- ctx context.Context38+ envDir string
39+ ctx context.Context
34 }40 }
35 41
36 // NewAutoRouteHandler creates a new AutoRouteHandler.42 // NewAutoRouteHandler creates a new AutoRouteHandler.
@@ -40,13 +46,15 @@ func NewAutoRouteHandler(
40 containerSvc in.ContainerService,46 containerSvc in.ContainerService,
41 blobStorage out.BlobStorage,47 blobStorage out.BlobStorage,
42 registryDomain string,48 registryDomain string,
49+ legacyRegistryDomains ...string,
43 ) *AutoRouteHandler {50 ) *AutoRouteHandler {
44 return &AutoRouteHandler{51 return &AutoRouteHandler{
45- configSvc: configSvc,52+ configSvc: configSvc,
46- containerSvc: containerSvc,53+ containerSvc: containerSvc,
47- blobStorage: blobStorage,54+ blobStorage: blobStorage,
48- registryDomain: registryDomain,55+ registryDomain: registryDomain,
49- ctx: ctx,56+ legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...),
57+ ctx: ctx,
50 }58 }
51 }59 }
52 60
@@ -130,15 +138,24 @@ func (h *AutoRouteHandler) buildImageName(name, reference string) string {
130 return fmt.Sprintf("%s:%s", name, reference)138 return fmt.Sprintf("%s:%s", name, reference)
131 }139 }
132 140
141+func (h *AutoRouteHandler) registryDomains() (string, []string) {
142+ provider, ok := h.configSvc.(registryDomainsProvider)
143+ if !ok {
144+ return h.registryDomain, append([]string(nil), h.legacyRegistryDomains...)
145+ }
146+ return provider.GetRegistryDomain(), provider.GetLegacyRegistryDomains()
147+}
148+
133 // buildFullImageRef constructs a fully qualified image reference with registry domain.149 // buildFullImageRef constructs a fully qualified image reference with registry domain.
134 // This is needed for Docker operations since images are stored with the registry prefix.150 // This is needed for Docker operations since images are stored with the registry prefix.
135 func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {151 func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
136- if h.registryDomain == "" {152+ registryDomain, _ := h.registryDomains()
153+ if registryDomain == "" {
137 return imageName154 return imageName
138 }155 }
139 156
140 // Don't prefix if already has registry domain157 // Don't prefix if already has registry domain
141- if strings.HasPrefix(imageName, h.registryDomain+"/") {158+ if strings.HasPrefix(imageName, registryDomain+"/") {
142 return imageName159 return imageName
143 }160 }
144 161
@@ -151,7 +168,7 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string {
151 return imageName168 return imageName
152 }169 }
153 170
154- return fmt.Sprintf("%s/%s", h.registryDomain, imageName)171+ return fmt.Sprintf("%s/%s", registryDomain, imageName)
155 }172 }
156 173
157 // processRoutes processes each domain and creates/updates routes.174 // processRoutes processes each domain and creates/updates routes.
@@ -208,11 +225,12 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain,
208 HTTPS: true,225 HTTPS: true,
209 }226 }
210 227
228+ registryDomain, legacyRegistryDomains := h.registryDomains()
211 existingRoutes := h.configSvc.GetRoutes(ctx)229 existingRoutes := h.configSvc.GetRoutes(ctx)
212 for _, existing := range existingRoutes {230 for _, existing := range existingRoutes {
213 if existing.Domain == routeDomain {231 if existing.Domain == routeDomain {
214 route.HTTPS = existing.HTTPS232 route.HTTPS = existing.HTTPS
215- if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) {233+ if auto.ExtractRepoName(existing.Image, registryDomain, legacyRegistryDomains...) != auto.ExtractRepoName(imageName, registryDomain, 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")234 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 false235 return false
218 }236 }
M internal/usecase/container/autoroute_test.go +90 -0
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111
1212 "github.com/bnema/zerowrap"
13+ "github.com/spf13/viper"
1314 "github.com/stretchr/testify/assert"
1415 "github.com/stretchr/testify/mock"
1516 "github.com/stretchr/testify/require"
@@ -18,6 +19,7 @@ import (
1819 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1920 "github.com/bnema/gordon/internal/domain"
2021 "github.com/bnema/gordon/internal/usecase/auto"
22+ configusecase "github.com/bnema/gordon/internal/usecase/config"
2123 )
2224
2325 // domainToEnvFileName tests
@@ -829,6 +831,77 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
829831 assert.False(t, created)
830832 }
831833
834+func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) {
835+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
836+ configSvc := inmocks.NewMockConfigService(t)
837+ containerSvc := inmocks.NewMockContainerService(t)
838+ blobStorage := mocks.NewMockBlobStorage(t)
839+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
840+
841+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
842+ Domain: "app.example.com",
843+ Image: "old-registry.example.com/app:v1",
844+ HTTPS: true,
845+ }})
846+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
847+ Domain: "app.example.com",
848+ Image: "new-registry.example.com/app:v2",
849+ HTTPS: true,
850+ }).Return(nil)
851+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
852+ Domain: "app.example.com",
853+ Image: "new-registry.example.com/app:v2",
854+ HTTPS: true,
855+ }).Return(&domain.Container{ID: "c1"}, nil)
856+
857+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
858+ assert.False(t, created)
859+}
860+
861+func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) {
862+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
863+ tmpDir := t.TempDir()
864+ configFile := filepath.Join(tmpDir, "gordon.toml")
865+ require.NoError(t, os.WriteFile(configFile, []byte(`[server]
866+ gordon_domain = "old-registry.example.com"
867+
868+ [routes]
869+ "app.example.com" = "old-registry.example.com/app:v1"
870+ `), 0600))
871+
872+ v := viper.New()
873+ v.SetConfigFile(configFile)
874+ require.NoError(t, v.ReadInConfig())
875+
876+ configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t))
877+ require.NoError(t, configSvc.Load(ctx))
878+
879+ containerSvc := inmocks.NewMockContainerService(t)
880+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com")
881+
882+ require.NoError(t, os.WriteFile(configFile, []byte(`[server]
883+ gordon_domain = "new-registry.example.com"
884+ legacy_registry_domains = ["old-registry.example.com"]
885+
886+ [routes]
887+ "app.example.com" = "old-registry.example.com/app:v1"
888+ `), 0600))
889+ require.NoError(t, configSvc.Reload(ctx))
890+
891+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
892+ Domain: "app.example.com",
893+ Image: "new-registry.example.com/app:v2",
894+ HTTPS: true,
895+ }).Return(&domain.Container{ID: "c1"}, nil)
896+
897+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
898+ assert.False(t, created)
899+
900+ route, err := configSvc.GetRoute(ctx, "app.example.com")
901+ require.NoError(t, err)
902+ assert.Equal(t, "new-registry.example.com/app:v2", route.Image)
903+}
904+
832905 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
833906 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834907 configSvc := inmocks.NewMockConfigService(t)
@@ -842,6 +915,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
842915 assert.False(t, created)
843916 }
844917
918+func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) {
919+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
920+ configSvc := inmocks.NewMockConfigService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ blobStorage := mocks.NewMockBlobStorage(t)
923+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
924+
925+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
926+ Domain: "app.example.com",
927+ Image: "old-registry.example.com/oldapp:v1",
928+ HTTPS: true,
929+ }})
930+
931+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"})
932+ assert.False(t, created)
933+}
934+
845935 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
846936 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
847937 configSvc := inmocks.NewMockConfigService(t)
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13+ "github.com/spf13/viper"
13 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"15 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
@@ -18,6 +19,7 @@ import (
18 "github.com/bnema/gordon/internal/boundaries/out/mocks"19 "github.com/bnema/gordon/internal/boundaries/out/mocks"
19 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
20 "github.com/bnema/gordon/internal/usecase/auto"21 "github.com/bnema/gordon/internal/usecase/auto"
22+ configusecase "github.com/bnema/gordon/internal/usecase/config"
21 )23 )
22 24
23 // domainToEnvFileName tests25 // domainToEnvFileName tests
@@ -829,6 +831,77 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) {
829 assert.False(t, created)831 assert.False(t, created)
830 }832 }
831 833
834+func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) {
835+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
836+ configSvc := inmocks.NewMockConfigService(t)
837+ containerSvc := inmocks.NewMockContainerService(t)
838+ blobStorage := mocks.NewMockBlobStorage(t)
839+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
840+
841+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
842+ Domain: "app.example.com",
843+ Image: "old-registry.example.com/app:v1",
844+ HTTPS: true,
845+ }})
846+ configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{
847+ Domain: "app.example.com",
848+ Image: "new-registry.example.com/app:v2",
849+ HTTPS: true,
850+ }).Return(nil)
851+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
852+ Domain: "app.example.com",
853+ Image: "new-registry.example.com/app:v2",
854+ HTTPS: true,
855+ }).Return(&domain.Container{ID: "c1"}, nil)
856+
857+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
858+ assert.False(t, created)
859+}
860+
861+func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) {
862+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
863+ tmpDir := t.TempDir()
864+ configFile := filepath.Join(tmpDir, "gordon.toml")
865+ require.NoError(t, os.WriteFile(configFile, []byte(`[server]
866+ gordon_domain = "old-registry.example.com"
867+
868+ [routes]
869+ "app.example.com" = "old-registry.example.com/app:v1"
870+ `), 0600))
871+
872+ v := viper.New()
873+ v.SetConfigFile(configFile)
874+ require.NoError(t, v.ReadInConfig())
875+
876+ configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t))
877+ require.NoError(t, configSvc.Load(ctx))
878+
879+ containerSvc := inmocks.NewMockContainerService(t)
880+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com")
881+
882+ require.NoError(t, os.WriteFile(configFile, []byte(`[server]
883+ gordon_domain = "new-registry.example.com"
884+ legacy_registry_domains = ["old-registry.example.com"]
885+
886+ [routes]
887+ "app.example.com" = "old-registry.example.com/app:v1"
888+ `), 0600))
889+ require.NoError(t, configSvc.Reload(ctx))
890+
891+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
892+ Domain: "app.example.com",
893+ Image: "new-registry.example.com/app:v2",
894+ HTTPS: true,
895+ }).Return(&domain.Container{ID: "c1"}, nil)
896+
897+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"})
898+ assert.False(t, created)
899+
900+ route, err := configSvc.GetRoute(ctx, "app.example.com")
901+ require.NoError(t, err)
902+ assert.Equal(t, "new-registry.example.com/app:v2", route.Image)
903+}
904+
832 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {905 func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
833 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())906 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
834 configSvc := inmocks.NewMockConfigService(t)907 configSvc := inmocks.NewMockConfigService(t)
@@ -842,6 +915,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) {
842 assert.False(t, created)915 assert.False(t, created)
843 }916 }
844 917
918+func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) {
919+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
920+ configSvc := inmocks.NewMockConfigService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ blobStorage := mocks.NewMockBlobStorage(t)
923+ handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com")
924+
925+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
926+ Domain: "app.example.com",
927+ Image: "old-registry.example.com/oldapp:v1",
928+ HTTPS: true,
929+ }})
930+
931+ created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"})
932+ assert.False(t, created)
933+}
934+
845 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {935 func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) {
846 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())936 ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default())
847 configSvc := inmocks.NewMockConfigService(t)937 configSvc := inmocks.NewMockConfigService(t)
M internal/usecase/container/service.go +24 -58
@@ -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
@@ -1333,21 +1334,16 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
13331334 return results
13341335 }
13351336
1336-// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
1337-// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
1337+// stripRegistryPrefix removes the configured current or legacy Gordon registry
1338+// domain prefix from an image reference. For example,
1339+// "reg.example.com/myapp:latest" becomes "myapp:latest" when
1340+// "reg.example.com" is a current or legacy Gordon registry domain.
13381341 func (s *Service) stripRegistryPrefix(image string) string {
13391342 s.mu.RLock()
13401343 cfg := s.config
13411344 s.mu.RUnlock()
13421345
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
1346+ return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains)
13511347 }
13521348
13531349 // ListAttachments returns attachments for a domain.
@@ -1405,41 +1401,6 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
14051401 return health
14061402 }
14071403
1408-// EnsureManagedContainerRestartPolicies updates restart policy for all managed containers.
1409-func (s *Service) EnsureManagedContainerRestartPolicies(ctx context.Context) error {
1410- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1411- zerowrap.FieldLayer: "usecase",
1412- zerowrap.FieldUseCase: "EnsureManagedContainerRestartPolicies",
1413- })
1414- log := zerowrap.FromCtx(ctx)
1415-
1416- allContainers, err := s.runtime.ListContainers(ctx, true)
1417- if err != nil {
1418- return log.WrapErr(err, "failed to list containers for restart policy migration")
1419- }
1420-
1421- return s.ensureManagedContainerRestartPolicies(ctx, allContainers)
1422-}
1423-
1424-func (s *Service) ensureManagedContainerRestartPolicies(ctx context.Context, allContainers []*domain.Container) error {
1425- log := zerowrap.FromCtx(ctx)
1426-
1427- var errs []error
1428- for _, c := range allContainers {
1429- if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1430- continue
1431- }
1432- if err := s.runtime.EnsureContainerRestartPolicy(ctx, c.ID, domain.RestartPolicyAlways); err != nil {
1433- log.Warn().Err(err).
1434- Str(zerowrap.FieldEntityID, c.ID).
1435- Str("container_name", c.Name).
1436- Msg("failed to ensure managed container restart policy")
1437- errs = append(errs, fmt.Errorf("container %s: %w", c.ID, err))
1438- }
1439- }
1440- return errors.Join(errs...)
1441-}
1442-
14431404 // SyncContainers synchronizes containers with runtime state.
14441405 func (s *Service) SyncContainers(ctx context.Context) error {
14451406 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1448,9 +1409,9 @@ func (s *Service) SyncContainers(ctx context.Context) error {
14481409 })
14491410 log := zerowrap.FromCtx(ctx)
14501411
1451- // List containers without holding lock to avoid blocking during Docker API call.
1452- // Use all=true so the same runtime snapshot can migrate stopped containers and
1453- // rebuild the running-container maps without a second Docker list call.
1412+ // List containers without holding lock to avoid blocking during the runtime call.
1413+ // Use all=true so we can rebuild the running-container maps from a single
1414+ // snapshot without a second list call.
14541415 allContainers, err := s.runtime.ListContainers(ctx, true)
14551416 if err != nil {
14561417 return log.WrapErr(err, "failed to list containers")
@@ -1823,7 +1784,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist
18231784 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)
18241785 }
18251786
1826- if sameRegistry(registry, cfg.RegistryDomain) {
1787+ if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) {
18271788 return nil
18281789 }
18291790 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))
@@ -1975,7 +1936,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
19751936 s.mu.RLock()
19761937 cfg := s.config
19771938 s.mu.RUnlock()
1978- return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
1939+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true
19791940 }
19801941
19811942 // ensureImage ensures the image is available locally, pulling if needed.
@@ -3360,21 +3321,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
33603321 return prefix + imageRef
33613322 }
33623323
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 {
3324+// rewriteToLocalRegistry rewrites Gordon-managed image references to use the
3325+// local registry address. Current and legacy Gordon registry domains are
3326+// stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed.
3327+// External registries are preserved unchanged.
3328+func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string {
33663329 if imageRef == "" {
33673330 return imageRef
33683331 }
33693332
33703333 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
33713334 localPrefix := localRegistry + "/"
3372- imageRef = strings.TrimPrefix(imageRef, localPrefix)
3335+ if strings.HasPrefix(imageRef, localPrefix) {
3336+ return imageRef
3337+ }
33733338
3374- registryDomain = strings.TrimSuffix(registryDomain, "/")
3375- if registryDomain != "" {
3376- prefix := registryDomain + "/"
3377- imageRef = strings.TrimPrefix(imageRef, prefix)
3339+ if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) {
3340+ return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains)
3341+ }
3342+ if hasExplicitRegistry(imageRef) {
3343+ return imageRef
33783344 }
33793345
33803346 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
@@ -1333,21 +1334,16 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
1333 return results1334 return results
1334 }1335 }
1335 1336
1336-// stripRegistryPrefix removes the configured registry domain prefix from an image reference.1337+// stripRegistryPrefix removes the configured current or legacy Gordon registry
1337-// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".1338+// domain prefix from an image reference. For example,
1339+// "reg.example.com/myapp:latest" becomes "myapp:latest" when
1340+// "reg.example.com" is a current or legacy Gordon registry domain.
1338 func (s *Service) stripRegistryPrefix(image string) string {1341 func (s *Service) stripRegistryPrefix(image string) string {
1339 s.mu.RLock()1342 s.mu.RLock()
1340 cfg := s.config1343 cfg := s.config
1341 s.mu.RUnlock()1344 s.mu.RUnlock()
1342 1345
1343- if cfg.RegistryDomain == "" {1346+ 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 }1347 }
1352 1348
1353 // ListAttachments returns attachments for a domain.1349 // ListAttachments returns attachments for a domain.
@@ -1405,41 +1401,6 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
1405 return health1401 return health
1406 }1402 }
1407 1403
1408-// EnsureManagedContainerRestartPolicies updates restart policy for all managed containers.
1409-func (s *Service) EnsureManagedContainerRestartPolicies(ctx context.Context) error {
1410- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1411- zerowrap.FieldLayer: "usecase",
1412- zerowrap.FieldUseCase: "EnsureManagedContainerRestartPolicies",
1413- })
1414- log := zerowrap.FromCtx(ctx)
1415-
1416- allContainers, err := s.runtime.ListContainers(ctx, true)
1417- if err != nil {
1418- return log.WrapErr(err, "failed to list containers for restart policy migration")
1419- }
1420-
1421- return s.ensureManagedContainerRestartPolicies(ctx, allContainers)
1422-}
1423-
1424-func (s *Service) ensureManagedContainerRestartPolicies(ctx context.Context, allContainers []*domain.Container) error {
1425- log := zerowrap.FromCtx(ctx)
1426-
1427- var errs []error
1428- for _, c := range allContainers {
1429- if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1430- continue
1431- }
1432- if err := s.runtime.EnsureContainerRestartPolicy(ctx, c.ID, domain.RestartPolicyAlways); err != nil {
1433- log.Warn().Err(err).
1434- Str(zerowrap.FieldEntityID, c.ID).
1435- Str("container_name", c.Name).
1436- Msg("failed to ensure managed container restart policy")
1437- errs = append(errs, fmt.Errorf("container %s: %w", c.ID, err))
1438- }
1439- }
1440- return errors.Join(errs...)
1441-}
1442-
1443 // SyncContainers synchronizes containers with runtime state.1404 // SyncContainers synchronizes containers with runtime state.
1444 func (s *Service) SyncContainers(ctx context.Context) error {1405 func (s *Service) SyncContainers(ctx context.Context) error {
1445 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1406 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1448,9 +1409,9 @@ func (s *Service) SyncContainers(ctx context.Context) error {
1448 })1409 })
1449 log := zerowrap.FromCtx(ctx)1410 log := zerowrap.FromCtx(ctx)
1450 1411
1451- // List containers without holding lock to avoid blocking during Docker API call.1412+ // List containers without holding lock to avoid blocking during the runtime call.
1452- // Use all=true so the same runtime snapshot can migrate stopped containers and1413+ // Use all=true so we can rebuild the running-container maps from a single
1453- // rebuild the running-container maps without a second Docker list call.1414+ // snapshot without a second list call.
1454 allContainers, err := s.runtime.ListContainers(ctx, true)1415 allContainers, err := s.runtime.ListContainers(ctx, true)
1455 if err != nil {1416 if err != nil {
1456 return log.WrapErr(err, "failed to list containers")1417 return log.WrapErr(err, "failed to list containers")
@@ -1823,7 +1784,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist
1823 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)1784 return fmt.Errorf("image %q rejected: invalid image reference", imageRef)
1824 }1785 }
1825 1786
1826- if sameRegistry(registry, cfg.RegistryDomain) {1787+ if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) {
1827 return nil1788 return nil
1828 }1789 }
1829 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))1790 dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry))
@@ -1975,7 +1936,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
1975 s.mu.RLock()1936 s.mu.RLock()
1976 cfg := s.config1937 cfg := s.config
1977 s.mu.RUnlock()1938 s.mu.RUnlock()
1978- return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true1939+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true
1979 }1940 }
1980 1941
1981 // ensureImage ensures the image is available locally, pulling if needed.1942 // ensureImage ensures the image is available locally, pulling if needed.
@@ -3360,21 +3321,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
3360 return prefix + imageRef3321 return prefix + imageRef
3361 }3322 }
3362 3323
3363-// rewriteToLocalRegistry rewrites an image reference to use the local registry address.3324+// rewriteToLocalRegistry rewrites Gordon-managed image references to use the
3364-// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"3325+// local registry address. Current and legacy Gordon registry domains are
3365-func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {3326+// stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed.
3327+// External registries are preserved unchanged.
3328+func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string {
3366 if imageRef == "" {3329 if imageRef == "" {
3367 return imageRef3330 return imageRef
3368 }3331 }
3369 3332
3370 localRegistry := fmt.Sprintf("localhost:%d", registryPort)3333 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
3371 localPrefix := localRegistry + "/"3334 localPrefix := localRegistry + "/"
3372- imageRef = strings.TrimPrefix(imageRef, localPrefix)3335+ if strings.HasPrefix(imageRef, localPrefix) {
3336+ return imageRef
3337+ }
3373 3338
3374- registryDomain = strings.TrimSuffix(registryDomain, "/")3339+ if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) {
3375- if registryDomain != "" {3340+ return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains)
3376- prefix := registryDomain + "/"3341+ }
3377- imageRef = strings.TrimPrefix(imageRef, prefix)3342+ if hasExplicitRegistry(imageRef) {
3343+ return imageRef
3378 }3344 }
3379 3345
3380 return localPrefix + imageRef3346 return localPrefix + imageRef
M internal/usecase/container/service_test.go +111 -107
@@ -1237,97 +1237,6 @@ func TestService_HealthCheck(t *testing.T) {
12371237 assert.False(t, result["unhealthy.example.com"])
12381238 }
12391239
1240-func TestService_EnsureManagedContainerRestartPolicies_EnsuresRoutesAttachmentsAndStoppedContainers(t *testing.T) {
1241- runtime := mocks.NewMockContainerRuntime(t)
1242- envLoader := mocks.NewMockEnvLoader(t)
1243- eventBus := mocks.NewMockEventPublisher(t)
1244-
1245- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1246- ctx := testContext()
1247-
1248- routeContainer := &domain.Container{
1249- ID: "route-1",
1250- Name: "gordon-app.example.com",
1251- Status: string(domain.ContainerStatusRunning),
1252- Labels: map[string]string{
1253- domain.LabelManaged: "true",
1254- domain.LabelDomain: "app.example.com",
1255- },
1256- }
1257- attachmentContainer := &domain.Container{
1258- ID: "attachment-1",
1259- Name: "gordon-app-example-com-postgres",
1260- Status: string(domain.ContainerStatusRunning),
1261- Labels: map[string]string{
1262- domain.LabelManaged: "true",
1263- domain.LabelAttachment: "true",
1264- domain.LabelAttachedTo: "app.example.com",
1265- },
1266- }
1267- stoppedContainer := &domain.Container{
1268- ID: "stopped-1",
1269- Name: "gordon-stopped.example.com",
1270- Status: string(domain.ContainerStatusExited),
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- domain.LabelDomain: "stopped.example.com",
1274- },
1275- }
1276- unmanagedContainer := &domain.Container{
1277- ID: "other-1",
1278- Labels: map[string]string{},
1279- }
1280-
1281- runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1282- routeContainer,
1283- attachmentContainer,
1284- stoppedContainer,
1285- unmanagedContainer,
1286- }, nil).Once()
1287- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(nil).Once()
1288- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "attachment-1", domain.RestartPolicyAlways).Return(nil).Once()
1289- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "stopped-1", domain.RestartPolicyAlways).Return(nil).Once()
1290- err := svc.EnsureManagedContainerRestartPolicies(ctx)
1291-
1292- require.NoError(t, err)
1293- runtime.AssertNumberOfCalls(t, "EnsureContainerRestartPolicy", 3)
1294-}
1295-
1296-func TestService_EnsureManagedContainerRestartPolicies_ContinuesAndReturnsError(t *testing.T) {
1297- runtime := mocks.NewMockContainerRuntime(t)
1298- envLoader := mocks.NewMockEnvLoader(t)
1299- eventBus := mocks.NewMockEventPublisher(t)
1300-
1301- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1302- ctx := testContext()
1303-
1304- routeContainer := &domain.Container{
1305- ID: "route-1",
1306- Name: "gordon-app.example.com",
1307- Labels: map[string]string{
1308- domain.LabelManaged: "true",
1309- domain.LabelDomain: "app.example.com",
1310- },
1311- }
1312- routeContainer2 := &domain.Container{
1313- ID: "route-2",
1314- Name: "gordon-api.example.com",
1315- Labels: map[string]string{
1316- domain.LabelManaged: "true",
1317- domain.LabelDomain: "api.example.com",
1318- },
1319- }
1320-
1321- runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{routeContainer, routeContainer2}, nil).Once()
1322- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(errors.New("update failed")).Once()
1323- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-2", domain.RestartPolicyAlways).Return(nil).Once()
1324- err := svc.EnsureManagedContainerRestartPolicies(ctx)
1325-
1326- require.Error(t, err)
1327- assert.Contains(t, err.Error(), "route-1")
1328- assert.Contains(t, err.Error(), "update failed")
1329-}
1330-
13311240 func TestService_SyncContainers(t *testing.T) {
13321241 runtime := mocks.NewMockContainerRuntime(t)
13331242 envLoader := mocks.NewMockEnvLoader(t)
@@ -1992,11 +1901,12 @@ func TestRewriteToRegistryDomain(t *testing.T) {
19921901
19931902 func TestRewriteToLocalRegistry(t *testing.T) {
19941903 tests := []struct {
1995- name string
1996- imageRef string
1997- registryDomain string
1998- registryPort int
1999- wantRef string
1904+ name string
1905+ imageRef string
1906+ registryDomain string
1907+ legacyRegistryDomains []string
1908+ registryPort int
1909+ wantRef string
20001910 }{
20011911 {
20021912 name: "rewrites registry domain prefix",
@@ -2020,17 +1930,42 @@ func TestRewriteToLocalRegistry(t *testing.T) {
20201930 wantRef: "localhost:5000/myapp:latest",
20211931 },
20221932 {
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",
1933+ name: "rewrites legacy registry domain prefix",
1934+ imageRef: "old-registry.example.com/myapp:latest",
1935+ registryDomain: "new-registry.example.com",
1936+ legacyRegistryDomains: []string{"old-registry.example.com"},
1937+ registryPort: 5000,
1938+ wantRef: "localhost:5000/myapp:latest",
1939+ },
1940+ {
1941+ name: "rewrites legacy registry domain prefix with explicit port and digest",
1942+ imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef",
1943+ registryDomain: "new-registry.example.com",
1944+ legacyRegistryDomains: []string{"old-registry.example.com:5001"},
1945+ registryPort: 5000,
1946+ wantRef: "localhost:5000/myapp@sha256:deadbeef",
1947+ },
1948+ {
1949+ name: "external image remains external",
1950+ imageRef: "docker.io/library/nginx:latest",
1951+ registryDomain: "new-registry.example.com",
1952+ legacyRegistryDomains: []string{"old-registry.example.com"},
1953+ registryPort: 5000,
1954+ wantRef: "docker.io/library/nginx:latest",
1955+ },
1956+ {
1957+ name: "hostile lookalike remains external",
1958+ imageRef: "old-registry.example.com.evil/myapp:latest",
1959+ registryDomain: "new-registry.example.com",
1960+ legacyRegistryDomains: []string{"old-registry.example.com"},
1961+ registryPort: 5000,
1962+ wantRef: "old-registry.example.com.evil/myapp:latest",
20281963 },
20291964 }
20301965
20311966 for _, tt := range tests {
20321967 t.Run(tt.name, func(t *testing.T) {
2033- gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
1968+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
20341969 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
20351970 })
20361971 }
@@ -2116,6 +2051,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
21162051 assert.Equal(t, "container-123", result.ID)
21172052 }
21182053
2054+func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2055+ runtime := mocks.NewMockContainerRuntime(t)
2056+ envLoader := mocks.NewMockEnvLoader(t)
2057+ eventBus := mocks.NewMockEventPublisher(t)
2058+
2059+ config := Config{
2060+ AllowedRegistries: []string{"docker.io"},
2061+ RegistryAuthEnabled: true,
2062+ RegistryDomain: "new-registry.example.com",
2063+ LegacyRegistryDomains: []string{"old-registry.example.com"},
2064+ RegistryPort: 5000,
2065+ InternalRegistryUsername: "internal",
2066+ InternalRegistryPassword: "secret",
2067+ ReadinessDelay: time.Millisecond,
2068+ DrainDelay: time.Millisecond,
2069+ DrainDelayConfigured: true,
2070+ }
2071+
2072+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2073+ ctx := domain.WithInternalDeploy(testContext())
2074+
2075+ route := domain.Route{
2076+ Domain: "test.example.com",
2077+ Image: "old-registry.example.com/myapp:latest",
2078+ }
2079+
2080+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2081+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2082+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2083+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2084+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2085+
2086+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2087+ runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2088+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2089+ runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2090+
2091+ createdContainer := &domain.Container{
2092+ ID: "container-legacy",
2093+ Name: "gordon-test.example.com",
2094+ Status: "created",
2095+ }
2096+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2097+ runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2098+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2099+ runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2100+ ID: "container-legacy",
2101+ Name: "gordon-test.example.com",
2102+ Status: "running",
2103+ Ports: []int{8080},
2104+ }, nil)
2105+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2106+
2107+ result, err := svc.Deploy(ctx, route)
2108+
2109+ assert.NoError(t, err)
2110+ assert.NotNil(t, result)
2111+ assert.Equal(t, "container-legacy", result.ID)
2112+}
2113+
21192114 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
21202115 runtime := mocks.NewMockContainerRuntime(t)
21212116 envLoader := mocks.NewMockEnvLoader(t)
@@ -2609,10 +2604,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
26092604
26102605 func TestService_StripRegistryPrefix(t *testing.T) {
26112606 tests := []struct {
2612- name string
2613- registryDomain string
2614- image string
2615- expected string
2607+ name string
2608+ registryDomain string
2609+ legacyRegistryDomains []string
2610+ image string
2611+ expected string
26162612 }{
26172613 {
26182614 name: "strips registry prefix",
@@ -2626,6 +2622,13 @@ func TestService_StripRegistryPrefix(t *testing.T) {
26262622 image: "reg.example.com/myapp:v1.0",
26272623 expected: "myapp:v1.0",
26282624 },
2625+ {
2626+ name: "strips legacy registry prefix",
2627+ registryDomain: "new-reg.example.com",
2628+ legacyRegistryDomains: []string{"old-reg.example.com"},
2629+ image: "old-reg.example.com/myapp:latest",
2630+ expected: "myapp:latest",
2631+ },
26292632 {
26302633 name: "preserves image without registry prefix",
26312634 registryDomain: "reg.example.com",
@@ -2659,7 +2662,8 @@ func TestService_StripRegistryPrefix(t *testing.T) {
26592662 eventBus := mocks.NewMockEventPublisher(t)
26602663
26612664 config := Config{
2662- RegistryDomain: tt.registryDomain,
2665+ RegistryDomain: tt.registryDomain,
2666+ LegacyRegistryDomains: tt.legacyRegistryDomains,
26632667 }
26642668 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
26652669
@@ -1237,97 +1237,6 @@ func TestService_HealthCheck(t *testing.T) {
1237 assert.False(t, result["unhealthy.example.com"])1237 assert.False(t, result["unhealthy.example.com"])
1238 }1238 }
1239 1239
1240-func TestService_EnsureManagedContainerRestartPolicies_EnsuresRoutesAttachmentsAndStoppedContainers(t *testing.T) {
1241- runtime := mocks.NewMockContainerRuntime(t)
1242- envLoader := mocks.NewMockEnvLoader(t)
1243- eventBus := mocks.NewMockEventPublisher(t)
1244-
1245- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1246- ctx := testContext()
1247-
1248- routeContainer := &domain.Container{
1249- ID: "route-1",
1250- Name: "gordon-app.example.com",
1251- Status: string(domain.ContainerStatusRunning),
1252- Labels: map[string]string{
1253- domain.LabelManaged: "true",
1254- domain.LabelDomain: "app.example.com",
1255- },
1256- }
1257- attachmentContainer := &domain.Container{
1258- ID: "attachment-1",
1259- Name: "gordon-app-example-com-postgres",
1260- Status: string(domain.ContainerStatusRunning),
1261- Labels: map[string]string{
1262- domain.LabelManaged: "true",
1263- domain.LabelAttachment: "true",
1264- domain.LabelAttachedTo: "app.example.com",
1265- },
1266- }
1267- stoppedContainer := &domain.Container{
1268- ID: "stopped-1",
1269- Name: "gordon-stopped.example.com",
1270- Status: string(domain.ContainerStatusExited),
1271- Labels: map[string]string{
1272- domain.LabelManaged: "true",
1273- domain.LabelDomain: "stopped.example.com",
1274- },
1275- }
1276- unmanagedContainer := &domain.Container{
1277- ID: "other-1",
1278- Labels: map[string]string{},
1279- }
1280-
1281- runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1282- routeContainer,
1283- attachmentContainer,
1284- stoppedContainer,
1285- unmanagedContainer,
1286- }, nil).Once()
1287- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(nil).Once()
1288- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "attachment-1", domain.RestartPolicyAlways).Return(nil).Once()
1289- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "stopped-1", domain.RestartPolicyAlways).Return(nil).Once()
1290- err := svc.EnsureManagedContainerRestartPolicies(ctx)
1291-
1292- require.NoError(t, err)
1293- runtime.AssertNumberOfCalls(t, "EnsureContainerRestartPolicy", 3)
1294-}
1295-
1296-func TestService_EnsureManagedContainerRestartPolicies_ContinuesAndReturnsError(t *testing.T) {
1297- runtime := mocks.NewMockContainerRuntime(t)
1298- envLoader := mocks.NewMockEnvLoader(t)
1299- eventBus := mocks.NewMockEventPublisher(t)
1300-
1301- svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1302- ctx := testContext()
1303-
1304- routeContainer := &domain.Container{
1305- ID: "route-1",
1306- Name: "gordon-app.example.com",
1307- Labels: map[string]string{
1308- domain.LabelManaged: "true",
1309- domain.LabelDomain: "app.example.com",
1310- },
1311- }
1312- routeContainer2 := &domain.Container{
1313- ID: "route-2",
1314- Name: "gordon-api.example.com",
1315- Labels: map[string]string{
1316- domain.LabelManaged: "true",
1317- domain.LabelDomain: "api.example.com",
1318- },
1319- }
1320-
1321- runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{routeContainer, routeContainer2}, nil).Once()
1322- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(errors.New("update failed")).Once()
1323- runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-2", domain.RestartPolicyAlways).Return(nil).Once()
1324- err := svc.EnsureManagedContainerRestartPolicies(ctx)
1325-
1326- require.Error(t, err)
1327- assert.Contains(t, err.Error(), "route-1")
1328- assert.Contains(t, err.Error(), "update failed")
1329-}
1330-
1331 func TestService_SyncContainers(t *testing.T) {1240 func TestService_SyncContainers(t *testing.T) {
1332 runtime := mocks.NewMockContainerRuntime(t)1241 runtime := mocks.NewMockContainerRuntime(t)
1333 envLoader := mocks.NewMockEnvLoader(t)1242 envLoader := mocks.NewMockEnvLoader(t)
@@ -1992,11 +1901,12 @@ func TestRewriteToRegistryDomain(t *testing.T) {
1992 1901
1993 func TestRewriteToLocalRegistry(t *testing.T) {1902 func TestRewriteToLocalRegistry(t *testing.T) {
1994 tests := []struct {1903 tests := []struct {
1995- name string1904+ name string
1996- imageRef string1905+ imageRef string
1997- registryDomain string1906+ registryDomain string
1998- registryPort int1907+ legacyRegistryDomains []string
1999- wantRef string1908+ registryPort int
1909+ wantRef string
2000 }{1910 }{
2001 {1911 {
2002 name: "rewrites registry domain prefix",1912 name: "rewrites registry domain prefix",
@@ -2020,17 +1930,42 @@ func TestRewriteToLocalRegistry(t *testing.T) {
2020 wantRef: "localhost:5000/myapp:latest",1930 wantRef: "localhost:5000/myapp:latest",
2021 },1931 },
2022 {1932 {
2023- name: "prefixes external image path",1933+ name: "rewrites legacy registry domain prefix",
2024- imageRef: "docker.io/library/nginx:latest",1934+ imageRef: "old-registry.example.com/myapp:latest",
2025- registryDomain: "registry.example.com",1935+ registryDomain: "new-registry.example.com",
2026- registryPort: 5000,1936+ legacyRegistryDomains: []string{"old-registry.example.com"},
2027- wantRef: "localhost:5000/docker.io/library/nginx:latest",1937+ registryPort: 5000,
1938+ wantRef: "localhost:5000/myapp:latest",
1939+ },
1940+ {
1941+ name: "rewrites legacy registry domain prefix with explicit port and digest",
1942+ imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef",
1943+ registryDomain: "new-registry.example.com",
1944+ legacyRegistryDomains: []string{"old-registry.example.com:5001"},
1945+ registryPort: 5000,
1946+ wantRef: "localhost:5000/myapp@sha256:deadbeef",
1947+ },
1948+ {
1949+ name: "external image remains external",
1950+ imageRef: "docker.io/library/nginx:latest",
1951+ registryDomain: "new-registry.example.com",
1952+ legacyRegistryDomains: []string{"old-registry.example.com"},
1953+ registryPort: 5000,
1954+ wantRef: "docker.io/library/nginx:latest",
1955+ },
1956+ {
1957+ name: "hostile lookalike remains external",
1958+ imageRef: "old-registry.example.com.evil/myapp:latest",
1959+ registryDomain: "new-registry.example.com",
1960+ legacyRegistryDomains: []string{"old-registry.example.com"},
1961+ registryPort: 5000,
1962+ wantRef: "old-registry.example.com.evil/myapp:latest",
2028 },1963 },
2029 }1964 }
2030 1965
2031 for _, tt := range tests {1966 for _, tt := range tests {
2032 t.Run(tt.name, func(t *testing.T) {1967 t.Run(tt.name, func(t *testing.T) {
2033- gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)1968+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort)
2034 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")1969 assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
2035 })1970 })
2036 }1971 }
@@ -2116,6 +2051,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
2116 assert.Equal(t, "container-123", result.ID)2051 assert.Equal(t, "container-123", result.ID)
2117 }2052 }
2118 2053
2054+func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) {
2055+ runtime := mocks.NewMockContainerRuntime(t)
2056+ envLoader := mocks.NewMockEnvLoader(t)
2057+ eventBus := mocks.NewMockEventPublisher(t)
2058+
2059+ config := Config{
2060+ AllowedRegistries: []string{"docker.io"},
2061+ RegistryAuthEnabled: true,
2062+ RegistryDomain: "new-registry.example.com",
2063+ LegacyRegistryDomains: []string{"old-registry.example.com"},
2064+ RegistryPort: 5000,
2065+ InternalRegistryUsername: "internal",
2066+ InternalRegistryPassword: "secret",
2067+ ReadinessDelay: time.Millisecond,
2068+ DrainDelay: time.Millisecond,
2069+ DrainDelayConfigured: true,
2070+ }
2071+
2072+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2073+ ctx := domain.WithInternalDeploy(testContext())
2074+
2075+ route := domain.Route{
2076+ Domain: "test.example.com",
2077+ Image: "old-registry.example.com/myapp:latest",
2078+ }
2079+
2080+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil)
2081+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
2082+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil)
2083+ runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil)
2084+ runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
2085+
2086+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil)
2087+ runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil)
2088+ envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil)
2089+ runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil)
2090+
2091+ createdContainer := &domain.Container{
2092+ ID: "container-legacy",
2093+ Name: "gordon-test.example.com",
2094+ Status: "created",
2095+ }
2096+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil)
2097+ runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil)
2098+ runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2)
2099+ runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{
2100+ ID: "container-legacy",
2101+ Name: "gordon-test.example.com",
2102+ Status: "running",
2103+ Ports: []int{8080},
2104+ }, nil)
2105+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil)
2106+
2107+ result, err := svc.Deploy(ctx, route)
2108+
2109+ assert.NoError(t, err)
2110+ assert.NotNil(t, result)
2111+ assert.Equal(t, "container-legacy", result.ID)
2112+}
2113+
2119 func TestService_AutoStart_StartsNewContainers(t *testing.T) {2114 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2120 runtime := mocks.NewMockContainerRuntime(t)2115 runtime := mocks.NewMockContainerRuntime(t)
2121 envLoader := mocks.NewMockEnvLoader(t)2116 envLoader := mocks.NewMockEnvLoader(t)
@@ -2609,10 +2604,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
2609 2604
2610 func TestService_StripRegistryPrefix(t *testing.T) {2605 func TestService_StripRegistryPrefix(t *testing.T) {
2611 tests := []struct {2606 tests := []struct {
2612- name string2607+ name string
2613- registryDomain string2608+ registryDomain string
2614- image string2609+ legacyRegistryDomains []string
2615- expected string2610+ image string
2611+ expected string
2616 }{2612 }{
2617 {2613 {
2618 name: "strips registry prefix",2614 name: "strips registry prefix",
@@ -2626,6 +2622,13 @@ func TestService_StripRegistryPrefix(t *testing.T) {
2626 image: "reg.example.com/myapp:v1.0",2622 image: "reg.example.com/myapp:v1.0",
2627 expected: "myapp:v1.0",2623 expected: "myapp:v1.0",
2628 },2624 },
2625+ {
2626+ name: "strips legacy registry prefix",
2627+ registryDomain: "new-reg.example.com",
2628+ legacyRegistryDomains: []string{"old-reg.example.com"},
2629+ image: "old-reg.example.com/myapp:latest",
2630+ expected: "myapp:latest",
2631+ },
2629 {2632 {
2630 name: "preserves image without registry prefix",2633 name: "preserves image without registry prefix",
2631 registryDomain: "reg.example.com",2634 registryDomain: "reg.example.com",
@@ -2659,7 +2662,8 @@ func TestService_StripRegistryPrefix(t *testing.T) {
2659 eventBus := mocks.NewMockEventPublisher(t)2662 eventBus := mocks.NewMockEventPublisher(t)
2660 2663
2661 config := Config{2664 config := Config{
2662- RegistryDomain: tt.registryDomain,2665+ RegistryDomain: tt.registryDomain,
2666+ LegacyRegistryDomains: tt.legacyRegistryDomains,
2663 }2667 }
2664 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)2668 svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2665 2669

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