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