Harden registry rename and startup recovery
- commit
- ec88514f169f6edd32c11fa6959c3dfb0babc19b
- author
- brice · · 2026-05-10 13:12 UTC
- parent
- ad19cc0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +1062 -157
M docs/cli/serve.md +21 -8
| @@ -70,7 +70,7 @@ Gordon responds to these signals: | |||
| 70 | 70 | | `SIGTERM` | Graceful shutdown | | |
| 71 | 71 | | `SIGINT` | Graceful shutdown (Ctrl+C) | | |
| 72 | 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 | 75 | ### Running with systemd | |
| 76 | 76 | ||
| @@ -106,8 +106,20 @@ sudo loginctl enable-linger $USER | |||
| 106 | 106 | 6. Initialize services (registry, proxy, auth) | |
| 107 | 107 | 7. Register event handlers | |
| 108 | 108 | 8. Start config file watcher | |
| 109 | - | 9. Sync existing containers | |
| 110 | - | 10. Start HTTP servers | |
| 109 | + | 9. Start HTTP servers | |
| 110 | + | 10. Run best-effort startup recovery (sync existing containers, then recover configured routes) | |
| 111 | + | ||
| 112 | + | ### Startup Recovery | |
| 113 | + | ||
| 114 | + | After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting. | |
| 115 | + | ||
| 116 | + | 1. **Sync existing containers** - Gordon reconciles runtime state with configured routes. | |
| 117 | + | 2. **Recover configured routes** - Gordon starts any configured route that has no running container. | |
| 118 | + | 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass. | |
| 119 | + | ||
| 120 | + | This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config. | |
| 121 | + | ||
| 122 | + | Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed. | |
| 111 | 123 | ||
| 112 | 124 | ### Shutdown Sequence | |
| 113 | 125 | ||
| @@ -177,14 +189,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 177 | 189 | ||
| 178 | 190 | ### Description | |
| 179 | 191 | ||
| 180 | - | **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. | |
| 192 | + | **Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery. | |
| 181 | 193 | ||
| 182 | - | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. | |
| 194 | + | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request. | |
| 183 | 195 | ||
| 184 | - | Both modes trigger: | |
| 196 | + | Both modes use Gordon's internal deploy path: | |
| 185 | 197 | ||
| 186 | - | - Fresh image pull (always pulls latest, ignoring cache) | |
| 187 | - | - Container redeployment for the specified route | |
| 198 | + | - Fresh image content is pulled for the route | |
| 199 | + | - The specified route is redeployed | |
| 200 | + | - Configured readiness checks and drain behavior apply when needed | |
| 188 | 201 | ||
| 189 | 202 | ### Examples | |
| 190 | 203 | ||
| @@ -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 systemd | 75 | ### Running with systemd | ||
| 76 | 76 | ||||
| @@ -106,8 +106,20 @@ sudo loginctl enable-linger $USER | |||||
| 106 | 6. Initialize services (registry, proxy, auth) | 106 | 6. Initialize services (registry, proxy, auth) | ||
| 107 | 7. Register event handlers | 107 | 7. Register event handlers | ||
| 108 | 8. Start config file watcher | 108 | 8. Start config file watcher | ||
| 109 | - | 9. Sync existing containers | 109 | + | 9. Start HTTP servers |
| 110 | - | 10. Start HTTP servers | 110 | + | 10. Run best-effort startup recovery (sync existing containers, then recover configured routes) |
| 111 | + | ||||
| 112 | + | ### Startup Recovery | |||
| 113 | + | ||||
| 114 | + | After Gordon starts, including after a host reboot, it runs a best-effort recovery pass once the listeners are bound. Errors are logged, but Gordon keeps starting. | |||
| 115 | + | ||||
| 116 | + | 1. **Sync existing containers** - Gordon reconciles runtime state with configured routes. | |||
| 117 | + | 2. **Recover configured routes** - Gordon starts any configured route that has no running container. | |||
| 118 | + | 3. **Start the background monitor** - Ongoing crash recovery resumes after the startup pass. | |||
| 119 | + | ||||
| 120 | + | This recovery runs even when `[auto_route].enabled = false`. `auto_route` only controls whether new image pushes create routes automatically; it does not disable restart recovery for routes already in the config. | |||
| 121 | + | ||||
| 122 | + | Recovery stays inside Gordon's own control flow instead of relying on Docker or Podman restart policies. That keeps the behavior consistent across both runtimes and applies Gordon's normal deploy checks, including fresh image pulls, readiness checks, and drain behavior when needed. | |||
| 111 | 123 | ||||
| 112 | ### Shutdown Sequence | 124 | ### Shutdown Sequence | ||
| 113 | 125 | ||||
| @@ -177,14 +189,15 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||||
| 177 | 189 | ||||
| 178 | ### Description | 190 | ### Description | ||
| 179 | 191 | ||||
| 180 | - | **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. | 192 | + | **Local mode:** On the Gordon host, writes a deploy request and sends `SIGUSR2` to the local Gordon process. Gordon then runs the deploy inside the server through the same internal path it uses during startup recovery. |
| 181 | 193 | ||||
| 182 | - | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. | 194 | + | **Remote mode:** Calls the remote Gordon Admin API to trigger deployment. The remote Gordon instance still performs the actual deploy internally; the CLI only submits the request. |
| 183 | 195 | ||||
| 184 | - | Both modes trigger: | 196 | + | Both modes use Gordon's internal deploy path: |
| 185 | 197 | ||||
| 186 | - | - Fresh image pull (always pulls latest, ignoring cache) | 198 | + | - Fresh image content is pulled for the route |
| 187 | - | - Container redeployment for the specified route | 199 | + | - The specified route is redeployed |
| 200 | + | - Configured readiness checks and drain behavior apply when needed | |||
| 188 | 201 | ||||
| 189 | ### Examples | 202 | ### Examples | ||
| 190 | 203 | ||||
M docs/config/index.md +2 -0
| @@ -22,6 +22,8 @@ gordon_domain = "gordon.mydomain.com" | |||
| 22 | 22 | ``` | |
| 23 | 23 | ||
| 24 | 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 | 28 | ## Full Configuration Reference | |
| 27 | 29 | ||
| @@ -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 Reference | 28 | ## Full Configuration Reference | ||
| 27 | 29 | ||||
M docs/config/server.md +4 -0
| @@ -13,6 +13,7 @@ tls_port = 8443 # HTTPS listener port (0 = disabled) | |||
| 13 | 13 | # tls_key_file = "" # Optional: PEM key for static TLS | |
| 14 | 14 | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | |
| 15 | 15 | gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |
| 16 | + | # legacy_registry_domains = ["registry.example.com:5000"] | |
| 16 | 17 | # data_dir = "~/.gordon" # Data storage directory (default) | |
| 17 | 18 | max_blob_chunk_size = "95MB" # Max registry blob upload chunk | |
| 18 | 19 | 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 | 31 | | `force_https_redirect` | bool | `false` | Redirect all HTTP requests to the HTTPS port. For direct-access setups without a TLS-terminating proxy | | |
| 31 | 32 | | `gordon_domain` | string | **required** | Domain for Gordon (registry + admin API) | | |
| 32 | 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 | 35 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 34 | 36 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 35 | 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 | 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 | 224 | Without this migration, a Host/remote-target mismatch can break routing or remote CLI token exchange. | |
| 221 | 225 | ||
| 222 | 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). | |
| @@ -13,6 +13,7 @@ tls_port = 8443 # HTTPS listener port (0 = disabled) | |||||
| 13 | # tls_key_file = "" # Optional: PEM key for static TLS | 13 | # tls_key_file = "" # Optional: PEM key for static TLS | ||
| 14 | # force_https_redirect = false # Redirect all HTTP traffic to HTTPS | 14 | # 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 chunk | 18 | max_blob_chunk_size = "95MB" # Max registry blob upload chunk | ||
| 18 | max_blob_size = "1GB" # Max cumulative registry blob/layer upload | 19 | 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" | |||
| 74 | 74 | ||
| 75 | 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 | 100 | ## v2.16.0 to v2.30.0 | |
| 78 | 101 | ||
| 79 | 102 | ### 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.0 | 100 | ## v2.16.0 to v2.30.0 | ||
| 78 | 101 | ||||
| 79 | ### Breaking: Password Authentication Removed | 102 | ### Breaking: Password Authentication Removed | ||
M gordon.toml.example +1 -0
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | port = 8088 | |
| 6 | 6 | registry_port = 5000 | |
| 7 | 7 | gordon_domain = "gordon.example.com" | |
| 8 | + | # legacy_registry_domains = ["registry.example.com:5000"] | |
| 8 | 9 | # data_dir = "~/.gordon" | |
| 9 | 10 | ||
| 10 | 11 | [auth] | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | port = 8088 | 5 | port = 8088 | ||
| 6 | registry_port = 5000 | 6 | 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 | |||
| 422 | 422 | if err != nil { | |
| 423 | 423 | return nil, err | |
| 424 | 424 | } | |
| 425 | - | container, err := l.containerSvc.Deploy(ctx, *route) | |
| 425 | + | container, err := l.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 426 | 426 | if err != nil { | |
| 427 | 427 | return nil, err | |
| 428 | 428 | } | |
| @@ -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, err | 423 | 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, err | 427 | 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) { | |||
| 37 | 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 | 67 | func TestLocalControlPlane_Backups(t *testing.T) { | |
| 41 | 68 | t.Parallel() | |
| 42 | 69 | ||
| @@ -37,6 +37,33 @@ func TestLocalControlPlane_GetStatus(t *testing.T) { | |||||
| 37 | require.Equal(t, "running", status.ContainerStatus["app.local"]) | 37 | require.Equal(t, "running", status.ContainerStatus["app.local"]) | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | func TestLocalControlPlane_DeployUsesInternalDeployContext(t *testing.T) { | |||
| 41 | + | t.Parallel() | |||
| 42 | + | ||||
| 43 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 44 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 45 | + | ||||
| 46 | + | ctx := context.Background() | |||
| 47 | + | route := &domain.Route{Domain: "app.local", Image: "repo/app:latest"} | |||
| 48 | + | ||||
| 49 | + | require.False(t, domain.IsInternalDeploy(ctx)) | |||
| 50 | + | ||||
| 51 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.local").Return(route, nil) | |||
| 52 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(deployCtx context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |||
| 53 | + | require.True(t, domain.IsInternalDeploy(deployCtx)) | |||
| 54 | + | require.Equal(t, *route, deployedRoute) | |||
| 55 | + | return &domain.Container{ID: "container-1"}, nil | |||
| 56 | + | }) | |||
| 57 | + | ||||
| 58 | + | cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc} | |||
| 59 | + | result, err := cp.Deploy(ctx, "app.local") | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.NotNil(t, result) | |||
| 62 | + | require.Equal(t, "deployed", result.Status) | |||
| 63 | + | require.Equal(t, "app.local", result.Domain) | |||
| 64 | + | require.Equal(t, "container-1", result.ContainerID) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 40 | func TestLocalControlPlane_Backups(t *testing.T) { | 67 | func TestLocalControlPlane_Backups(t *testing.T) { | ||
| 41 | t.Parallel() | 68 | t.Parallel() | ||
| 42 | 69 | ||||
M internal/app/run.go +37 -41
| @@ -93,22 +93,24 @@ import ( | |||
| 93 | 93 | // Config holds the application configuration. | |
| 94 | 94 | type Config struct { | |
| 95 | 95 | 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"` | |
| 112 | 114 | } `mapstructure:"server"` | |
| 113 | 115 | ||
| 114 | 116 | Logging struct { | |
| @@ -1356,6 +1358,14 @@ func resolveEnvDir(cfg Config) string { | |||
| 1356 | 1358 | 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 | 1369 | func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | |
| 1360 | 1370 | // Token store is always created since tokens work in both auth modes | |
| 1361 | 1371 | store, err := tokenstore.NewStore(backend, dataDir, log) | |
| @@ -1750,9 +1760,11 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro | |||
| 1750 | 1760 | } | |
| 1751 | 1761 | // 0 means no limit (as documented in proxy.Config) | |
| 1752 | 1762 | ||
| 1763 | + | registryDomain, _ := resolveRegistryDomains(cfg) | |
| 1764 | + | ||
| 1753 | 1765 | return &proxyConfigResult{ | |
| 1754 | 1766 | proxyConfig: proxy.Config{ | |
| 1755 | - | RegistryDomain: cfg.Server.GordonDomain, | |
| 1767 | + | RegistryDomain: registryDomain, | |
| 1756 | 1768 | RegistryPort: cfg.Server.RegistryPort, | |
| 1757 | 1769 | MaxBodySize: maxProxyBodySize, | |
| 1758 | 1770 | MaxResponseSize: maxProxyResponseSize, | |
| @@ -1827,10 +1839,12 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1827 | 1839 | } | |
| 1828 | 1840 | ||
| 1829 | 1841 | attachmentConfig := svc.configSvc.GetAttachmentConfig() | |
| 1842 | + | registryDomain, legacyRegistryDomains := resolveRegistryDomains(cfg) | |
| 1830 | 1843 | ||
| 1831 | 1844 | containerConfig := container.Config{ | |
| 1832 | 1845 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 1833 | - | RegistryDomain: cfg.Server.GordonDomain, | |
| 1846 | + | RegistryDomain: registryDomain, | |
| 1847 | + | LegacyRegistryDomains: legacyRegistryDomains, | |
| 1834 | 1848 | RegistryPort: cfg.Server.RegistryPort, | |
| 1835 | 1849 | InternalRegistryUsername: svc.internalRegUser, | |
| 1836 | 1850 | InternalRegistryPassword: svc.internalRegPass, | |
| @@ -1958,7 +1972,8 @@ func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func | |||
| 1958 | 1972 | } | |
| 1959 | 1973 | ||
| 1960 | 1974 | // 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 | 1977 | WithEnvExtractor(svc.runtime, svc.envDir) | |
| 1963 | 1978 | ||
| 1964 | 1979 | // Preview handler for creating preview environments from tagged images | |
| @@ -2013,26 +2028,6 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina | |||
| 2013 | 2028 | 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 | 2031 | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |
| 2037 | 2032 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2038 | 2033 | host, _, err := net.SplitHostPort(r.RemoteAddr) | |
| @@ -2589,8 +2584,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2589 | 2584 | defer schedulerCleanup() | |
| 2590 | 2585 | } | |
| 2591 | 2586 | ||
| 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) | |
| 2594 | 2589 | ||
| 2595 | 2590 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | |
| 2596 | 2591 | cleanupHandlers() // Stop debounce timers before draining containers | |
| @@ -3319,6 +3314,7 @@ func isProcessAlive(pid int) bool { | |||
| 3319 | 3314 | func loadConfig(v *viper.Viper, configPath string) error { | |
| 3320 | 3315 | v.SetDefault("server.port", 8088) | |
| 3321 | 3316 | v.SetDefault("server.registry_port", 5000) | |
| 3317 | + | v.SetDefault("server.legacy_registry_domains", []string{}) | |
| 3322 | 3318 | v.SetDefault("server.tls_port", 8443) | |
| 3323 | 3319 | v.SetDefault("server.tls_cert_file", "") | |
| 3324 | 3320 | 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 limit | 107 | + | 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 envDir | 1358 | 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 modes | 1370 | // 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 labels | 1974 | // 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 images | 1979 | // Preview handler for creating preview environments from tagged images | ||
| @@ -2013,26 +2028,6 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina | |||||
| 2013 | return nil | 2028 | 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 containers | 2591 | cleanupHandlers() // Stop debounce timers before draining containers | ||
| @@ -3319,6 +3314,7 @@ func isProcessAlive(pid int) bool { | |||||
| 3319 | func loadConfig(v *viper.Viper, configPath string) error { | 3314 | func loadConfig(v *viper.Viper, configPath string) error { | ||
| 3320 | v.SetDefault("server.port", 8088) | 3315 | v.SetDefault("server.port", 8088) | ||
| 3321 | v.SetDefault("server.registry_port", 5000) | 3316 | v.SetDefault("server.registry_port", 5000) | ||
| 3317 | + | v.SetDefault("server.legacy_registry_domains", []string{}) | |||
| 3322 | v.SetDefault("server.tls_port", 8443) | 3318 | v.SetDefault("server.tls_port", 8443) | ||
| 3323 | v.SetDefault("server.tls_cert_file", "") | 3319 | v.SetDefault("server.tls_cert_file", "") | ||
| 3324 | v.SetDefault("server.tls_key_file", "") | 3320 | v.SetDefault("server.tls_key_file", "") | ||
A internal/app/startup_recovery.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // startupConfigService defines the route configuration needed during startup | |
| 11 | + | // recovery. | |
| 12 | + | type startupConfigService interface { | |
| 13 | + | GetRoutes(ctx context.Context) []domain.Route | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // startupContainerService defines the container lifecycle operations needed | |
| 17 | + | // during startup recovery. | |
| 18 | + | type startupContainerService interface { | |
| 19 | + | SyncContainers(ctx context.Context) error | |
| 20 | + | AutoStart(ctx context.Context, routes []domain.Route) error | |
| 21 | + | StartMonitor(ctx context.Context) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // syncAndRecoverConfiguredRoutes performs best-effort startup recovery for | |
| 25 | + | // configured routes after listeners are ready. | |
| 26 | + | func syncAndRecoverConfiguredRoutes( | |
| 27 | + | ctx context.Context, | |
| 28 | + | configSvc startupConfigService, | |
| 29 | + | containerSvc startupContainerService, | |
| 30 | + | log zerowrap.Logger, | |
| 31 | + | ) { | |
| 32 | + | defer containerSvc.StartMonitor(ctx) | |
| 33 | + | ||
| 34 | + | if err := containerSvc.SyncContainers(ctx); err != nil { | |
| 35 | + | log.Warn().Err(err).Msg("failed to sync existing containers") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | routes := configSvc.GetRoutes(ctx) | |
| 39 | + | if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil { | |
| 40 | + | log.Warn().Err(err).Msg("failed to auto-start configured routes") | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // startupConfigService defines the route configuration needed during startup | |||
| 11 | + | // recovery. | |||
| 12 | + | type startupConfigService interface { | |||
| 13 | + | GetRoutes(ctx context.Context) []domain.Route | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | // startupContainerService defines the container lifecycle operations needed | |||
| 17 | + | // during startup recovery. | |||
| 18 | + | type startupContainerService interface { | |||
| 19 | + | SyncContainers(ctx context.Context) error | |||
| 20 | + | AutoStart(ctx context.Context, routes []domain.Route) error | |||
| 21 | + | StartMonitor(ctx context.Context) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // syncAndRecoverConfiguredRoutes performs best-effort startup recovery for | |||
| 25 | + | // configured routes after listeners are ready. | |||
| 26 | + | func syncAndRecoverConfiguredRoutes( | |||
| 27 | + | ctx context.Context, | |||
| 28 | + | configSvc startupConfigService, | |||
| 29 | + | containerSvc startupContainerService, | |||
| 30 | + | log zerowrap.Logger, | |||
| 31 | + | ) { | |||
| 32 | + | defer containerSvc.StartMonitor(ctx) | |||
| 33 | + | ||||
| 34 | + | if err := containerSvc.SyncContainers(ctx); err != nil { | |||
| 35 | + | log.Warn().Err(err).Msg("failed to sync existing containers") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | routes := configSvc.GetRoutes(ctx) | |||
| 39 | + | if err := containerSvc.AutoStart(domain.WithInternalDeploy(ctx), routes); err != nil { | |||
| 40 | + | log.Warn().Err(err).Msg("failed to auto-start configured routes") | |||
| 41 | + | } | |||
| 42 | + | } | |||
A internal/app/startup_recovery_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type startupRecoveryFakeConfigService struct { | |
| 14 | + | calls *[]string | |
| 15 | + | routes []domain.Route | |
| 16 | + | autoRouteEnabled bool | |
| 17 | + | panicOnAutoRouteCheck bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route { | |
| 21 | + | *f.calls = append(*f.calls, "routes") | |
| 22 | + | return append([]domain.Route(nil), f.routes...) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool { | |
| 26 | + | if f.panicOnAutoRouteCheck { | |
| 27 | + | panic("unexpected auto-route gate during startup recovery") | |
| 28 | + | } | |
| 29 | + | if f.calls != nil { | |
| 30 | + | *f.calls = append(*f.calls, "auto-route-enabled") | |
| 31 | + | } | |
| 32 | + | return f.autoRouteEnabled | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type startupRecoveryFakeContainerService struct { | |
| 36 | + | calls *[]string | |
| 37 | + | syncErr error | |
| 38 | + | autoStartErr error | |
| 39 | + | autoStartCtx context.Context | |
| 40 | + | autoStartRoutes []domain.Route | |
| 41 | + | startMonitorCtx context.Context | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error { | |
| 45 | + | *f.calls = append(*f.calls, "sync") | |
| 46 | + | return f.syncErr | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error { | |
| 50 | + | *f.calls = append(*f.calls, "autostart") | |
| 51 | + | f.autoStartCtx = ctx | |
| 52 | + | f.autoStartRoutes = append([]domain.Route(nil), routes...) | |
| 53 | + | return f.autoStartErr | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) { | |
| 57 | + | *f.calls = append(*f.calls, "monitor") | |
| 58 | + | f.startMonitorCtx = ctx | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) { | |
| 62 | + | ctx := context.Background() | |
| 63 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |
| 64 | + | calls := make([]string, 0, 4) | |
| 65 | + | ||
| 66 | + | configSvc := &startupRecoveryFakeConfigService{ | |
| 67 | + | calls: &calls, | |
| 68 | + | routes: routes, | |
| 69 | + | } | |
| 70 | + | containerSvc := &startupRecoveryFakeContainerService{calls: &calls} | |
| 71 | + | ||
| 72 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |
| 73 | + | ||
| 74 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |
| 75 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |
| 76 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |
| 77 | + | assert.False(t, domain.IsInternalDeploy(ctx)) | |
| 78 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) { | |
| 82 | + | ctx := context.Background() | |
| 83 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |
| 84 | + | calls := make([]string, 0, 4) | |
| 85 | + | ||
| 86 | + | configSvc := &startupRecoveryFakeConfigService{ | |
| 87 | + | calls: &calls, | |
| 88 | + | routes: routes, | |
| 89 | + | } | |
| 90 | + | containerSvc := &startupRecoveryFakeContainerService{ | |
| 91 | + | calls: &calls, | |
| 92 | + | syncErr: assert.AnError, | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | assert.NotPanics(t, func() { | |
| 96 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |
| 97 | + | }) | |
| 98 | + | ||
| 99 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |
| 100 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |
| 101 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |
| 102 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) { | |
| 106 | + | ctx := context.Background() | |
| 107 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |
| 108 | + | calls := make([]string, 0, 4) | |
| 109 | + | ||
| 110 | + | configSvc := &startupRecoveryFakeConfigService{ | |
| 111 | + | calls: &calls, | |
| 112 | + | routes: routes, | |
| 113 | + | } | |
| 114 | + | containerSvc := &startupRecoveryFakeContainerService{ | |
| 115 | + | calls: &calls, | |
| 116 | + | autoStartErr: assert.AnError, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | assert.NotPanics(t, func() { | |
| 120 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |
| 124 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |
| 125 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |
| 126 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) { | |
| 130 | + | ctx := context.Background() | |
| 131 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |
| 132 | + | calls := make([]string, 0, 4) | |
| 133 | + | ||
| 134 | + | configSvc := &startupRecoveryFakeConfigService{ | |
| 135 | + | calls: &calls, | |
| 136 | + | routes: routes, | |
| 137 | + | autoRouteEnabled: false, | |
| 138 | + | panicOnAutoRouteCheck: true, | |
| 139 | + | } | |
| 140 | + | containerSvc := &startupRecoveryFakeContainerService{calls: &calls} | |
| 141 | + | ||
| 142 | + | assert.NotPanics(t, func() { | |
| 143 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |
| 144 | + | }) | |
| 145 | + | ||
| 146 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |
| 147 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |
| 148 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |
| 149 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type startupRecoveryFakeConfigService struct { | |||
| 14 | + | calls *[]string | |||
| 15 | + | routes []domain.Route | |||
| 16 | + | autoRouteEnabled bool | |||
| 17 | + | panicOnAutoRouteCheck bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (f *startupRecoveryFakeConfigService) GetRoutes(_ context.Context) []domain.Route { | |||
| 21 | + | *f.calls = append(*f.calls, "routes") | |||
| 22 | + | return append([]domain.Route(nil), f.routes...) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (f *startupRecoveryFakeConfigService) IsAutoRouteEnabled(_ context.Context) bool { | |||
| 26 | + | if f.panicOnAutoRouteCheck { | |||
| 27 | + | panic("unexpected auto-route gate during startup recovery") | |||
| 28 | + | } | |||
| 29 | + | if f.calls != nil { | |||
| 30 | + | *f.calls = append(*f.calls, "auto-route-enabled") | |||
| 31 | + | } | |||
| 32 | + | return f.autoRouteEnabled | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type startupRecoveryFakeContainerService struct { | |||
| 36 | + | calls *[]string | |||
| 37 | + | syncErr error | |||
| 38 | + | autoStartErr error | |||
| 39 | + | autoStartCtx context.Context | |||
| 40 | + | autoStartRoutes []domain.Route | |||
| 41 | + | startMonitorCtx context.Context | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (f *startupRecoveryFakeContainerService) SyncContainers(_ context.Context) error { | |||
| 45 | + | *f.calls = append(*f.calls, "sync") | |||
| 46 | + | return f.syncErr | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (f *startupRecoveryFakeContainerService) AutoStart(ctx context.Context, routes []domain.Route) error { | |||
| 50 | + | *f.calls = append(*f.calls, "autostart") | |||
| 51 | + | f.autoStartCtx = ctx | |||
| 52 | + | f.autoStartRoutes = append([]domain.Route(nil), routes...) | |||
| 53 | + | return f.autoStartErr | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (f *startupRecoveryFakeContainerService) StartMonitor(ctx context.Context) { | |||
| 57 | + | *f.calls = append(*f.calls, "monitor") | |||
| 58 | + | f.startMonitorCtx = ctx | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestSyncAndRecoverConfiguredRoutes_HappyPath(t *testing.T) { | |||
| 62 | + | ctx := context.Background() | |||
| 63 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |||
| 64 | + | calls := make([]string, 0, 4) | |||
| 65 | + | ||||
| 66 | + | configSvc := &startupRecoveryFakeConfigService{ | |||
| 67 | + | calls: &calls, | |||
| 68 | + | routes: routes, | |||
| 69 | + | } | |||
| 70 | + | containerSvc := &startupRecoveryFakeContainerService{calls: &calls} | |||
| 71 | + | ||||
| 72 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |||
| 73 | + | ||||
| 74 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |||
| 75 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |||
| 76 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |||
| 77 | + | assert.False(t, domain.IsInternalDeploy(ctx)) | |||
| 78 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestSyncAndRecoverConfiguredRoutes_SyncFailureStillRecoversAndStartsMonitor(t *testing.T) { | |||
| 82 | + | ctx := context.Background() | |||
| 83 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |||
| 84 | + | calls := make([]string, 0, 4) | |||
| 85 | + | ||||
| 86 | + | configSvc := &startupRecoveryFakeConfigService{ | |||
| 87 | + | calls: &calls, | |||
| 88 | + | routes: routes, | |||
| 89 | + | } | |||
| 90 | + | containerSvc := &startupRecoveryFakeContainerService{ | |||
| 91 | + | calls: &calls, | |||
| 92 | + | syncErr: assert.AnError, | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | assert.NotPanics(t, func() { | |||
| 96 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |||
| 97 | + | }) | |||
| 98 | + | ||||
| 99 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |||
| 100 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |||
| 101 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |||
| 102 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestSyncAndRecoverConfiguredRoutes_AutoStartFailureStillStartsMonitor(t *testing.T) { | |||
| 106 | + | ctx := context.Background() | |||
| 107 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |||
| 108 | + | calls := make([]string, 0, 4) | |||
| 109 | + | ||||
| 110 | + | configSvc := &startupRecoveryFakeConfigService{ | |||
| 111 | + | calls: &calls, | |||
| 112 | + | routes: routes, | |||
| 113 | + | } | |||
| 114 | + | containerSvc := &startupRecoveryFakeContainerService{ | |||
| 115 | + | calls: &calls, | |||
| 116 | + | autoStartErr: assert.AnError, | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | assert.NotPanics(t, func() { | |||
| 120 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |||
| 121 | + | }) | |||
| 122 | + | ||||
| 123 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |||
| 124 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |||
| 125 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |||
| 126 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestSyncAndRecoverConfiguredRoutes_ProceedsEvenWhenAutoRouteDisabled(t *testing.T) { | |||
| 130 | + | ctx := context.Background() | |||
| 131 | + | routes := []domain.Route{{Domain: "app.example.com", Image: "reg.example.com/app:latest", HTTPS: true}} | |||
| 132 | + | calls := make([]string, 0, 4) | |||
| 133 | + | ||||
| 134 | + | configSvc := &startupRecoveryFakeConfigService{ | |||
| 135 | + | calls: &calls, | |||
| 136 | + | routes: routes, | |||
| 137 | + | autoRouteEnabled: false, | |||
| 138 | + | panicOnAutoRouteCheck: true, | |||
| 139 | + | } | |||
| 140 | + | containerSvc := &startupRecoveryFakeContainerService{calls: &calls} | |||
| 141 | + | ||||
| 142 | + | assert.NotPanics(t, func() { | |||
| 143 | + | syncAndRecoverConfiguredRoutes(ctx, configSvc, containerSvc, zerowrap.Default()) | |||
| 144 | + | }) | |||
| 145 | + | ||||
| 146 | + | assert.Equal(t, []string{"sync", "routes", "autostart", "monitor"}, calls) | |||
| 147 | + | assert.Equal(t, routes, containerSvc.autoStartRoutes) | |||
| 148 | + | assert.True(t, domain.IsInternalDeploy(containerSvc.autoStartCtx)) | |||
| 149 | + | assert.False(t, domain.IsInternalDeploy(containerSvc.startMonitorCtx)) | |||
| 150 | + | } | |||
A internal/domain/registry_image.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | // KnownGordonRegistryDomains returns the normalized set of Gordon-managed | |
| 6 | + | // registry domains, with the current domain first followed by legacy domains. | |
| 7 | + | // Empty values are ignored, trailing slashes are removed, and duplicates are | |
| 8 | + | // removed while preserving first-seen order. | |
| 9 | + | func KnownGordonRegistryDomains(current string, legacy []string) []string { | |
| 10 | + | domains := make([]string, 0, 1+len(legacy)) | |
| 11 | + | seen := make(map[string]struct{}, 1+len(legacy)) | |
| 12 | + | ||
| 13 | + | add := func(domain string) { | |
| 14 | + | normalized := normalizeRegistryDomain(domain) | |
| 15 | + | if normalized == "" { | |
| 16 | + | return | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | key := strings.ToLower(normalized) | |
| 20 | + | if _, ok := seen[key]; ok { | |
| 21 | + | return | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | seen[key] = struct{}{} | |
| 25 | + | domains = append(domains, normalized) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | add(current) | |
| 29 | + | for _, domain := range legacy { | |
| 30 | + | add(domain) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | return domains | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // StripKnownGordonRegistry removes the Gordon-managed registry host prefix from | |
| 37 | + | // an image reference when it matches the current or a legacy Gordon registry | |
| 38 | + | // domain. Bare references and external registries are returned unchanged. | |
| 39 | + | func StripKnownGordonRegistry(imageRef string, current string, legacy []string) string { | |
| 40 | + | host, remainder, ok := splitRegistryImageRef(imageRef) | |
| 41 | + | if !ok { | |
| 42 | + | return imageRef | |
| 43 | + | } | |
| 44 | + | if !isKnownGordonRegistryDomain(host, current, legacy) { | |
| 45 | + | return imageRef | |
| 46 | + | } | |
| 47 | + | return remainder | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ExtractGordonRepoName strips a known Gordon registry host, then removes any | |
| 51 | + | // tag or digest suffix, returning the repository name portion of the image | |
| 52 | + | // reference. | |
| 53 | + | func ExtractGordonRepoName(imageRef string, current string, legacy []string) string { | |
| 54 | + | repo := StripKnownGordonRegistry(imageRef, current, legacy) | |
| 55 | + | ||
| 56 | + | if idx := strings.Index(repo, "@"); idx != -1 { | |
| 57 | + | repo = repo[:idx] | |
| 58 | + | } | |
| 59 | + | if idx := strings.LastIndex(repo, ":"); idx != -1 { | |
| 60 | + | slashIdx := strings.LastIndex(repo, "/") | |
| 61 | + | if idx > slashIdx { | |
| 62 | + | repo = repo[:idx] | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | return repo | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // CanonicalizeGordonImageRef rewrites Gordon-managed image references to use | |
| 70 | + | // the current Gordon registry domain. External registries and bare references | |
| 71 | + | // are returned unchanged. If the current domain is empty, the original image | |
| 72 | + | // reference is returned unchanged. | |
| 73 | + | func CanonicalizeGordonImageRef(imageRef, current string, legacy []string) string { | |
| 74 | + | currentDomain := normalizeRegistryDomain(current) | |
| 75 | + | if currentDomain == "" { | |
| 76 | + | return imageRef | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | host, remainder, ok := splitRegistryImageRef(imageRef) | |
| 80 | + | if !ok { | |
| 81 | + | return imageRef | |
| 82 | + | } | |
| 83 | + | if !isKnownGordonRegistryDomain(host, currentDomain, legacy) { | |
| 84 | + | return imageRef | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | return currentDomain + "/" + remainder | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // IsGordonRegistryImageRef reports whether an image reference is explicitly | |
| 91 | + | // qualified with the current or a legacy Gordon registry domain. | |
| 92 | + | func IsGordonRegistryImageRef(imageRef, current string, legacy []string) bool { | |
| 93 | + | host, _, ok := splitRegistryImageRef(imageRef) | |
| 94 | + | if !ok { | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | return isKnownGordonRegistryDomain(host, current, legacy) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func normalizeRegistryDomain(domain string) string { | |
| 101 | + | domain = strings.TrimSpace(domain) | |
| 102 | + | domain = strings.TrimRight(domain, "/") | |
| 103 | + | return domain | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func isKnownGordonRegistryDomain(host, current string, legacy []string) bool { | |
| 107 | + | host = normalizeRegistryDomain(host) | |
| 108 | + | if host == "" { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | for _, domain := range KnownGordonRegistryDomains(current, legacy) { | |
| 113 | + | if strings.EqualFold(domain, host) { | |
| 114 | + | return true | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func splitRegistryImageRef(imageRef string) (host string, remainder string, ok bool) { | |
| 121 | + | host, remainder, ok = strings.Cut(imageRef, "/") | |
| 122 | + | if !ok || host == "" || remainder == "" { | |
| 123 | + | return "", "", false | |
| 124 | + | } | |
| 125 | + | if !looksLikeRegistryHost(host) { | |
| 126 | + | return "", "", false | |
| 127 | + | } | |
| 128 | + | return host, remainder, true | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func looksLikeRegistryHost(host string) bool { | |
| 132 | + | return strings.Contains(host, ".") || strings.Contains(host, ":") || strings.EqualFold(host, "localhost") | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | // KnownGordonRegistryDomains returns the normalized set of Gordon-managed | |||
| 6 | + | // registry domains, with the current domain first followed by legacy domains. | |||
| 7 | + | // Empty values are ignored, trailing slashes are removed, and duplicates are | |||
| 8 | + | // removed while preserving first-seen order. | |||
| 9 | + | func KnownGordonRegistryDomains(current string, legacy []string) []string { | |||
| 10 | + | domains := make([]string, 0, 1+len(legacy)) | |||
| 11 | + | seen := make(map[string]struct{}, 1+len(legacy)) | |||
| 12 | + | ||||
| 13 | + | add := func(domain string) { | |||
| 14 | + | normalized := normalizeRegistryDomain(domain) | |||
| 15 | + | if normalized == "" { | |||
| 16 | + | return | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | key := strings.ToLower(normalized) | |||
| 20 | + | if _, ok := seen[key]; ok { | |||
| 21 | + | return | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | seen[key] = struct{}{} | |||
| 25 | + | domains = append(domains, normalized) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | add(current) | |||
| 29 | + | for _, domain := range legacy { | |||
| 30 | + | add(domain) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | return domains | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // StripKnownGordonRegistry removes the Gordon-managed registry host prefix from | |||
| 37 | + | // an image reference when it matches the current or a legacy Gordon registry | |||
| 38 | + | // domain. Bare references and external registries are returned unchanged. | |||
| 39 | + | func StripKnownGordonRegistry(imageRef string, current string, legacy []string) string { | |||
| 40 | + | host, remainder, ok := splitRegistryImageRef(imageRef) | |||
| 41 | + | if !ok { | |||
| 42 | + | return imageRef | |||
| 43 | + | } | |||
| 44 | + | if !isKnownGordonRegistryDomain(host, current, legacy) { | |||
| 45 | + | return imageRef | |||
| 46 | + | } | |||
| 47 | + | return remainder | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // ExtractGordonRepoName strips a known Gordon registry host, then removes any | |||
| 51 | + | // tag or digest suffix, returning the repository name portion of the image | |||
| 52 | + | // reference. | |||
| 53 | + | func ExtractGordonRepoName(imageRef string, current string, legacy []string) string { | |||
| 54 | + | repo := StripKnownGordonRegistry(imageRef, current, legacy) | |||
| 55 | + | ||||
| 56 | + | if idx := strings.Index(repo, "@"); idx != -1 { | |||
| 57 | + | repo = repo[:idx] | |||
| 58 | + | } | |||
| 59 | + | if idx := strings.LastIndex(repo, ":"); idx != -1 { | |||
| 60 | + | slashIdx := strings.LastIndex(repo, "/") | |||
| 61 | + | if idx > slashIdx { | |||
| 62 | + | repo = repo[:idx] | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | return repo | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // CanonicalizeGordonImageRef rewrites Gordon-managed image references to use | |||
| 70 | + | // the current Gordon registry domain. External registries and bare references | |||
| 71 | + | // are returned unchanged. If the current domain is empty, the original image | |||
| 72 | + | // reference is returned unchanged. | |||
| 73 | + | func CanonicalizeGordonImageRef(imageRef, current string, legacy []string) string { | |||
| 74 | + | currentDomain := normalizeRegistryDomain(current) | |||
| 75 | + | if currentDomain == "" { | |||
| 76 | + | return imageRef | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | host, remainder, ok := splitRegistryImageRef(imageRef) | |||
| 80 | + | if !ok { | |||
| 81 | + | return imageRef | |||
| 82 | + | } | |||
| 83 | + | if !isKnownGordonRegistryDomain(host, currentDomain, legacy) { | |||
| 84 | + | return imageRef | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | return currentDomain + "/" + remainder | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // IsGordonRegistryImageRef reports whether an image reference is explicitly | |||
| 91 | + | // qualified with the current or a legacy Gordon registry domain. | |||
| 92 | + | func IsGordonRegistryImageRef(imageRef, current string, legacy []string) bool { | |||
| 93 | + | host, _, ok := splitRegistryImageRef(imageRef) | |||
| 94 | + | if !ok { | |||
| 95 | + | return false | |||
| 96 | + | } | |||
| 97 | + | return isKnownGordonRegistryDomain(host, current, legacy) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func normalizeRegistryDomain(domain string) string { | |||
| 101 | + | domain = strings.TrimSpace(domain) | |||
| 102 | + | domain = strings.TrimRight(domain, "/") | |||
| 103 | + | return domain | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func isKnownGordonRegistryDomain(host, current string, legacy []string) bool { | |||
| 107 | + | host = normalizeRegistryDomain(host) | |||
| 108 | + | if host == "" { | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | for _, domain := range KnownGordonRegistryDomains(current, legacy) { | |||
| 113 | + | if strings.EqualFold(domain, host) { | |||
| 114 | + | return true | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func splitRegistryImageRef(imageRef string) (host string, remainder string, ok bool) { | |||
| 121 | + | host, remainder, ok = strings.Cut(imageRef, "/") | |||
| 122 | + | if !ok || host == "" || remainder == "" { | |||
| 123 | + | return "", "", false | |||
| 124 | + | } | |||
| 125 | + | if !looksLikeRegistryHost(host) { | |||
| 126 | + | return "", "", false | |||
| 127 | + | } | |||
| 128 | + | return host, remainder, true | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func looksLikeRegistryHost(host string) bool { | |||
| 132 | + | return strings.Contains(host, ".") || strings.Contains(host, ":") || strings.EqualFold(host, "localhost") | |||
| 133 | + | } | |||
A internal/domain/registry_image_test.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package domain_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) { | |
| 11 | + | got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{ | |
| 12 | + | "", | |
| 13 | + | " old-registry.example.com/ ", | |
| 14 | + | "old-registry.example.com:5000///", | |
| 15 | + | "new-registry.example.com", | |
| 16 | + | "old-registry.example.com", | |
| 17 | + | }) | |
| 18 | + | ||
| 19 | + | want := []string{ | |
| 20 | + | "new-registry.example.com", | |
| 21 | + | "old-registry.example.com", | |
| 22 | + | "old-registry.example.com:5000", | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | if !reflect.DeepEqual(got, want) { | |
| 26 | + | t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRegistryImageStripKnownGordonRegistry(t *testing.T) { | |
| 31 | + | current := "new-registry.example.com/" | |
| 32 | + | legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"} | |
| 33 | + | ||
| 34 | + | tests := []struct { | |
| 35 | + | name string | |
| 36 | + | imageRef string | |
| 37 | + | want string | |
| 38 | + | }{ | |
| 39 | + | {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"}, | |
| 40 | + | {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"}, | |
| 41 | + | {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"}, | |
| 42 | + | {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"}, | |
| 43 | + | {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"}, | |
| 44 | + | {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"}, | |
| 45 | + | {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"}, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, tt := range tests { | |
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |
| 50 | + | got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy) | |
| 51 | + | if got != tt.want { | |
| 52 | + | t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want) | |
| 53 | + | } | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRegistryImageExtractGordonRepoName(t *testing.T) { | |
| 59 | + | current := "new-registry.example.com/" | |
| 60 | + | legacy := []string{"old-registry.example.com/"} | |
| 61 | + | ||
| 62 | + | tests := []struct { | |
| 63 | + | name string | |
| 64 | + | imageRef string | |
| 65 | + | want string | |
| 66 | + | }{ | |
| 67 | + | {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"}, | |
| 68 | + | {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"}, | |
| 69 | + | {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"}, | |
| 70 | + | {name: "bare image", imageRef: "app:latest", want: "app"}, | |
| 71 | + | {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, | |
| 72 | + | {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | for _, tt := range tests { | |
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |
| 77 | + | got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy) | |
| 78 | + | if got != tt.want { | |
| 79 | + | t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want) | |
| 80 | + | } | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) { | |
| 86 | + | legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} | |
| 87 | + | ||
| 88 | + | tests := []struct { | |
| 89 | + | name string | |
| 90 | + | imageRef string | |
| 91 | + | current string | |
| 92 | + | want string | |
| 93 | + | }{ | |
| 94 | + | {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, | |
| 95 | + | {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, | |
| 96 | + | {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"}, | |
| 97 | + | {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"}, | |
| 98 | + | {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"}, | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy) | |
| 104 | + | if got != tt.want { | |
| 105 | + | t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want) | |
| 106 | + | } | |
| 107 | + | }) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) { | |
| 112 | + | current := "new-registry.example.com/" | |
| 113 | + | legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} | |
| 114 | + | ||
| 115 | + | tests := []struct { | |
| 116 | + | name string | |
| 117 | + | imageRef string | |
| 118 | + | want bool | |
| 119 | + | }{ | |
| 120 | + | {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true}, | |
| 121 | + | {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true}, | |
| 122 | + | {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true}, | |
| 123 | + | {name: "bare ref", imageRef: "app:latest", want: false}, | |
| 124 | + | {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false}, | |
| 125 | + | {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false}, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy) | |
| 131 | + | if got != tt.want { | |
| 132 | + | t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want) | |
| 133 | + | } | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | package domain_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRegistryImageKnownGordonRegistryDomains(t *testing.T) { | |||
| 11 | + | got := domain.KnownGordonRegistryDomains(" new-registry.example.com/ ", []string{ | |||
| 12 | + | "", | |||
| 13 | + | " old-registry.example.com/ ", | |||
| 14 | + | "old-registry.example.com:5000///", | |||
| 15 | + | "new-registry.example.com", | |||
| 16 | + | "old-registry.example.com", | |||
| 17 | + | }) | |||
| 18 | + | ||||
| 19 | + | want := []string{ | |||
| 20 | + | "new-registry.example.com", | |||
| 21 | + | "old-registry.example.com", | |||
| 22 | + | "old-registry.example.com:5000", | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | if !reflect.DeepEqual(got, want) { | |||
| 26 | + | t.Fatalf("KnownGordonRegistryDomains() = %v, want %v", got, want) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestRegistryImageStripKnownGordonRegistry(t *testing.T) { | |||
| 31 | + | current := "new-registry.example.com/" | |||
| 32 | + | legacy := []string{" old-registry.example.com/ ", "old-registry.example.com:5000/"} | |||
| 33 | + | ||||
| 34 | + | tests := []struct { | |||
| 35 | + | name string | |||
| 36 | + | imageRef string | |||
| 37 | + | want string | |||
| 38 | + | }{ | |||
| 39 | + | {name: "current host strip", imageRef: "new-registry.example.com/app:latest", want: "app:latest"}, | |||
| 40 | + | {name: "legacy host strip", imageRef: "old-registry.example.com/app:latest", want: "app:latest"}, | |||
| 41 | + | {name: "explicit port strip", imageRef: "old-registry.example.com:5000/app:latest", want: "app:latest"}, | |||
| 42 | + | {name: "digest ref strip", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app@sha256:deadbeef"}, | |||
| 43 | + | {name: "bare image pass through", imageRef: "app:latest", want: "app:latest"}, | |||
| 44 | + | {name: "external image preserved", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx:latest"}, | |||
| 45 | + | {name: "hostile lookalike preserved", imageRef: "old-registry.example.com.evil/app:latest", want: "old-registry.example.com.evil/app:latest"}, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, tt := range tests { | |||
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 50 | + | got := domain.StripKnownGordonRegistry(tt.imageRef, current, legacy) | |||
| 51 | + | if got != tt.want { | |||
| 52 | + | t.Fatalf("StripKnownGordonRegistry(%q) = %q, want %q", tt.imageRef, got, tt.want) | |||
| 53 | + | } | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestRegistryImageExtractGordonRepoName(t *testing.T) { | |||
| 59 | + | current := "new-registry.example.com/" | |||
| 60 | + | legacy := []string{"old-registry.example.com/"} | |||
| 61 | + | ||||
| 62 | + | tests := []struct { | |||
| 63 | + | name string | |||
| 64 | + | imageRef string | |||
| 65 | + | want string | |||
| 66 | + | }{ | |||
| 67 | + | {name: "current host with tag", imageRef: "new-registry.example.com/app:latest", want: "app"}, | |||
| 68 | + | {name: "legacy host with namespace", imageRef: "old-registry.example.com/org/app:v1", want: "org/app"}, | |||
| 69 | + | {name: "digest ref", imageRef: "old-registry.example.com/app@sha256:deadbeef", want: "app"}, | |||
| 70 | + | {name: "bare image", imageRef: "app:latest", want: "app"}, | |||
| 71 | + | {name: "external registry retained in repo name", imageRef: "docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, | |||
| 72 | + | {name: "nested external path under gordon registry", imageRef: "old-registry.example.com/docker.io/library/nginx:latest", want: "docker.io/library/nginx"}, | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | for _, tt := range tests { | |||
| 76 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 77 | + | got := domain.ExtractGordonRepoName(tt.imageRef, current, legacy) | |||
| 78 | + | if got != tt.want { | |||
| 79 | + | t.Fatalf("ExtractGordonRepoName(%q) = %q, want %q", tt.imageRef, got, tt.want) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestRegistryImageCanonicalizeGordonImageRef(t *testing.T) { | |||
| 86 | + | legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} | |||
| 87 | + | ||||
| 88 | + | tests := []struct { | |||
| 89 | + | name string | |||
| 90 | + | imageRef string | |||
| 91 | + | current string | |||
| 92 | + | want string | |||
| 93 | + | }{ | |||
| 94 | + | {name: "canonicalization", imageRef: "old-registry.example.com/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, | |||
| 95 | + | {name: "explicit port canonicalization", imageRef: "old-registry.example.com:5000/app:latest", current: "new-registry.example.com/", want: "new-registry.example.com/app:latest"}, | |||
| 96 | + | {name: "external images are not canonicalized", imageRef: "docker.io/library/nginx:latest", current: "new-registry.example.com/", want: "docker.io/library/nginx:latest"}, | |||
| 97 | + | {name: "bare refs are not canonicalized", imageRef: "app:latest", current: "new-registry.example.com/", want: "app:latest"}, | |||
| 98 | + | {name: "empty current domain leaves ref unchanged", imageRef: "old-registry.example.com/app:latest", current: "", want: "old-registry.example.com/app:latest"}, | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | for _, tt := range tests { | |||
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 103 | + | got := domain.CanonicalizeGordonImageRef(tt.imageRef, tt.current, legacy) | |||
| 104 | + | if got != tt.want { | |||
| 105 | + | t.Fatalf("CanonicalizeGordonImageRef(%q) = %q, want %q", tt.imageRef, got, tt.want) | |||
| 106 | + | } | |||
| 107 | + | }) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestRegistryImageIsGordonRegistryImageRef(t *testing.T) { | |||
| 112 | + | current := "new-registry.example.com/" | |||
| 113 | + | legacy := []string{"old-registry.example.com/", "old-registry.example.com:5000/"} | |||
| 114 | + | ||||
| 115 | + | tests := []struct { | |||
| 116 | + | name string | |||
| 117 | + | imageRef string | |||
| 118 | + | want bool | |||
| 119 | + | }{ | |||
| 120 | + | {name: "current host", imageRef: "new-registry.example.com/app:latest", want: true}, | |||
| 121 | + | {name: "legacy host", imageRef: "old-registry.example.com/app:latest", want: true}, | |||
| 122 | + | {name: "legacy host with explicit port", imageRef: "old-registry.example.com:5000/app:latest", want: true}, | |||
| 123 | + | {name: "bare ref", imageRef: "app:latest", want: false}, | |||
| 124 | + | {name: "external ref", imageRef: "docker.io/library/nginx:latest", want: false}, | |||
| 125 | + | {name: "hostile lookalike", imageRef: "old-registry.example.com.evil/app:latest", want: false}, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for _, tt := range tests { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | got := domain.IsGordonRegistryImageRef(tt.imageRef, current, legacy) | |||
| 131 | + | if got != tt.want { | |||
| 132 | + | t.Fatalf("IsGordonRegistryImageRef(%q) = %t, want %t", tt.imageRef, got, tt.want) | |||
| 133 | + | } | |||
| 134 | + | }) | |||
| 135 | + | } | |||
| 136 | + | } | |||
M internal/usecase/auto/validation.go +10 -20
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package auto | |
| 2 | 2 | ||
| 3 | - | import "strings" | |
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // MatchesDomainAllowlist reports whether domain matches any of the given patterns. | |
| 6 | 10 | // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to | |
| @@ -36,23 +40,9 @@ func MatchesDomainAllowlist(domain string, patterns []string) bool { | |||
| 36 | 40 | return false | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 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)) | |
| 58 | 48 | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package auto | 1 | 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 "*" to | 10 | // Patterns may be exact domains, wildcard subdomains (*.example.com), or "*" to | ||
| @@ -36,23 +40,9 @@ func MatchesDomainAllowlist(domain string, patterns []string) bool { | |||||
| 36 | return false | 40 | return false | ||
| 37 | } | 41 | } | ||
| 38 | 42 | ||||
| 39 | - | // ExtractRepoName strips the registry domain, tag, and digest from an image | 43 | + | // 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) { | |||
| 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 | + | } | |
| @@ -57,3 +57,19 @@ func TestExtractRepoName(t *testing.T) { | |||||
| 57 | }) | 57 | }) | ||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | + | ||||
| 61 | + | func TestExtractRepoName_TreatsLegacyAndCurrentGordonRegistryHostsAsSameRepo(t *testing.T) { | |||
| 62 | + | current := "new-registry.example.com" | |||
| 63 | + | legacy := []string{"old-registry.example.com"} | |||
| 64 | + | ||||
| 65 | + | oldRepo := ExtractRepoName("old-registry.example.com/app:latest", current, legacy...) | |||
| 66 | + | newRepo := ExtractRepoName("new-registry.example.com/app:latest", current, legacy...) | |||
| 67 | + | ||||
| 68 | + | assert.Equal(t, "app", oldRepo) | |||
| 69 | + | assert.Equal(t, oldRepo, newRepo) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestExtractRepoName_HandlesPortsAndDigests(t *testing.T) { | |||
| 73 | + | assert.Equal(t, "org/app", ExtractRepoName("new-registry.example.com:5000/org/app:latest", "new-registry.example.com:5000")) | |||
| 74 | + | assert.Equal(t, "org/app", ExtractRepoName("old-registry.example.com:5001/org/app@sha256:deadbeef", "new-registry.example.com:5000", "old-registry.example.com:5001")) | |||
| 75 | + | } | |||
M internal/usecase/config/service.go +45 -23
| @@ -30,6 +30,7 @@ type Config struct { | |||
| 30 | 30 | ServerPort int | |
| 31 | 31 | RegistryPort int | |
| 32 | 32 | RegistryDomain string | |
| 33 | + | LegacyRegistryDomains []string | |
| 33 | 34 | DataDir string | |
| 34 | 35 | AutoRouteEnabled bool | |
| 35 | 36 | AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"` | |
| @@ -171,10 +172,13 @@ func (s *Service) loadConfigValues() Config { | |||
| 171 | 172 | previewSep = "--" | |
| 172 | 173 | } | |
| 173 | 174 | ||
| 175 | + | legacyRegistryDomains := append([]string{}, s.viper.GetStringSlice("server.legacy_registry_domains")...) | |
| 176 | + | ||
| 174 | 177 | return Config{ | |
| 175 | 178 | ServerPort: s.viper.GetInt("server.port"), | |
| 176 | 179 | RegistryPort: s.viper.GetInt("server.registry_port"), | |
| 177 | 180 | RegistryDomain: registryDomain, | |
| 181 | + | LegacyRegistryDomains: legacyRegistryDomains, | |
| 178 | 182 | DataDir: s.viper.GetString("server.data_dir"), | |
| 179 | 183 | AutoRouteEnabled: autoEnabled, | |
| 180 | 184 | AutoRouteAllowedDomains: append([]string{}, allowedDomains...), | |
| @@ -423,7 +427,7 @@ func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domai | |||
| 423 | 427 | if strings.HasPrefix(domainName, "http://") { | |
| 424 | 428 | 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 | 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 | 443 | var targets []string | |
| 440 | 444 | for target, images := range s.config.Attachments { | |
| 441 | 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 | 447 | targets = append(targets, target) | |
| 444 | 448 | break | |
| 445 | 449 | } | |
| @@ -449,10 +453,10 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri | |||
| 449 | 453 | return targets | |
| 450 | 454 | } | |
| 451 | 455 | ||
| 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) | |
| 454 | 458 | inputName, inputHasTag := splitImageNameTag(normalizedInput) | |
| 455 | - | normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain) | |
| 459 | + | normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain, legacyRegistryDomains) | |
| 456 | 460 | ||
| 457 | 461 | if inputHasTag { | |
| 458 | 462 | return strings.EqualFold(normalizedCandidate, normalizedInput) | |
| @@ -470,19 +474,10 @@ func splitImageNameTag(image string) (name string, hasTag bool) { | |||
| 470 | 474 | return image, false | |
| 471 | 475 | } | |
| 472 | 476 | ||
| 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) | |
| 486 | 481 | } | |
| 487 | 482 | ||
| 488 | 483 | // NormalizeBootstrapImage converts a user-supplied image argument into | |
| @@ -816,7 +811,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) | |||
| 816 | 811 | ||
| 817 | 812 | delete(configMap, "routes") | |
| 818 | 813 | configMap["external_routes"] = snapshotConfig.ExternalRoutes | |
| 819 | - | configMap["attachments"] = snapshotConfig.Attachments | |
| 814 | + | configMap["attachments"] = canonicalAttachmentsForSave(snapshotConfig.Attachments, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains) | |
| 820 | 815 | configMap["network_groups"] = snapshotConfig.NetworkGroups | |
| 821 | 816 | applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains) | |
| 822 | 817 | ||
| @@ -825,7 +820,7 @@ func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) | |||
| 825 | 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 | 824 | if err != nil { | |
| 830 | 825 | return err | |
| 831 | 826 | } | |
| @@ -879,12 +874,39 @@ func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) { | |||
| 879 | 874 | } | |
| 880 | 875 | } | |
| 881 | 876 | ||
| 882 | - | func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) { | |
| 877 | + | func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain string, legacyRegistryDomains []string) map[string][]string { | |
| 878 | + | if attachments == nil { | |
| 879 | + | return nil | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | result := make(map[string][]string, len(attachments)) | |
| 883 | + | for target, images := range attachments { | |
| 884 | + | if images == nil { | |
| 885 | + | result[target] = nil | |
| 886 | + | continue | |
| 887 | + | } | |
| 888 | + | ||
| 889 | + | canonicalImages := make([]string, len(images)) | |
| 890 | + | for i, image := range images { | |
| 891 | + | canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | |
| 892 | + | } | |
| 893 | + | result[target] = canonicalImages | |
| 894 | + | } | |
| 895 | + | ||
| 896 | + | return result | |
| 897 | + | } | |
| 898 | + | ||
| 899 | + | func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) { | |
| 883 | 900 | result := make(map[string]routeConfig) | |
| 884 | 901 | if routes == nil { | |
| 885 | 902 | return result, nil | |
| 886 | 903 | } | |
| 887 | 904 | ||
| 905 | + | canonicalizeRoute := func(route routeConfig) routeConfig { | |
| 906 | + | route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains) | |
| 907 | + | return route | |
| 908 | + | } | |
| 909 | + | ||
| 888 | 910 | for key, route := range routes { | |
| 889 | 911 | if strings.HasPrefix(key, "http://") { | |
| 890 | 912 | continue | |
| @@ -892,7 +914,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf | |||
| 892 | 914 | if !domain.IsValidRouteDomain(key) { | |
| 893 | 915 | return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid) | |
| 894 | 916 | } | |
| 895 | - | result[key] = route | |
| 917 | + | result[key] = canonicalizeRoute(route) | |
| 896 | 918 | } | |
| 897 | 919 | ||
| 898 | 920 | for key, route := range routes { | |
| @@ -908,7 +930,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf | |||
| 908 | 930 | continue | |
| 909 | 931 | } | |
| 910 | 932 | route.HTTPS = false | |
| 911 | - | result[domainName] = route | |
| 933 | + | result[domainName] = canonicalizeRoute(route) | |
| 912 | 934 | } | |
| 913 | 935 | ||
| 914 | 936 | return result, nil | |
| @@ -30,6 +30,7 @@ type Config struct { | |||||
| 30 | ServerPort int | 30 | ServerPort int | ||
| 31 | RegistryPort int | 31 | RegistryPort int | ||
| 32 | RegistryDomain string | 32 | RegistryDomain string | ||
| 33 | + | LegacyRegistryDomains []string | |||
| 33 | DataDir string | 34 | DataDir string | ||
| 34 | AutoRouteEnabled bool | 35 | 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 | continue | 428 | 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 []string | 443 | 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 | break | 448 | break | ||
| 445 | } | 449 | } | ||
| @@ -449,10 +453,10 @@ func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName stri | |||||
| 449 | return targets | 453 | 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, false | 474 | 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 into | 483 | // 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.ExternalRoutes | 813 | configMap["external_routes"] = snapshotConfig.ExternalRoutes | ||
| 819 | - | configMap["attachments"] = snapshotConfig.Attachments | 814 | + | configMap["attachments"] = canonicalAttachmentsForSave(snapshotConfig.Attachments, snapshotConfig.RegistryDomain, snapshotConfig.LegacyRegistryDomains) |
| 820 | configMap["network_groups"] = snapshotConfig.NetworkGroups | 815 | 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 err | 825 | return err | ||
| 831 | } | 826 | } | ||
| @@ -879,12 +874,39 @@ func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) { | |||||
| 879 | } | 874 | } | ||
| 880 | } | 875 | } | ||
| 881 | 876 | ||||
| 882 | - | func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) { | 877 | + | func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain string, legacyRegistryDomains []string) map[string][]string { |
| 878 | + | if attachments == nil { | |||
| 879 | + | return nil | |||
| 880 | + | } | |||
| 881 | + | ||||
| 882 | + | result := make(map[string][]string, len(attachments)) | |||
| 883 | + | for target, images := range attachments { | |||
| 884 | + | if images == nil { | |||
| 885 | + | result[target] = nil | |||
| 886 | + | continue | |||
| 887 | + | } | |||
| 888 | + | ||||
| 889 | + | canonicalImages := make([]string, len(images)) | |||
| 890 | + | for i, image := range images { | |||
| 891 | + | canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | |||
| 892 | + | } | |||
| 893 | + | result[target] = canonicalImages | |||
| 894 | + | } | |||
| 895 | + | ||||
| 896 | + | return result | |||
| 897 | + | } | |||
| 898 | + | ||||
| 899 | + | func canonicalRoutesForSave(routes map[string]routeConfig, registryDomain string, legacyRegistryDomains []string) (map[string]routeConfig, error) { | |||
| 883 | result := make(map[string]routeConfig) | 900 | result := make(map[string]routeConfig) | ||
| 884 | if routes == nil { | 901 | if routes == nil { | ||
| 885 | return result, nil | 902 | return result, nil | ||
| 886 | } | 903 | } | ||
| 887 | 904 | ||||
| 905 | + | canonicalizeRoute := func(route routeConfig) routeConfig { | |||
| 906 | + | route.Image = domain.CanonicalizeGordonImageRef(route.Image, registryDomain, legacyRegistryDomains) | |||
| 907 | + | return route | |||
| 908 | + | } | |||
| 909 | + | ||||
| 888 | for key, route := range routes { | 910 | for key, route := range routes { | ||
| 889 | if strings.HasPrefix(key, "http://") { | 911 | if strings.HasPrefix(key, "http://") { | ||
| 890 | continue | 912 | continue | ||
| @@ -892,7 +914,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf | |||||
| 892 | if !domain.IsValidRouteDomain(key) { | 914 | if !domain.IsValidRouteDomain(key) { | ||
| 893 | return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid) | 915 | return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid) | ||
| 894 | } | 916 | } | ||
| 895 | - | result[key] = route | 917 | + | result[key] = canonicalizeRoute(route) |
| 896 | } | 918 | } | ||
| 897 | 919 | ||||
| 898 | for key, route := range routes { | 920 | for key, route := range routes { | ||
| @@ -908,7 +930,7 @@ func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConf | |||||
| 908 | continue | 930 | continue | ||
| 909 | } | 931 | } | ||
| 910 | route.HTTPS = false | 932 | route.HTTPS = false | ||
| 911 | - | result[domainName] = route | 933 | + | result[domainName] = canonicalizeRoute(route) |
| 912 | } | 934 | } | ||
| 913 | 935 | ||||
| 914 | return result, nil | 936 | return result, nil | ||
M internal/usecase/config/service_test.go +226 -17
| @@ -217,6 +217,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test | |||
| 217 | 217 | assert.True(t, routes[0].HTTPS) | |
| 218 | 218 | } | |
| 219 | 219 | ||
| 220 | + | func TestCanonicalRoutesForSave(t *testing.T) { | |
| 221 | + | routes := map[string]routeConfig{ | |
| 222 | + | "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true}, | |
| 223 | + | "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true}, | |
| 224 | + | "bare.example.com": {Image: "worker:v2", HTTPS: true}, | |
| 225 | + | "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true}, | |
| 226 | + | "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true}, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"}) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com")) | |
| 233 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com")) | |
| 234 | + | assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com")) | |
| 235 | + | assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com")) | |
| 236 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com")) | |
| 237 | + | ||
| 238 | + | assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image) | |
| 239 | + | assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image) | |
| 240 | + | } | |
| 241 | + | ||
| 220 | 242 | func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { | |
| 221 | 243 | tmpDir := t.TempDir() | |
| 222 | 244 | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| @@ -242,9 +264,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { | |||
| 242 | 264 | func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { | |
| 243 | 265 | tmpDir := t.TempDir() | |
| 244 | 266 | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 245 | - | initialConfig := `[routes] | |
| 246 | - | "secure.example.com" = "secure:v1" | |
| 247 | - | "http://insecure.example.com" = "insecure:v1" | |
| 267 | + | initialConfig := `[server] | |
| 268 | + | gordon_domain = "registry.example.com" | |
| 269 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 270 | + | ||
| 271 | + | [routes] | |
| 272 | + | "secure.example.com" = "old-registry.example.com/secure:v1" | |
| 273 | + | "http://insecure.example.com" = "old-registry.example.com/insecure:v1" | |
| 248 | 274 | ` | |
| 249 | 275 | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 250 | 276 | require.NoError(t, err) | |
| @@ -266,13 +292,118 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { | |||
| 266 | 292 | content, err := os.ReadFile(configFile) | |
| 267 | 293 | require.NoError(t, err) | |
| 268 | 294 | text := string(content) | |
| 269 | - | assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`) | |
| 270 | - | assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`) | |
| 295 | + | assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`) | |
| 296 | + | assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`) | |
| 271 | 297 | assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`) | |
| 272 | 298 | assert.NotContains(t, text, "http://insecure.example.com") | |
| 273 | 299 | assert.Contains(t, text, "[routes]") | |
| 274 | 300 | } | |
| 275 | 301 | ||
| 302 | + | func TestService_SaveCanonicalizesRouteImages(t *testing.T) { | |
| 303 | + | tmpDir := t.TempDir() | |
| 304 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 305 | + | initialConfig := `[server] | |
| 306 | + | gordon_domain = "registry.example.com" | |
| 307 | + | legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"] | |
| 308 | + | ||
| 309 | + | [routes] | |
| 310 | + | "legacy.example.com" = "old-registry.example.com/app:latest" | |
| 311 | + | "legacy-port.example.com" = "old-registry.example.com:5000/api:v2" | |
| 312 | + | "current.example.com" = "registry.example.com/web:v3" | |
| 313 | + | "bare.example.com" = "worker:v4" | |
| 314 | + | "external.example.com" = "docker.io/library/nginx:latest" | |
| 315 | + | ` | |
| 316 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 317 | + | require.NoError(t, err) | |
| 318 | + | ||
| 319 | + | v := viper.New() | |
| 320 | + | v.SetConfigFile(configFile) | |
| 321 | + | require.NoError(t, v.ReadInConfig()) | |
| 322 | + | ||
| 323 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 324 | + | svc := NewService(v, eventBus) | |
| 325 | + | ctx := testContext() | |
| 326 | + | ||
| 327 | + | require.NoError(t, svc.Load(ctx)) | |
| 328 | + | assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image) | |
| 329 | + | ||
| 330 | + | require.NoError(t, svc.Save(ctx)) | |
| 331 | + | ||
| 332 | + | content, err := os.ReadFile(configFile) | |
| 333 | + | require.NoError(t, err) | |
| 334 | + | text := string(content) | |
| 335 | + | assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`) | |
| 336 | + | assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`) | |
| 337 | + | assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`) | |
| 338 | + | assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`) | |
| 339 | + | assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |
| 343 | + | tmpDir := t.TempDir() | |
| 344 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 345 | + | initialConfig := `[server] | |
| 346 | + | gordon_domain = "registry.example.com" | |
| 347 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 348 | + | ||
| 349 | + | [attachments] | |
| 350 | + | "app.example.com" = [ | |
| 351 | + | "old-registry.example.com/postgres:18", | |
| 352 | + | "registry.example.com/redis:7", | |
| 353 | + | "rabbitmq:3", | |
| 354 | + | "docker.io/library/nginx:latest", | |
| 355 | + | ] | |
| 356 | + | ` | |
| 357 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 358 | + | require.NoError(t, err) | |
| 359 | + | ||
| 360 | + | v := viper.New() | |
| 361 | + | v.SetConfigFile(configFile) | |
| 362 | + | require.NoError(t, v.ReadInConfig()) | |
| 363 | + | ||
| 364 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 365 | + | svc := NewService(v, eventBus) | |
| 366 | + | ctx := testContext() | |
| 367 | + | ||
| 368 | + | require.NoError(t, svc.Load(ctx)) | |
| 369 | + | require.Equal(t, []string{ | |
| 370 | + | "old-registry.example.com/postgres:18", | |
| 371 | + | "registry.example.com/redis:7", | |
| 372 | + | "rabbitmq:3", | |
| 373 | + | "docker.io/library/nginx:latest", | |
| 374 | + | }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs") | |
| 375 | + | ||
| 376 | + | require.NoError(t, svc.Save(ctx)) | |
| 377 | + | require.Equal(t, []string{ | |
| 378 | + | "old-registry.example.com/postgres:18", | |
| 379 | + | "registry.example.com/redis:7", | |
| 380 | + | "rabbitmq:3", | |
| 381 | + | "docker.io/library/nginx:latest", | |
| 382 | + | }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs") | |
| 383 | + | ||
| 384 | + | saved := viper.New() | |
| 385 | + | saved.SetConfigFile(configFile) | |
| 386 | + | require.NoError(t, saved.ReadInConfig()) | |
| 387 | + | ||
| 388 | + | var attachments map[string][]string | |
| 389 | + | require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) | |
| 390 | + | assert.Equal(t, []string{ | |
| 391 | + | "registry.example.com/postgres:18", | |
| 392 | + | "registry.example.com/redis:7", | |
| 393 | + | "rabbitmq:3", | |
| 394 | + | "docker.io/library/nginx:latest", | |
| 395 | + | }, attachments["app.example.com"]) | |
| 396 | + | ||
| 397 | + | content, err := os.ReadFile(configFile) | |
| 398 | + | require.NoError(t, err) | |
| 399 | + | text := string(content) | |
| 400 | + | assert.Contains(t, text, `registry.example.com/postgres:18`) | |
| 401 | + | assert.Contains(t, text, `registry.example.com/redis:7`) | |
| 402 | + | assert.Contains(t, text, `rabbitmq:3`) | |
| 403 | + | assert.Contains(t, text, `docker.io/library/nginx:latest`) | |
| 404 | + | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | |
| 405 | + | } | |
| 406 | + | ||
| 276 | 407 | func TestService_Reload(t *testing.T) { | |
| 277 | 408 | t.Run("success - picks up config file changes", func(t *testing.T) { | |
| 278 | 409 | // Create temp config file | |
| @@ -1382,6 +1513,47 @@ func TestService_AddAttachment(t *testing.T) { | |||
| 1382 | 1513 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) | |
| 1383 | 1514 | }) | |
| 1384 | 1515 | ||
| 1516 | + | t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) { | |
| 1517 | + | tmpDir := t.TempDir() | |
| 1518 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1519 | + | initialConfig := `[server] | |
| 1520 | + | gordon_domain = "registry.example.com" | |
| 1521 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 1522 | + | ||
| 1523 | + | [attachments] | |
| 1524 | + | ` | |
| 1525 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1526 | + | require.NoError(t, err) | |
| 1527 | + | ||
| 1528 | + | v := viper.New() | |
| 1529 | + | v.SetConfigFile(configFile) | |
| 1530 | + | require.NoError(t, v.ReadInConfig()) | |
| 1531 | + | ||
| 1532 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1533 | + | svc := NewService(v, eventBus) | |
| 1534 | + | ctx := testContext() | |
| 1535 | + | require.NoError(t, svc.Load(ctx)) | |
| 1536 | + | ||
| 1537 | + | err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") | |
| 1538 | + | require.NoError(t, err) | |
| 1539 | + | ||
| 1540 | + | require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |
| 1541 | + | ||
| 1542 | + | saved := viper.New() | |
| 1543 | + | saved.SetConfigFile(configFile) | |
| 1544 | + | require.NoError(t, saved.ReadInConfig()) | |
| 1545 | + | ||
| 1546 | + | var attachments map[string][]string | |
| 1547 | + | require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) | |
| 1548 | + | assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"]) | |
| 1549 | + | ||
| 1550 | + | content, err := os.ReadFile(configFile) | |
| 1551 | + | require.NoError(t, err) | |
| 1552 | + | text := string(content) | |
| 1553 | + | assert.Contains(t, text, `registry.example.com/postgres:18`) | |
| 1554 | + | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | |
| 1555 | + | }) | |
| 1556 | + | ||
| 1385 | 1557 | t.Run("duplicate attachment", func(t *testing.T) { | |
| 1386 | 1558 | v := viper.New() | |
| 1387 | 1559 | v.Set("attachments", map[string]interface{}{ | |
| @@ -1700,23 +1872,25 @@ func TestSplitImageNameTag(t *testing.T) { | |||
| 1700 | 1872 | ||
| 1701 | 1873 | func TestNormalizeRegistryImage(t *testing.T) { | |
| 1702 | 1874 | tests := []struct { | |
| 1703 | - | name string | |
| 1704 | - | imageName string | |
| 1705 | - | registryDomain string | |
| 1706 | - | expected string | |
| 1875 | + | name string | |
| 1876 | + | imageName string | |
| 1877 | + | registryDomain string | |
| 1878 | + | legacyRegistryDomains []string | |
| 1879 | + | expected string | |
| 1707 | 1880 | }{ | |
| 1708 | - | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 1709 | - | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 1710 | - | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | |
| 1711 | - | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | |
| 1712 | - | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | |
| 1713 | - | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | |
| 1714 | - | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | |
| 1881 | + | {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"}, | |
| 1882 | + | {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"}, | |
| 1883 | + | {"strips legacy registry prefix with explicit port and digest", "old-reg.example.com:5000/myapp@sha256:deadbeef", "new-reg.example.com", []string{"old-reg.example.com:5000"}, "myapp@sha256:deadbeef"}, | |
| 1884 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"}, | |
| 1885 | + | {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"}, | |
| 1886 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"}, | |
| 1887 | + | {"hostile lookalike is not stripped", "old-reg.example.com.evil/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "old-reg.example.com.evil/myapp:latest"}, | |
| 1888 | + | {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"}, | |
| 1715 | 1889 | } | |
| 1716 | 1890 | ||
| 1717 | 1891 | for _, tt := range tests { | |
| 1718 | 1892 | t.Run(tt.name, func(t *testing.T) { | |
| 1719 | - | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | |
| 1893 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains) | |
| 1720 | 1894 | assert.Equal(t, tt.expected, result) | |
| 1721 | 1895 | }) | |
| 1722 | 1896 | } | |
| @@ -1869,6 +2043,24 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||
| 1869 | 2043 | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 1870 | 2044 | }) | |
| 1871 | 2045 | ||
| 2046 | + | t.Run("matches current image when route stores legacy registry host", func(t *testing.T) { | |
| 2047 | + | v := viper.New() | |
| 2048 | + | v.Set("server.gordon_domain", "new-registry.example.com") | |
| 2049 | + | v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) | |
| 2050 | + | v.Set("routes", map[string]interface{}{ | |
| 2051 | + | "app.example.com": "old-registry.example.com/myapp:latest", | |
| 2052 | + | }) | |
| 2053 | + | ||
| 2054 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2055 | + | svc := NewService(v, eventBus) | |
| 2056 | + | ctx := testContext() | |
| 2057 | + | _ = svc.Load(ctx) | |
| 2058 | + | ||
| 2059 | + | routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest") | |
| 2060 | + | assert.Len(t, routes, 1) | |
| 2061 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 2062 | + | }) | |
| 2063 | + | ||
| 1872 | 2064 | t.Run("multiple routes for same image", func(t *testing.T) { | |
| 1873 | 2065 | v := viper.New() | |
| 1874 | 2066 | v.Set("routes", map[string]interface{}{ | |
| @@ -1980,6 +2172,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) { | |||
| 1980 | 2172 | assert.Equal(t, []string{"backend"}, targets) | |
| 1981 | 2173 | }) | |
| 1982 | 2174 | ||
| 2175 | + | t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) { | |
| 2176 | + | v := viper.New() | |
| 2177 | + | v.Set("server.gordon_domain", "new-registry.example.com") | |
| 2178 | + | v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) | |
| 2179 | + | v.Set("attachments", map[string]interface{}{ | |
| 2180 | + | "backend": []interface{}{"old-registry.example.com/postgres:18"}, | |
| 2181 | + | }) | |
| 2182 | + | ||
| 2183 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2184 | + | svc := NewService(v, eventBus) | |
| 2185 | + | ctx := testContext() | |
| 2186 | + | _ = svc.Load(ctx) | |
| 2187 | + | ||
| 2188 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18") | |
| 2189 | + | assert.Equal(t, []string{"backend"}, targets) | |
| 2190 | + | }) | |
| 2191 | + | ||
| 1983 | 2192 | t.Run("shared attachment used by multiple targets", func(t *testing.T) { | |
| 1984 | 2193 | v := viper.New() | |
| 1985 | 2194 | v.Set("attachments", map[string]interface{}{ | |
| @@ -217,6 +217,28 @@ func TestService_FindRoutesByImage_DeduplicatesCanonicalAndLegacyEntries(t *test | |||||
| 217 | assert.True(t, routes[0].HTTPS) | 217 | assert.True(t, routes[0].HTTPS) | ||
| 218 | } | 218 | } | ||
| 219 | 219 | ||||
| 220 | + | func TestCanonicalRoutesForSave(t *testing.T) { | |||
| 221 | + | routes := map[string]routeConfig{ | |||
| 222 | + | "legacy.example.com": {Image: "old-registry.example.com/app:latest", HTTPS: true}, | |||
| 223 | + | "current.example.com": {Image: "registry.example.com/current:v1", HTTPS: true}, | |||
| 224 | + | "bare.example.com": {Image: "worker:v2", HTTPS: true}, | |||
| 225 | + | "external.example.com": {Image: "docker.io/library/nginx:latest", HTTPS: true}, | |||
| 226 | + | "http://legacy-http.example.com": {Image: "old-registry.example.com:5000/http:v3", HTTPS: true}, | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | savedRoutes, err := canonicalRoutesForSave(routes, "registry.example.com", []string{"old-registry.example.com", "old-registry.example.com:5000"}) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/app:latest", HTTPS: true}, requireRoute(t, savedRoutes, "legacy.example.com")) | |||
| 233 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/current:v1", HTTPS: true}, requireRoute(t, savedRoutes, "current.example.com")) | |||
| 234 | + | assert.Equal(t, routeConfig{Image: "worker:v2", HTTPS: true}, requireRoute(t, savedRoutes, "bare.example.com")) | |||
| 235 | + | assert.Equal(t, routeConfig{Image: "docker.io/library/nginx:latest", HTTPS: true}, requireRoute(t, savedRoutes, "external.example.com")) | |||
| 236 | + | assert.Equal(t, routeConfig{Image: "registry.example.com/http:v3", HTTPS: false}, requireRoute(t, savedRoutes, "legacy-http.example.com")) | |||
| 237 | + | ||||
| 238 | + | assert.Equal(t, "old-registry.example.com/app:latest", routes["legacy.example.com"].Image) | |||
| 239 | + | assert.Equal(t, "old-registry.example.com:5000/http:v3", routes["http://legacy-http.example.com"].Image) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 220 | func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { | 242 | func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { | ||
| 221 | tmpDir := t.TempDir() | 243 | tmpDir := t.TempDir() | ||
| 222 | configFile := filepath.Join(tmpDir, "gordon.toml") | 244 | configFile := filepath.Join(tmpDir, "gordon.toml") | ||
| @@ -242,9 +264,13 @@ func TestService_AddRoute_StoresHTTPSFalseInCanonicalForm(t *testing.T) { | |||||
| 242 | func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { | 264 | func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { | ||
| 243 | tmpDir := t.TempDir() | 265 | tmpDir := t.TempDir() | ||
| 244 | configFile := filepath.Join(tmpDir, "gordon.toml") | 266 | configFile := filepath.Join(tmpDir, "gordon.toml") | ||
| 245 | - | initialConfig := `[routes] | 267 | + | initialConfig := `[server] |
| 246 | - | "secure.example.com" = "secure:v1" | 268 | + | gordon_domain = "registry.example.com" |
| 247 | - | "http://insecure.example.com" = "insecure:v1" | 269 | + | legacy_registry_domains = ["old-registry.example.com"] |
| 270 | + | ||||
| 271 | + | [routes] | |||
| 272 | + | "secure.example.com" = "old-registry.example.com/secure:v1" | |||
| 273 | + | "http://insecure.example.com" = "old-registry.example.com/insecure:v1" | |||
| 248 | ` | 274 | ` | ||
| 249 | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | 275 | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | ||
| 250 | require.NoError(t, err) | 276 | require.NoError(t, err) | ||
| @@ -266,13 +292,118 @@ func TestService_AddRoute_RewritesRoutesToCanonicalInlineTables(t *testing.T) { | |||||
| 266 | content, err := os.ReadFile(configFile) | 292 | content, err := os.ReadFile(configFile) | ||
| 267 | require.NoError(t, err) | 293 | require.NoError(t, err) | ||
| 268 | text := string(content) | 294 | text := string(content) | ||
| 269 | - | assert.Contains(t, text, `"secure.example.com" = { image = "secure:v1", https = true }`) | 295 | + | assert.Contains(t, text, `"secure.example.com" = { image = "registry.example.com/secure:v1", https = true }`) |
| 270 | - | assert.Contains(t, text, `"insecure.example.com" = { image = "insecure:v1", https = false }`) | 296 | + | assert.Contains(t, text, `"insecure.example.com" = { image = "registry.example.com/insecure:v1", https = false }`) |
| 271 | assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`) | 297 | assert.Contains(t, text, `"new.example.com" = { image = "new:v1", https = false }`) | ||
| 272 | assert.NotContains(t, text, "http://insecure.example.com") | 298 | assert.NotContains(t, text, "http://insecure.example.com") | ||
| 273 | assert.Contains(t, text, "[routes]") | 299 | assert.Contains(t, text, "[routes]") | ||
| 274 | } | 300 | } | ||
| 275 | 301 | ||||
| 302 | + | func TestService_SaveCanonicalizesRouteImages(t *testing.T) { | |||
| 303 | + | tmpDir := t.TempDir() | |||
| 304 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 305 | + | initialConfig := `[server] | |||
| 306 | + | gordon_domain = "registry.example.com" | |||
| 307 | + | legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com:5000"] | |||
| 308 | + | ||||
| 309 | + | [routes] | |||
| 310 | + | "legacy.example.com" = "old-registry.example.com/app:latest" | |||
| 311 | + | "legacy-port.example.com" = "old-registry.example.com:5000/api:v2" | |||
| 312 | + | "current.example.com" = "registry.example.com/web:v3" | |||
| 313 | + | "bare.example.com" = "worker:v4" | |||
| 314 | + | "external.example.com" = "docker.io/library/nginx:latest" | |||
| 315 | + | ` | |||
| 316 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 317 | + | require.NoError(t, err) | |||
| 318 | + | ||||
| 319 | + | v := viper.New() | |||
| 320 | + | v.SetConfigFile(configFile) | |||
| 321 | + | require.NoError(t, v.ReadInConfig()) | |||
| 322 | + | ||||
| 323 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 324 | + | svc := NewService(v, eventBus) | |||
| 325 | + | ctx := testContext() | |||
| 326 | + | ||||
| 327 | + | require.NoError(t, svc.Load(ctx)) | |||
| 328 | + | assert.Equal(t, "old-registry.example.com/app:latest", requireRoute(t, svc.GetConfig().Routes, "legacy.example.com").Image) | |||
| 329 | + | ||||
| 330 | + | require.NoError(t, svc.Save(ctx)) | |||
| 331 | + | ||||
| 332 | + | content, err := os.ReadFile(configFile) | |||
| 333 | + | require.NoError(t, err) | |||
| 334 | + | text := string(content) | |||
| 335 | + | assert.Contains(t, text, `"legacy.example.com" = { image = "registry.example.com/app:latest", https = true }`) | |||
| 336 | + | assert.Contains(t, text, `"legacy-port.example.com" = { image = "registry.example.com/api:v2", https = true }`) | |||
| 337 | + | assert.Contains(t, text, `"current.example.com" = { image = "registry.example.com/web:v3", https = true }`) | |||
| 338 | + | assert.Contains(t, text, `"bare.example.com" = { image = "worker:v4", https = true }`) | |||
| 339 | + | assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||
| 343 | + | tmpDir := t.TempDir() | |||
| 344 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 345 | + | initialConfig := `[server] | |||
| 346 | + | gordon_domain = "registry.example.com" | |||
| 347 | + | legacy_registry_domains = ["old-registry.example.com"] | |||
| 348 | + | ||||
| 349 | + | [attachments] | |||
| 350 | + | "app.example.com" = [ | |||
| 351 | + | "old-registry.example.com/postgres:18", | |||
| 352 | + | "registry.example.com/redis:7", | |||
| 353 | + | "rabbitmq:3", | |||
| 354 | + | "docker.io/library/nginx:latest", | |||
| 355 | + | ] | |||
| 356 | + | ` | |||
| 357 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 358 | + | require.NoError(t, err) | |||
| 359 | + | ||||
| 360 | + | v := viper.New() | |||
| 361 | + | v.SetConfigFile(configFile) | |||
| 362 | + | require.NoError(t, v.ReadInConfig()) | |||
| 363 | + | ||||
| 364 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 365 | + | svc := NewService(v, eventBus) | |||
| 366 | + | ctx := testContext() | |||
| 367 | + | ||||
| 368 | + | require.NoError(t, svc.Load(ctx)) | |||
| 369 | + | require.Equal(t, []string{ | |||
| 370 | + | "old-registry.example.com/postgres:18", | |||
| 371 | + | "registry.example.com/redis:7", | |||
| 372 | + | "rabbitmq:3", | |||
| 373 | + | "docker.io/library/nginx:latest", | |||
| 374 | + | }, svc.GetConfig().Attachments["app.example.com"], "load path should preserve legacy attachment refs") | |||
| 375 | + | ||||
| 376 | + | require.NoError(t, svc.Save(ctx)) | |||
| 377 | + | require.Equal(t, []string{ | |||
| 378 | + | "old-registry.example.com/postgres:18", | |||
| 379 | + | "registry.example.com/redis:7", | |||
| 380 | + | "rabbitmq:3", | |||
| 381 | + | "docker.io/library/nginx:latest", | |||
| 382 | + | }, svc.GetConfig().Attachments["app.example.com"], "save path should not rewrite in-memory attachment refs") | |||
| 383 | + | ||||
| 384 | + | saved := viper.New() | |||
| 385 | + | saved.SetConfigFile(configFile) | |||
| 386 | + | require.NoError(t, saved.ReadInConfig()) | |||
| 387 | + | ||||
| 388 | + | var attachments map[string][]string | |||
| 389 | + | require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) | |||
| 390 | + | assert.Equal(t, []string{ | |||
| 391 | + | "registry.example.com/postgres:18", | |||
| 392 | + | "registry.example.com/redis:7", | |||
| 393 | + | "rabbitmq:3", | |||
| 394 | + | "docker.io/library/nginx:latest", | |||
| 395 | + | }, attachments["app.example.com"]) | |||
| 396 | + | ||||
| 397 | + | content, err := os.ReadFile(configFile) | |||
| 398 | + | require.NoError(t, err) | |||
| 399 | + | text := string(content) | |||
| 400 | + | assert.Contains(t, text, `registry.example.com/postgres:18`) | |||
| 401 | + | assert.Contains(t, text, `registry.example.com/redis:7`) | |||
| 402 | + | assert.Contains(t, text, `rabbitmq:3`) | |||
| 403 | + | assert.Contains(t, text, `docker.io/library/nginx:latest`) | |||
| 404 | + | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 276 | func TestService_Reload(t *testing.T) { | 407 | func TestService_Reload(t *testing.T) { | ||
| 277 | t.Run("success - picks up config file changes", func(t *testing.T) { | 408 | t.Run("success - picks up config file changes", func(t *testing.T) { | ||
| 278 | // Create temp config file | 409 | // Create temp config file | ||
| @@ -1382,6 +1513,47 @@ func TestService_AddAttachment(t *testing.T) { | |||||
| 1382 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) | 1513 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, config.Attachments["app.example.com"]) | ||
| 1383 | }) | 1514 | }) | ||
| 1384 | 1515 | ||||
| 1516 | + | t.Run("legacy Gordon ref is canonicalized on disk only", func(t *testing.T) { | |||
| 1517 | + | tmpDir := t.TempDir() | |||
| 1518 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1519 | + | initialConfig := `[server] | |||
| 1520 | + | gordon_domain = "registry.example.com" | |||
| 1521 | + | legacy_registry_domains = ["old-registry.example.com"] | |||
| 1522 | + | ||||
| 1523 | + | [attachments] | |||
| 1524 | + | ` | |||
| 1525 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1526 | + | require.NoError(t, err) | |||
| 1527 | + | ||||
| 1528 | + | v := viper.New() | |||
| 1529 | + | v.SetConfigFile(configFile) | |||
| 1530 | + | require.NoError(t, v.ReadInConfig()) | |||
| 1531 | + | ||||
| 1532 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1533 | + | svc := NewService(v, eventBus) | |||
| 1534 | + | ctx := testContext() | |||
| 1535 | + | require.NoError(t, svc.Load(ctx)) | |||
| 1536 | + | ||||
| 1537 | + | err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") | |||
| 1538 | + | require.NoError(t, err) | |||
| 1539 | + | ||||
| 1540 | + | require.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |||
| 1541 | + | ||||
| 1542 | + | saved := viper.New() | |||
| 1543 | + | saved.SetConfigFile(configFile) | |||
| 1544 | + | require.NoError(t, saved.ReadInConfig()) | |||
| 1545 | + | ||||
| 1546 | + | var attachments map[string][]string | |||
| 1547 | + | require.NoError(t, saved.UnmarshalKey("attachments", &attachments)) | |||
| 1548 | + | assert.Equal(t, []string{"registry.example.com/postgres:18"}, attachments["app.example.com"]) | |||
| 1549 | + | ||||
| 1550 | + | content, err := os.ReadFile(configFile) | |||
| 1551 | + | require.NoError(t, err) | |||
| 1552 | + | text := string(content) | |||
| 1553 | + | assert.Contains(t, text, `registry.example.com/postgres:18`) | |||
| 1554 | + | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | |||
| 1555 | + | }) | |||
| 1556 | + | ||||
| 1385 | t.Run("duplicate attachment", func(t *testing.T) { | 1557 | t.Run("duplicate attachment", func(t *testing.T) { | ||
| 1386 | v := viper.New() | 1558 | v := viper.New() | ||
| 1387 | v.Set("attachments", map[string]interface{}{ | 1559 | v.Set("attachments", map[string]interface{}{ | ||
| @@ -1700,23 +1872,25 @@ func TestSplitImageNameTag(t *testing.T) { | |||||
| 1700 | 1872 | ||||
| 1701 | func TestNormalizeRegistryImage(t *testing.T) { | 1873 | func TestNormalizeRegistryImage(t *testing.T) { | ||
| 1702 | tests := []struct { | 1874 | tests := []struct { | ||
| 1703 | - | name string | 1875 | + | name string |
| 1704 | - | imageName string | 1876 | + | imageName string |
| 1705 | - | registryDomain string | 1877 | + | registryDomain string |
| 1706 | - | expected string | 1878 | + | legacyRegistryDomains []string |
| 1879 | + | expected string | |||
| 1707 | }{ | 1880 | }{ | ||
| 1708 | - | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | 1881 | + | {"strips current registry prefix", "reg.example.com/myapp:latest", "reg.example.com", nil, "myapp:latest"}, |
| 1709 | - | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | 1882 | + | {"strips legacy registry prefix", "old-reg.example.com/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "myapp:latest"}, |
| 1710 | - | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | 1883 | + | {"strips legacy registry prefix with explicit port and digest", "old-reg.example.com:5000/myapp@sha256:deadbeef", "new-reg.example.com", []string{"old-reg.example.com:5000"}, "myapp@sha256:deadbeef"}, |
| 1711 | - | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | 1884 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", nil, "myapp:latest"}, |
| 1712 | - | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | 1885 | + | {"empty registry domain", "myapp:latest", "", nil, "myapp:latest"}, |
| 1713 | - | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | 1886 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", nil, "myapp:latest"}, |
| 1714 | - | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | 1887 | + | {"hostile lookalike is not stripped", "old-reg.example.com.evil/myapp:latest", "new-reg.example.com", []string{"old-reg.example.com"}, "old-reg.example.com.evil/myapp:latest"}, |
| 1888 | + | {"bare name no registry", "myapp", "reg.example.com", nil, "myapp"}, | |||
| 1715 | } | 1889 | } | ||
| 1716 | 1890 | ||||
| 1717 | for _, tt := range tests { | 1891 | for _, tt := range tests { | ||
| 1718 | t.Run(tt.name, func(t *testing.T) { | 1892 | t.Run(tt.name, func(t *testing.T) { | ||
| 1719 | - | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | 1893 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain, tt.legacyRegistryDomains) |
| 1720 | assert.Equal(t, tt.expected, result) | 1894 | assert.Equal(t, tt.expected, result) | ||
| 1721 | }) | 1895 | }) | ||
| 1722 | } | 1896 | } | ||
| @@ -1869,6 +2043,24 @@ func TestService_FindRoutesByImage(t *testing.T) { | |||||
| 1869 | assert.Equal(t, "app.example.com", routes[0].Domain) | 2043 | assert.Equal(t, "app.example.com", routes[0].Domain) | ||
| 1870 | }) | 2044 | }) | ||
| 1871 | 2045 | ||||
| 2046 | + | t.Run("matches current image when route stores legacy registry host", func(t *testing.T) { | |||
| 2047 | + | v := viper.New() | |||
| 2048 | + | v.Set("server.gordon_domain", "new-registry.example.com") | |||
| 2049 | + | v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) | |||
| 2050 | + | v.Set("routes", map[string]interface{}{ | |||
| 2051 | + | "app.example.com": "old-registry.example.com/myapp:latest", | |||
| 2052 | + | }) | |||
| 2053 | + | ||||
| 2054 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2055 | + | svc := NewService(v, eventBus) | |||
| 2056 | + | ctx := testContext() | |||
| 2057 | + | _ = svc.Load(ctx) | |||
| 2058 | + | ||||
| 2059 | + | routes := svc.FindRoutesByImage(ctx, "new-registry.example.com/myapp:latest") | |||
| 2060 | + | assert.Len(t, routes, 1) | |||
| 2061 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 2062 | + | }) | |||
| 2063 | + | ||||
| 1872 | t.Run("multiple routes for same image", func(t *testing.T) { | 2064 | t.Run("multiple routes for same image", func(t *testing.T) { | ||
| 1873 | v := viper.New() | 2065 | v := viper.New() | ||
| 1874 | v.Set("routes", map[string]interface{}{ | 2066 | v.Set("routes", map[string]interface{}{ | ||
| @@ -1980,6 +2172,23 @@ func TestService_FindAttachmentTargetsByImage(t *testing.T) { | |||||
| 1980 | assert.Equal(t, []string{"backend"}, targets) | 2172 | assert.Equal(t, []string{"backend"}, targets) | ||
| 1981 | }) | 2173 | }) | ||
| 1982 | 2174 | ||||
| 2175 | + | t.Run("matches current image when attachment stores legacy registry host", func(t *testing.T) { | |||
| 2176 | + | v := viper.New() | |||
| 2177 | + | v.Set("server.gordon_domain", "new-registry.example.com") | |||
| 2178 | + | v.Set("server.legacy_registry_domains", []string{"old-registry.example.com"}) | |||
| 2179 | + | v.Set("attachments", map[string]interface{}{ | |||
| 2180 | + | "backend": []interface{}{"old-registry.example.com/postgres:18"}, | |||
| 2181 | + | }) | |||
| 2182 | + | ||||
| 2183 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2184 | + | svc := NewService(v, eventBus) | |||
| 2185 | + | ctx := testContext() | |||
| 2186 | + | _ = svc.Load(ctx) | |||
| 2187 | + | ||||
| 2188 | + | targets := svc.FindAttachmentTargetsByImage(ctx, "new-registry.example.com/postgres:18") | |||
| 2189 | + | assert.Equal(t, []string{"backend"}, targets) | |||
| 2190 | + | }) | |||
| 2191 | + | ||||
| 1983 | t.Run("shared attachment used by multiple targets", func(t *testing.T) { | 2192 | t.Run("shared attachment used by multiple targets", func(t *testing.T) { | ||
| 1984 | v := viper.New() | 2193 | v := viper.New() | ||
| 1985 | v.Set("attachments", map[string]interface{}{ | 2194 | v.Set("attachments", map[string]interface{}{ | ||
M internal/usecase/container/autoroute.go +16 -13
| @@ -24,13 +24,14 @@ type EnvFileExtractor interface { | |||
| 24 | 24 | ||
| 25 | 25 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | |
| 26 | 26 | type AutoRouteHandler struct { | |
| 27 | - | configSvc in.ConfigService | |
| 28 | - | containerSvc in.ContainerService | |
| 29 | - | blobStorage out.BlobStorage | |
| 30 | - | extractor EnvFileExtractor | |
| 31 | - | registryDomain string | |
| 32 | - | envDir string | |
| 33 | - | ctx context.Context | |
| 27 | + | configSvc in.ConfigService | |
| 28 | + | containerSvc in.ContainerService | |
| 29 | + | blobStorage out.BlobStorage | |
| 30 | + | extractor EnvFileExtractor | |
| 31 | + | registryDomain string | |
| 32 | + | legacyRegistryDomains []string | |
| 33 | + | envDir string | |
| 34 | + | ctx context.Context | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | // NewAutoRouteHandler creates a new AutoRouteHandler. | |
| @@ -40,13 +41,15 @@ func NewAutoRouteHandler( | |||
| 40 | 41 | containerSvc in.ContainerService, | |
| 41 | 42 | blobStorage out.BlobStorage, | |
| 42 | 43 | registryDomain string, | |
| 44 | + | legacyRegistryDomains ...string, | |
| 43 | 45 | ) *AutoRouteHandler { | |
| 44 | 46 | return &AutoRouteHandler{ | |
| 45 | - | configSvc: configSvc, | |
| 46 | - | containerSvc: containerSvc, | |
| 47 | - | blobStorage: blobStorage, | |
| 48 | - | registryDomain: registryDomain, | |
| 49 | - | ctx: ctx, | |
| 47 | + | configSvc: configSvc, | |
| 48 | + | containerSvc: containerSvc, | |
| 49 | + | blobStorage: blobStorage, | |
| 50 | + | registryDomain: registryDomain, | |
| 51 | + | legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...), | |
| 52 | + | ctx: ctx, | |
| 50 | 53 | } | |
| 51 | 54 | } | |
| 52 | 55 | ||
| @@ -212,7 +215,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||
| 212 | 215 | for _, existing := range existingRoutes { | |
| 213 | 216 | if existing.Domain == routeDomain { | |
| 214 | 217 | route.HTTPS = existing.HTTPS | |
| 215 | - | if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) { | |
| 218 | + | if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) { | |
| 216 | 219 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | |
| 217 | 220 | return false | |
| 218 | 221 | } | |
| @@ -24,13 +24,14 @@ type EnvFileExtractor interface { | |||||
| 24 | 24 | ||||
| 25 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | 25 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | ||
| 26 | type AutoRouteHandler struct { | 26 | type AutoRouteHandler struct { | ||
| 27 | - | configSvc in.ConfigService | 27 | + | configSvc in.ConfigService |
| 28 | - | containerSvc in.ContainerService | 28 | + | containerSvc in.ContainerService |
| 29 | - | blobStorage out.BlobStorage | 29 | + | blobStorage out.BlobStorage |
| 30 | - | extractor EnvFileExtractor | 30 | + | extractor EnvFileExtractor |
| 31 | - | registryDomain string | 31 | + | registryDomain string |
| 32 | - | envDir string | 32 | + | legacyRegistryDomains []string |
| 33 | - | ctx context.Context | 33 | + | envDir string |
| 34 | + | ctx context.Context | |||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | // NewAutoRouteHandler creates a new AutoRouteHandler. | 37 | // NewAutoRouteHandler creates a new AutoRouteHandler. | ||
| @@ -40,13 +41,15 @@ func NewAutoRouteHandler( | |||||
| 40 | containerSvc in.ContainerService, | 41 | containerSvc in.ContainerService, | ||
| 41 | blobStorage out.BlobStorage, | 42 | blobStorage out.BlobStorage, | ||
| 42 | registryDomain string, | 43 | registryDomain string, | ||
| 44 | + | legacyRegistryDomains ...string, | |||
| 43 | ) *AutoRouteHandler { | 45 | ) *AutoRouteHandler { | ||
| 44 | return &AutoRouteHandler{ | 46 | return &AutoRouteHandler{ | ||
| 45 | - | configSvc: configSvc, | 47 | + | configSvc: configSvc, |
| 46 | - | containerSvc: containerSvc, | 48 | + | containerSvc: containerSvc, |
| 47 | - | blobStorage: blobStorage, | 49 | + | blobStorage: blobStorage, |
| 48 | - | registryDomain: registryDomain, | 50 | + | registryDomain: registryDomain, |
| 49 | - | ctx: ctx, | 51 | + | legacyRegistryDomains: append([]string(nil), legacyRegistryDomains...), |
| 52 | + | ctx: ctx, | |||
| 50 | } | 53 | } | ||
| 51 | } | 54 | } | ||
| 52 | 55 | ||||
| @@ -212,7 +215,7 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||||
| 212 | for _, existing := range existingRoutes { | 215 | for _, existing := range existingRoutes { | ||
| 213 | if existing.Domain == routeDomain { | 216 | if existing.Domain == routeDomain { | ||
| 214 | route.HTTPS = existing.HTTPS | 217 | route.HTTPS = existing.HTTPS | ||
| 215 | - | if auto.ExtractRepoName(existing.Image, h.registryDomain) != auto.ExtractRepoName(imageName, h.registryDomain) { | 218 | + | if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) { |
| 216 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | 219 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | ||
| 217 | return false | 220 | return false | ||
| 218 | } | 221 | } | ||
M internal/usecase/container/autoroute_test.go +44 -0
| @@ -829,6 +829,33 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) { | |||
| 829 | 829 | assert.False(t, created) | |
| 830 | 830 | } | |
| 831 | 831 | ||
| 832 | + | func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) { | |
| 833 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 834 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 835 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 836 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 837 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") | |
| 838 | + | ||
| 839 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |
| 840 | + | Domain: "app.example.com", | |
| 841 | + | Image: "old-registry.example.com/app:v1", | |
| 842 | + | HTTPS: true, | |
| 843 | + | }}) | |
| 844 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{ | |
| 845 | + | Domain: "app.example.com", | |
| 846 | + | Image: "new-registry.example.com/app:v2", | |
| 847 | + | HTTPS: true, | |
| 848 | + | }).Return(nil) | |
| 849 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |
| 850 | + | Domain: "app.example.com", | |
| 851 | + | Image: "new-registry.example.com/app:v2", | |
| 852 | + | HTTPS: true, | |
| 853 | + | }).Return(&domain.Container{ID: "c1"}, nil) | |
| 854 | + | ||
| 855 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) | |
| 856 | + | assert.False(t, created) | |
| 857 | + | } | |
| 858 | + | ||
| 832 | 859 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | |
| 833 | 860 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 834 | 861 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -842,6 +869,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | |||
| 842 | 869 | assert.False(t, created) | |
| 843 | 870 | } | |
| 844 | 871 | ||
| 872 | + | func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) { | |
| 873 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 874 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 875 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 876 | + | blobStorage := mocks.NewMockBlobStorage(t) | |
| 877 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") | |
| 878 | + | ||
| 879 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |
| 880 | + | Domain: "app.example.com", | |
| 881 | + | Image: "old-registry.example.com/oldapp:v1", | |
| 882 | + | HTTPS: true, | |
| 883 | + | }}) | |
| 884 | + | ||
| 885 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"}) | |
| 886 | + | assert.False(t, created) | |
| 887 | + | } | |
| 888 | + | ||
| 845 | 889 | func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) { | |
| 846 | 890 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 847 | 891 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -829,6 +829,33 @@ func TestAutoRouteHandler_ExistingDomain_SameRepo(t *testing.T) { | |||||
| 829 | assert.False(t, created) | 829 | assert.False(t, created) | ||
| 830 | } | 830 | } | ||
| 831 | 831 | ||||
| 832 | + | func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryHosts_UpdatesRoute(t *testing.T) { | |||
| 833 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 834 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 835 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 836 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 837 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") | |||
| 838 | + | ||||
| 839 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |||
| 840 | + | Domain: "app.example.com", | |||
| 841 | + | Image: "old-registry.example.com/app:v1", | |||
| 842 | + | HTTPS: true, | |||
| 843 | + | }}) | |||
| 844 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{ | |||
| 845 | + | Domain: "app.example.com", | |||
| 846 | + | Image: "new-registry.example.com/app:v2", | |||
| 847 | + | HTTPS: true, | |||
| 848 | + | }).Return(nil) | |||
| 849 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |||
| 850 | + | Domain: "app.example.com", | |||
| 851 | + | Image: "new-registry.example.com/app:v2", | |||
| 852 | + | HTTPS: true, | |||
| 853 | + | }).Return(&domain.Container{ID: "c1"}, nil) | |||
| 854 | + | ||||
| 855 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) | |||
| 856 | + | assert.False(t, created) | |||
| 857 | + | } | |||
| 858 | + | ||||
| 832 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | 859 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | ||
| 833 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | 860 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | ||
| 834 | configSvc := inmocks.NewMockConfigService(t) | 861 | configSvc := inmocks.NewMockConfigService(t) | ||
| @@ -842,6 +869,23 @@ func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | |||||
| 842 | assert.False(t, created) | 869 | assert.False(t, created) | ||
| 843 | } | 870 | } | ||
| 844 | 871 | ||||
| 872 | + | func TestAutoRouteHandler_ExistingDomain_DifferentRepoAcrossLegacyAndCurrentRegistryHosts_RejectsRoute(t *testing.T) { | |||
| 873 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 874 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 875 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 876 | + | blobStorage := mocks.NewMockBlobStorage(t) | |||
| 877 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, blobStorage, "new-registry.example.com", "old-registry.example.com") | |||
| 878 | + | ||||
| 879 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ | |||
| 880 | + | Domain: "app.example.com", | |||
| 881 | + | Image: "old-registry.example.com/oldapp:v1", | |||
| 882 | + | HTTPS: true, | |||
| 883 | + | }}) | |||
| 884 | + | ||||
| 885 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/newapp:v2", []string{"*.example.com"}) | |||
| 886 | + | assert.False(t, created) | |||
| 887 | + | } | |||
| 888 | + | ||||
| 845 | func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) { | 889 | func TestAutoRouteHandler_EnvExtractionOnlyOnCreate(t *testing.T) { | ||
| 846 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | 890 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | ||
| 847 | configSvc := inmocks.NewMockConfigService(t) | 891 | configSvc := inmocks.NewMockConfigService(t) | ||
M internal/usecase/container/service.go +17 -18
| @@ -33,6 +33,7 @@ import ( | |||
| 33 | 33 | type Config struct { | |
| 34 | 34 | RegistryAuthEnabled bool | |
| 35 | 35 | RegistryDomain string | |
| 36 | + | LegacyRegistryDomains []string | |
| 36 | 37 | RegistryPort int | |
| 37 | 38 | ServiceTokenUsername string | |
| 38 | 39 | ServiceToken string | |
| @@ -1340,14 +1341,7 @@ func (s *Service) stripRegistryPrefix(image string) string { | |||
| 1340 | 1341 | cfg := s.config | |
| 1341 | 1342 | s.mu.RUnlock() | |
| 1342 | 1343 | ||
| 1343 | - | if cfg.RegistryDomain == "" { | |
| 1344 | - | return image | |
| 1345 | - | } | |
| 1346 | - | prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/" | |
| 1347 | - | if strings.HasPrefix(image, prefix) { | |
| 1348 | - | return strings.TrimPrefix(image, prefix) | |
| 1349 | - | } | |
| 1350 | - | return image | |
| 1344 | + | return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains) | |
| 1351 | 1345 | } | |
| 1352 | 1346 | ||
| 1353 | 1347 | // ListAttachments returns attachments for a domain. | |
| @@ -1823,7 +1817,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist | |||
| 1823 | 1817 | return fmt.Errorf("image %q rejected: invalid image reference", imageRef) | |
| 1824 | 1818 | } | |
| 1825 | 1819 | ||
| 1826 | - | if sameRegistry(registry, cfg.RegistryDomain) { | |
| 1820 | + | if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) { | |
| 1827 | 1821 | return nil | |
| 1828 | 1822 | } | |
| 1829 | 1823 | dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry)) | |
| @@ -1975,7 +1969,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string | |||
| 1975 | 1969 | s.mu.RLock() | |
| 1976 | 1970 | cfg := s.config | |
| 1977 | 1971 | s.mu.RUnlock() | |
| 1978 | - | return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true | |
| 1972 | + | return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true | |
| 1979 | 1973 | } | |
| 1980 | 1974 | ||
| 1981 | 1975 | // ensureImage ensures the image is available locally, pulling if needed. | |
| @@ -3360,21 +3354,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string { | |||
| 3360 | 3354 | return prefix + imageRef | |
| 3361 | 3355 | } | |
| 3362 | 3356 | ||
| 3363 | - | // rewriteToLocalRegistry rewrites an image reference to use the local registry address. | |
| 3364 | - | // e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest" | |
| 3365 | - | func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string { | |
| 3357 | + | // rewriteToLocalRegistry rewrites Gordon-managed image references to use the | |
| 3358 | + | // local registry address. Current and legacy Gordon registry domains are | |
| 3359 | + | // stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed. | |
| 3360 | + | // External registries are preserved unchanged. | |
| 3361 | + | func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string { | |
| 3366 | 3362 | if imageRef == "" { | |
| 3367 | 3363 | return imageRef | |
| 3368 | 3364 | } | |
| 3369 | 3365 | ||
| 3370 | 3366 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | |
| 3371 | 3367 | localPrefix := localRegistry + "/" | |
| 3372 | - | imageRef = strings.TrimPrefix(imageRef, localPrefix) | |
| 3368 | + | if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok { | |
| 3369 | + | return localPrefix + remainder | |
| 3370 | + | } | |
| 3373 | 3371 | ||
| 3374 | - | registryDomain = strings.TrimSuffix(registryDomain, "/") | |
| 3375 | - | if registryDomain != "" { | |
| 3376 | - | prefix := registryDomain + "/" | |
| 3377 | - | imageRef = strings.TrimPrefix(imageRef, prefix) | |
| 3372 | + | if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { | |
| 3373 | + | return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains) | |
| 3374 | + | } | |
| 3375 | + | if hasExplicitRegistry(imageRef) { | |
| 3376 | + | return imageRef | |
| 3378 | 3377 | } | |
| 3379 | 3378 | ||
| 3380 | 3379 | return localPrefix + imageRef | |
| @@ -33,6 +33,7 @@ import ( | |||||
| 33 | type Config struct { | 33 | type Config struct { | ||
| 34 | RegistryAuthEnabled bool | 34 | RegistryAuthEnabled bool | ||
| 35 | RegistryDomain string | 35 | RegistryDomain string | ||
| 36 | + | LegacyRegistryDomains []string | |||
| 36 | RegistryPort int | 37 | RegistryPort int | ||
| 37 | ServiceTokenUsername string | 38 | ServiceTokenUsername string | ||
| 38 | ServiceToken string | 39 | ServiceToken string | ||
| @@ -1340,14 +1341,7 @@ func (s *Service) stripRegistryPrefix(image string) string { | |||||
| 1340 | cfg := s.config | 1341 | cfg := s.config | ||
| 1341 | s.mu.RUnlock() | 1342 | s.mu.RUnlock() | ||
| 1342 | 1343 | ||||
| 1343 | - | if cfg.RegistryDomain == "" { | 1344 | + | return domain.StripKnownGordonRegistry(image, cfg.RegistryDomain, cfg.LegacyRegistryDomains) |
| 1344 | - | return image | |||
| 1345 | - | } | |||
| 1346 | - | prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/" | |||
| 1347 | - | if strings.HasPrefix(image, prefix) { | |||
| 1348 | - | return strings.TrimPrefix(image, prefix) | |||
| 1349 | - | } | |||
| 1350 | - | return image | |||
| 1351 | } | 1345 | } | ||
| 1352 | 1346 | ||||
| 1353 | // ListAttachments returns attachments for a domain. | 1347 | // ListAttachments returns attachments for a domain. | ||
| @@ -1823,7 +1817,7 @@ func (s *Service) validateExternalImageRef(ctx context.Context, imageRef, regist | |||||
| 1823 | return fmt.Errorf("image %q rejected: invalid image reference", imageRef) | 1817 | return fmt.Errorf("image %q rejected: invalid image reference", imageRef) | ||
| 1824 | } | 1818 | } | ||
| 1825 | 1819 | ||||
| 1826 | - | if sameRegistry(registry, cfg.RegistryDomain) { | 1820 | + | if domain.IsGordonRegistryImageRef(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains) || sameRegistry(registry, cfg.RegistryDomain) { |
| 1827 | return nil | 1821 | return nil | ||
| 1828 | } | 1822 | } | ||
| 1829 | dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry)) | 1823 | dangerous, err := isDangerousRegistryHost(ctx, imageRegistryHost(registry)) | ||
| @@ -1975,7 +1969,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string | |||||
| 1975 | s.mu.RLock() | 1969 | s.mu.RLock() | ||
| 1976 | cfg := s.config | 1970 | cfg := s.config | ||
| 1977 | s.mu.RUnlock() | 1971 | s.mu.RUnlock() | ||
| 1978 | - | return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true | 1972 | + | return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.LegacyRegistryDomains, cfg.RegistryPort), true |
| 1979 | } | 1973 | } | ||
| 1980 | 1974 | ||||
| 1981 | // ensureImage ensures the image is available locally, pulling if needed. | 1975 | // ensureImage ensures the image is available locally, pulling if needed. | ||
| @@ -3360,21 +3354,26 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string { | |||||
| 3360 | return prefix + imageRef | 3354 | return prefix + imageRef | ||
| 3361 | } | 3355 | } | ||
| 3362 | 3356 | ||||
| 3363 | - | // rewriteToLocalRegistry rewrites an image reference to use the local registry address. | 3357 | + | // rewriteToLocalRegistry rewrites Gordon-managed image references to use the |
| 3364 | - | // e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest" | 3358 | + | // local registry address. Current and legacy Gordon registry domains are |
| 3365 | - | func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string { | 3359 | + | // stripped before prefixing localhost:<registryPort>/. Bare refs are prefixed. |
| 3360 | + | // External registries are preserved unchanged. | |||
| 3361 | + | func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomains []string, registryPort int) string { | |||
| 3366 | if imageRef == "" { | 3362 | if imageRef == "" { | ||
| 3367 | return imageRef | 3363 | return imageRef | ||
| 3368 | } | 3364 | } | ||
| 3369 | 3365 | ||||
| 3370 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | 3366 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | ||
| 3371 | localPrefix := localRegistry + "/" | 3367 | localPrefix := localRegistry + "/" | ||
| 3372 | - | imageRef = strings.TrimPrefix(imageRef, localPrefix) | 3368 | + | if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok { |
| 3369 | + | return localPrefix + remainder | |||
| 3370 | + | } | |||
| 3373 | 3371 | ||||
| 3374 | - | registryDomain = strings.TrimSuffix(registryDomain, "/") | 3372 | + | if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { |
| 3375 | - | if registryDomain != "" { | 3373 | + | return localPrefix + domain.StripKnownGordonRegistry(imageRef, registryDomain, legacyRegistryDomains) |
| 3376 | - | prefix := registryDomain + "/" | 3374 | + | } |
| 3377 | - | imageRef = strings.TrimPrefix(imageRef, prefix) | 3375 | + | if hasExplicitRegistry(imageRef) { |
| 3376 | + | return imageRef | |||
| 3378 | } | 3377 | } | ||
| 3379 | 3378 | ||||
| 3380 | return localPrefix + imageRef | 3379 | return localPrefix + imageRef | ||
M internal/usecase/container/service_test.go +111 -16
| @@ -1992,11 +1992,12 @@ func TestRewriteToRegistryDomain(t *testing.T) { | |||
| 1992 | 1992 | ||
| 1993 | 1993 | func TestRewriteToLocalRegistry(t *testing.T) { | |
| 1994 | 1994 | tests := []struct { | |
| 1995 | - | name string | |
| 1996 | - | imageRef string | |
| 1997 | - | registryDomain string | |
| 1998 | - | registryPort int | |
| 1999 | - | wantRef string | |
| 1995 | + | name string | |
| 1996 | + | imageRef string | |
| 1997 | + | registryDomain string | |
| 1998 | + | legacyRegistryDomains []string | |
| 1999 | + | registryPort int | |
| 2000 | + | wantRef string | |
| 2000 | 2001 | }{ | |
| 2001 | 2002 | { | |
| 2002 | 2003 | name: "rewrites registry domain prefix", | |
| @@ -2020,17 +2021,42 @@ func TestRewriteToLocalRegistry(t *testing.T) { | |||
| 2020 | 2021 | wantRef: "localhost:5000/myapp:latest", | |
| 2021 | 2022 | }, | |
| 2022 | 2023 | { | |
| 2023 | - | name: "prefixes external image path", | |
| 2024 | - | imageRef: "docker.io/library/nginx:latest", | |
| 2025 | - | registryDomain: "registry.example.com", | |
| 2026 | - | registryPort: 5000, | |
| 2027 | - | wantRef: "localhost:5000/docker.io/library/nginx:latest", | |
| 2024 | + | name: "rewrites legacy registry domain prefix", | |
| 2025 | + | imageRef: "old-registry.example.com/myapp:latest", | |
| 2026 | + | registryDomain: "new-registry.example.com", | |
| 2027 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, | |
| 2028 | + | registryPort: 5000, | |
| 2029 | + | wantRef: "localhost:5000/myapp:latest", | |
| 2030 | + | }, | |
| 2031 | + | { | |
| 2032 | + | name: "rewrites legacy registry domain prefix with explicit port and digest", | |
| 2033 | + | imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef", | |
| 2034 | + | registryDomain: "new-registry.example.com", | |
| 2035 | + | legacyRegistryDomains: []string{"old-registry.example.com:5001"}, | |
| 2036 | + | registryPort: 5000, | |
| 2037 | + | wantRef: "localhost:5000/myapp@sha256:deadbeef", | |
| 2038 | + | }, | |
| 2039 | + | { | |
| 2040 | + | name: "external image remains external", | |
| 2041 | + | imageRef: "docker.io/library/nginx:latest", | |
| 2042 | + | registryDomain: "new-registry.example.com", | |
| 2043 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, | |
| 2044 | + | registryPort: 5000, | |
| 2045 | + | wantRef: "docker.io/library/nginx:latest", | |
| 2046 | + | }, | |
| 2047 | + | { | |
| 2048 | + | name: "hostile lookalike remains external", | |
| 2049 | + | imageRef: "old-registry.example.com.evil/myapp:latest", | |
| 2050 | + | registryDomain: "new-registry.example.com", | |
| 2051 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, | |
| 2052 | + | registryPort: 5000, | |
| 2053 | + | wantRef: "old-registry.example.com.evil/myapp:latest", | |
| 2028 | 2054 | }, | |
| 2029 | 2055 | } | |
| 2030 | 2056 | ||
| 2031 | 2057 | for _, tt := range tests { | |
| 2032 | 2058 | t.Run(tt.name, func(t *testing.T) { | |
| 2033 | - | gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort) | |
| 2059 | + | gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort) | |
| 2034 | 2060 | assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference") | |
| 2035 | 2061 | }) | |
| 2036 | 2062 | } | |
| @@ -2116,6 +2142,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 2116 | 2142 | assert.Equal(t, "container-123", result.ID) | |
| 2117 | 2143 | } | |
| 2118 | 2144 | ||
| 2145 | + | func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) { | |
| 2146 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2147 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2148 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2149 | + | ||
| 2150 | + | config := Config{ | |
| 2151 | + | AllowedRegistries: []string{"docker.io"}, | |
| 2152 | + | RegistryAuthEnabled: true, | |
| 2153 | + | RegistryDomain: "new-registry.example.com", | |
| 2154 | + | LegacyRegistryDomains: []string{"old-registry.example.com"}, | |
| 2155 | + | RegistryPort: 5000, | |
| 2156 | + | InternalRegistryUsername: "internal", | |
| 2157 | + | InternalRegistryPassword: "secret", | |
| 2158 | + | ReadinessDelay: time.Millisecond, | |
| 2159 | + | DrainDelay: time.Millisecond, | |
| 2160 | + | DrainDelayConfigured: true, | |
| 2161 | + | } | |
| 2162 | + | ||
| 2163 | + | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | |
| 2164 | + | ctx := domain.WithInternalDeploy(testContext()) | |
| 2165 | + | ||
| 2166 | + | route := domain.Route{ | |
| 2167 | + | Domain: "test.example.com", | |
| 2168 | + | Image: "old-registry.example.com/myapp:latest", | |
| 2169 | + | } | |
| 2170 | + | ||
| 2171 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |
| 2172 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2173 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil) | |
| 2174 | + | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil) | |
| 2175 | + | runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |
| 2176 | + | ||
| 2177 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil) | |
| 2178 | + | runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil) | |
| 2179 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2180 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil) | |
| 2181 | + | ||
| 2182 | + | createdContainer := &domain.Container{ | |
| 2183 | + | ID: "container-legacy", | |
| 2184 | + | Name: "gordon-test.example.com", | |
| 2185 | + | Status: "created", | |
| 2186 | + | } | |
| 2187 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |
| 2188 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil) | |
| 2189 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2) | |
| 2190 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{ | |
| 2191 | + | ID: "container-legacy", | |
| 2192 | + | Name: "gordon-test.example.com", | |
| 2193 | + | Status: "running", | |
| 2194 | + | Ports: []int{8080}, | |
| 2195 | + | }, nil) | |
| 2196 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2197 | + | ||
| 2198 | + | result, err := svc.Deploy(ctx, route) | |
| 2199 | + | ||
| 2200 | + | assert.NoError(t, err) | |
| 2201 | + | assert.NotNil(t, result) | |
| 2202 | + | assert.Equal(t, "container-legacy", result.ID) | |
| 2203 | + | } | |
| 2204 | + | ||
| 2119 | 2205 | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |
| 2120 | 2206 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2121 | 2207 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -2609,10 +2695,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 2609 | 2695 | ||
| 2610 | 2696 | func TestService_StripRegistryPrefix(t *testing.T) { | |
| 2611 | 2697 | tests := []struct { | |
| 2612 | - | name string | |
| 2613 | - | registryDomain string | |
| 2614 | - | image string | |
| 2615 | - | expected string | |
| 2698 | + | name string | |
| 2699 | + | registryDomain string | |
| 2700 | + | legacyRegistryDomains []string | |
| 2701 | + | image string | |
| 2702 | + | expected string | |
| 2616 | 2703 | }{ | |
| 2617 | 2704 | { | |
| 2618 | 2705 | name: "strips registry prefix", | |
| @@ -2626,6 +2713,13 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||
| 2626 | 2713 | image: "reg.example.com/myapp:v1.0", | |
| 2627 | 2714 | expected: "myapp:v1.0", | |
| 2628 | 2715 | }, | |
| 2716 | + | { | |
| 2717 | + | name: "strips legacy registry prefix", | |
| 2718 | + | registryDomain: "new-reg.example.com", | |
| 2719 | + | legacyRegistryDomains: []string{"old-reg.example.com"}, | |
| 2720 | + | image: "old-reg.example.com/myapp:latest", | |
| 2721 | + | expected: "myapp:latest", | |
| 2722 | + | }, | |
| 2629 | 2723 | { | |
| 2630 | 2724 | name: "preserves image without registry prefix", | |
| 2631 | 2725 | registryDomain: "reg.example.com", | |
| @@ -2659,7 +2753,8 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||
| 2659 | 2753 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2660 | 2754 | ||
| 2661 | 2755 | config := Config{ | |
| 2662 | - | RegistryDomain: tt.registryDomain, | |
| 2756 | + | RegistryDomain: tt.registryDomain, | |
| 2757 | + | LegacyRegistryDomains: tt.legacyRegistryDomains, | |
| 2663 | 2758 | } | |
| 2664 | 2759 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | |
| 2665 | 2760 | ||
| @@ -1992,11 +1992,12 @@ func TestRewriteToRegistryDomain(t *testing.T) { | |||||
| 1992 | 1992 | ||||
| 1993 | func TestRewriteToLocalRegistry(t *testing.T) { | 1993 | func TestRewriteToLocalRegistry(t *testing.T) { | ||
| 1994 | tests := []struct { | 1994 | tests := []struct { | ||
| 1995 | - | name string | 1995 | + | name string |
| 1996 | - | imageRef string | 1996 | + | imageRef string |
| 1997 | - | registryDomain string | 1997 | + | registryDomain string |
| 1998 | - | registryPort int | 1998 | + | legacyRegistryDomains []string |
| 1999 | - | wantRef string | 1999 | + | registryPort int |
| 2000 | + | wantRef string | |||
| 2000 | }{ | 2001 | }{ | ||
| 2001 | { | 2002 | { | ||
| 2002 | name: "rewrites registry domain prefix", | 2003 | name: "rewrites registry domain prefix", | ||
| @@ -2020,17 +2021,42 @@ func TestRewriteToLocalRegistry(t *testing.T) { | |||||
| 2020 | wantRef: "localhost:5000/myapp:latest", | 2021 | wantRef: "localhost:5000/myapp:latest", | ||
| 2021 | }, | 2022 | }, | ||
| 2022 | { | 2023 | { | ||
| 2023 | - | name: "prefixes external image path", | 2024 | + | name: "rewrites legacy registry domain prefix", |
| 2024 | - | imageRef: "docker.io/library/nginx:latest", | 2025 | + | imageRef: "old-registry.example.com/myapp:latest", |
| 2025 | - | registryDomain: "registry.example.com", | 2026 | + | registryDomain: "new-registry.example.com", |
| 2026 | - | registryPort: 5000, | 2027 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, |
| 2027 | - | wantRef: "localhost:5000/docker.io/library/nginx:latest", | 2028 | + | registryPort: 5000, |
| 2029 | + | wantRef: "localhost:5000/myapp:latest", | |||
| 2030 | + | }, | |||
| 2031 | + | { | |||
| 2032 | + | name: "rewrites legacy registry domain prefix with explicit port and digest", | |||
| 2033 | + | imageRef: "old-registry.example.com:5001/myapp@sha256:deadbeef", | |||
| 2034 | + | registryDomain: "new-registry.example.com", | |||
| 2035 | + | legacyRegistryDomains: []string{"old-registry.example.com:5001"}, | |||
| 2036 | + | registryPort: 5000, | |||
| 2037 | + | wantRef: "localhost:5000/myapp@sha256:deadbeef", | |||
| 2038 | + | }, | |||
| 2039 | + | { | |||
| 2040 | + | name: "external image remains external", | |||
| 2041 | + | imageRef: "docker.io/library/nginx:latest", | |||
| 2042 | + | registryDomain: "new-registry.example.com", | |||
| 2043 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, | |||
| 2044 | + | registryPort: 5000, | |||
| 2045 | + | wantRef: "docker.io/library/nginx:latest", | |||
| 2046 | + | }, | |||
| 2047 | + | { | |||
| 2048 | + | name: "hostile lookalike remains external", | |||
| 2049 | + | imageRef: "old-registry.example.com.evil/myapp:latest", | |||
| 2050 | + | registryDomain: "new-registry.example.com", | |||
| 2051 | + | legacyRegistryDomains: []string{"old-registry.example.com"}, | |||
| 2052 | + | registryPort: 5000, | |||
| 2053 | + | wantRef: "old-registry.example.com.evil/myapp:latest", | |||
| 2028 | }, | 2054 | }, | ||
| 2029 | } | 2055 | } | ||
| 2030 | 2056 | ||||
| 2031 | for _, tt := range tests { | 2057 | for _, tt := range tests { | ||
| 2032 | t.Run(tt.name, func(t *testing.T) { | 2058 | t.Run(tt.name, func(t *testing.T) { | ||
| 2033 | - | gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort) | 2059 | + | gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.legacyRegistryDomains, tt.registryPort) |
| 2034 | assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference") | 2060 | assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference") | ||
| 2035 | }) | 2061 | }) | ||
| 2036 | } | 2062 | } | ||
| @@ -2116,6 +2142,66 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||||
| 2116 | assert.Equal(t, "container-123", result.ID) | 2142 | assert.Equal(t, "container-123", result.ID) | ||
| 2117 | } | 2143 | } | ||
| 2118 | 2144 | ||||
| 2145 | + | func TestService_Deploy_InternalDeployForcesPull_LegacyRegistryHost(t *testing.T) { | |||
| 2146 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2147 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2148 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2149 | + | ||||
| 2150 | + | config := Config{ | |||
| 2151 | + | AllowedRegistries: []string{"docker.io"}, | |||
| 2152 | + | RegistryAuthEnabled: true, | |||
| 2153 | + | RegistryDomain: "new-registry.example.com", | |||
| 2154 | + | LegacyRegistryDomains: []string{"old-registry.example.com"}, | |||
| 2155 | + | RegistryPort: 5000, | |||
| 2156 | + | InternalRegistryUsername: "internal", | |||
| 2157 | + | InternalRegistryPassword: "secret", | |||
| 2158 | + | ReadinessDelay: time.Millisecond, | |||
| 2159 | + | DrainDelay: time.Millisecond, | |||
| 2160 | + | DrainDelayConfigured: true, | |||
| 2161 | + | } | |||
| 2162 | + | ||||
| 2163 | + | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | |||
| 2164 | + | ctx := domain.WithInternalDeploy(testContext()) | |||
| 2165 | + | ||||
| 2166 | + | route := domain.Route{ | |||
| 2167 | + | Domain: "test.example.com", | |||
| 2168 | + | Image: "old-registry.example.com/myapp:latest", | |||
| 2169 | + | } | |||
| 2170 | + | ||||
| 2171 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil) | |||
| 2172 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2173 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").Return(nil) | |||
| 2174 | + | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "old-registry.example.com/myapp:latest").Return(nil) | |||
| 2175 | + | runtime.EXPECT().UntagImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |||
| 2176 | + | ||||
| 2177 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "old-registry.example.com/myapp:latest").Return([]int{8080}, nil) | |||
| 2178 | + | runtime.EXPECT().GetImageLabels(mock.Anything, "old-registry.example.com/myapp:latest").Return(nil, nil) | |||
| 2179 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2180 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "old-registry.example.com/myapp:latest").Return([]string{}, nil) | |||
| 2181 | + | ||||
| 2182 | + | createdContainer := &domain.Container{ | |||
| 2183 | + | ID: "container-legacy", | |||
| 2184 | + | Name: "gordon-test.example.com", | |||
| 2185 | + | Status: "created", | |||
| 2186 | + | } | |||
| 2187 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |||
| 2188 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-legacy").Return(nil) | |||
| 2189 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-legacy").Return(true, nil).Times(2) | |||
| 2190 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-legacy").Return(&domain.Container{ | |||
| 2191 | + | ID: "container-legacy", | |||
| 2192 | + | Name: "gordon-test.example.com", | |||
| 2193 | + | Status: "running", | |||
| 2194 | + | Ports: []int{8080}, | |||
| 2195 | + | }, nil) | |||
| 2196 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2197 | + | ||||
| 2198 | + | result, err := svc.Deploy(ctx, route) | |||
| 2199 | + | ||||
| 2200 | + | assert.NoError(t, err) | |||
| 2201 | + | assert.NotNil(t, result) | |||
| 2202 | + | assert.Equal(t, "container-legacy", result.ID) | |||
| 2203 | + | } | |||
| 2204 | + | ||||
| 2119 | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | 2205 | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | ||
| 2120 | runtime := mocks.NewMockContainerRuntime(t) | 2206 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 2121 | envLoader := mocks.NewMockEnvLoader(t) | 2207 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -2609,10 +2695,11 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||||
| 2609 | 2695 | ||||
| 2610 | func TestService_StripRegistryPrefix(t *testing.T) { | 2696 | func TestService_StripRegistryPrefix(t *testing.T) { | ||
| 2611 | tests := []struct { | 2697 | tests := []struct { | ||
| 2612 | - | name string | 2698 | + | name string |
| 2613 | - | registryDomain string | 2699 | + | registryDomain string |
| 2614 | - | image string | 2700 | + | legacyRegistryDomains []string |
| 2615 | - | expected string | 2701 | + | image string |
| 2702 | + | expected string | |||
| 2616 | }{ | 2703 | }{ | ||
| 2617 | { | 2704 | { | ||
| 2618 | name: "strips registry prefix", | 2705 | name: "strips registry prefix", | ||
| @@ -2626,6 +2713,13 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||||
| 2626 | image: "reg.example.com/myapp:v1.0", | 2713 | image: "reg.example.com/myapp:v1.0", | ||
| 2627 | expected: "myapp:v1.0", | 2714 | expected: "myapp:v1.0", | ||
| 2628 | }, | 2715 | }, | ||
| 2716 | + | { | |||
| 2717 | + | name: "strips legacy registry prefix", | |||
| 2718 | + | registryDomain: "new-reg.example.com", | |||
| 2719 | + | legacyRegistryDomains: []string{"old-reg.example.com"}, | |||
| 2720 | + | image: "old-reg.example.com/myapp:latest", | |||
| 2721 | + | expected: "myapp:latest", | |||
| 2722 | + | }, | |||
| 2629 | { | 2723 | { | ||
| 2630 | name: "preserves image without registry prefix", | 2724 | name: "preserves image without registry prefix", | ||
| 2631 | registryDomain: "reg.example.com", | 2725 | registryDomain: "reg.example.com", | ||
| @@ -2659,7 +2753,8 @@ func TestService_StripRegistryPrefix(t *testing.T) { | |||||
| 2659 | eventBus := mocks.NewMockEventPublisher(t) | 2753 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 2660 | 2754 | ||||
| 2661 | config := Config{ | 2755 | config := Config{ | ||
| 2662 | - | RegistryDomain: tt.registryDomain, | 2756 | + | RegistryDomain: tt.registryDomain, |
| 2757 | + | LegacyRegistryDomains: tt.legacyRegistryDomains, | |||
| 2663 | } | 2758 | } | ||
| 2664 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | 2759 | svc := NewService(runtime, envLoader, eventBus, nil, config, nil) | ||
| 2665 | 2760 | ||||