Merge pull request #60 from bnema/review/flatten-v2.11.0
Flatten post-v2.11.0 fixes for review
39 files · +1721 -270
M docs/cli/index.md +13 -2
| @@ -109,6 +109,7 @@ gordon remotes use prod | |||
| 109 | 109 | | `-c, --config` | Path to configuration file | | |
| 110 | 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | |
| 111 | 111 | | `--token` | Authentication token for remote | | |
| 112 | + | | `--insecure` | Skip TLS certificate verification for remote HTTPS endpoints | | |
| 112 | 113 | ||
| 113 | 114 | ### Remote Targeting | |
| 114 | 115 | ||
| @@ -118,13 +119,23 @@ when you want to bypass your saved configuration. | |||
| 118 | 119 | ||
| 119 | 120 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | |
| 120 | 121 | ||
| 122 | + | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |
| 123 | + | You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml` | |
| 124 | + | or in a specific entry in `~/.config/gordon/remotes.toml`. | |
| 125 | + | For Tailscale setups, you can also avoid `--insecure` by using the machine `*.ts.net` name with Tailscale-issued TLS certs in Gordon server config. | |
| 126 | + | Your public app domains can still use wildcard DNS and normal reverse-proxy routing. | |
| 127 | + | ||
| 121 | 128 | ```bash | |
| 122 | 129 | # Using flags (use the gordon_domain from remote Gordon config) | |
| 123 | - | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |
| 130 | + | gordon routes list --remote https://gordon.example.com --token $TOKEN | |
| 131 | + | ||
| 132 | + | # Against self-signed/private CA endpoint | |
| 133 | + | gordon --remote https://gordon.example.com --token $TOKEN --insecure status | |
| 124 | 134 | ||
| 125 | 135 | # Using environment variables | |
| 126 | - | export GORDON_REMOTE=https://gordon.mydomain.com | |
| 136 | + | export GORDON_REMOTE=https://gordon.example.com | |
| 127 | 137 | export GORDON_TOKEN=$TOKEN | |
| 138 | + | export GORDON_INSECURE=true | |
| 128 | 139 | gordon routes list | |
| 129 | 140 | ``` | |
| 130 | 141 | ||
| @@ -109,6 +109,7 @@ gordon remotes use prod | |||||
| 109 | | `-c, --config` | Path to configuration file | | 109 | | `-c, --config` | Path to configuration file | | ||
| 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | 110 | | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) | | ||
| 111 | | `--token` | Authentication token for remote | | 111 | | `--token` | Authentication token for remote | | ||
| 112 | + | | `--insecure` | Skip TLS certificate verification for remote HTTPS endpoints | | |||
| 112 | 113 | ||||
| 113 | ### Remote Targeting | 114 | ### Remote Targeting | ||
| 114 | 115 | ||||
| @@ -118,13 +119,23 @@ when you want to bypass your saved configuration. | |||||
| 118 | 119 | ||||
| 119 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | 120 | **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API. | ||
| 120 | 121 | ||||
| 122 | + | Use `--insecure` when the remote endpoint uses a self-signed or otherwise untrusted TLS certificate. | |||
| 123 | + | You can make this persistent with `insecure_tls = true` in `[client]` of `~/.config/gordon/gordon.toml` | |||
| 124 | + | or in a specific entry in `~/.config/gordon/remotes.toml`. | |||
| 125 | + | For Tailscale setups, you can also avoid `--insecure` by using the machine `*.ts.net` name with Tailscale-issued TLS certs in Gordon server config. | |||
| 126 | + | Your public app domains can still use wildcard DNS and normal reverse-proxy routing. | |||
| 127 | + | ||||
| 121 | ```bash | 128 | ```bash | ||
| 122 | # Using flags (use the gordon_domain from remote Gordon config) | 129 | # Using flags (use the gordon_domain from remote Gordon config) | ||
| 123 | - | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | 130 | + | gordon routes list --remote https://gordon.example.com --token $TOKEN |
| 131 | + | ||||
| 132 | + | # Against self-signed/private CA endpoint | |||
| 133 | + | gordon --remote https://gordon.example.com --token $TOKEN --insecure status | |||
| 124 | 134 | ||||
| 125 | # Using environment variables | 135 | # Using environment variables | ||
| 126 | - | export GORDON_REMOTE=https://gordon.mydomain.com | 136 | + | export GORDON_REMOTE=https://gordon.example.com |
| 127 | export GORDON_TOKEN=$TOKEN | 137 | export GORDON_TOKEN=$TOKEN | ||
| 138 | + | export GORDON_INSECURE=true | |||
| 128 | gordon routes list | 139 | gordon routes list | ||
| 129 | ``` | 140 | ``` | ||
| 130 | 141 | ||||
M docs/cli/push.md +7 -2
| @@ -21,6 +21,7 @@ gordon push <domain> [options] | |||
| 21 | 21 | | Option | Description | | |
| 22 | 22 | |--------|-------------| | |
| 23 | 23 | | `--build` | Build the image first using `docker buildx` | | |
| 24 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |
| 24 | 25 | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | |
| 25 | 26 | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | |
| 26 | 27 | | `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) | | |
| @@ -37,8 +38,9 @@ Gordon registry, pushes it, and optionally deploys it. | |||
| 37 | 38 | - The registry and repository are derived from the route image on the server. | |
| 38 | 39 | - The version tag defaults to the latest git tag. If no tag is found, `latest` | |
| 39 | 40 | is used. | |
| 40 | - | - When `--build` is set, the command uses `docker buildx build --push` and | |
| 41 | - | injects `VERSION=<tag>` plus any `--build-arg` values. | |
| 41 | + | - When `--build` is set, the command builds with `docker buildx build --load` | |
| 42 | + | and injects `VERSION=<tag>` plus any `--build-arg` values. | |
| 43 | + | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |
| 42 | 44 | - The version tag and `latest` are both pushed (unless the version is `latest`). | |
| 43 | 45 | ||
| 44 | 46 | ### Examples | |
| @@ -55,6 +57,9 @@ gordon push myapp.example.com --no-confirm | |||
| 55 | 57 | ||
| 56 | 58 | # Build for ARM and pass build args | |
| 57 | 59 | gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | |
| 60 | + | ||
| 61 | + | # Build from a custom Dockerfile path | |
| 62 | + | gordon push myapp.example.com --build -f docker/app/Dockerfile | |
| 58 | 63 | ``` | |
| 59 | 64 | ||
| 60 | 65 | ### Notes | |
| @@ -21,6 +21,7 @@ gordon push <domain> [options] | |||||
| 21 | | Option | Description | | 21 | | Option | Description | | ||
| 22 | |--------|-------------| | 22 | |--------|-------------| | ||
| 23 | | `--build` | Build the image first using `docker buildx` | | 23 | | `--build` | Build the image first using `docker buildx` | | ||
| 24 | + | | `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) | | |||
| 24 | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | 25 | | `--platform` | Target platform for buildx (default: `linux/amd64`) | | ||
| 25 | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | 26 | | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) | | ||
| 26 | | `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) | | 27 | | `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) | | ||
| @@ -37,8 +38,9 @@ Gordon registry, pushes it, and optionally deploys it. | |||||
| 37 | - The registry and repository are derived from the route image on the server. | 38 | - The registry and repository are derived from the route image on the server. | ||
| 38 | - The version tag defaults to the latest git tag. If no tag is found, `latest` | 39 | - The version tag defaults to the latest git tag. If no tag is found, `latest` | ||
| 39 | is used. | 40 | is used. | ||
| 40 | - | - When `--build` is set, the command uses `docker buildx build --push` and | 41 | + | - When `--build` is set, the command builds with `docker buildx build --load` |
| 41 | - | injects `VERSION=<tag>` plus any `--build-arg` values. | 42 | + | and injects `VERSION=<tag>` plus any `--build-arg` values. |
| 43 | + | - Use `-f/--file` to build from a Dockerfile outside the current directory root. | |||
| 42 | - The version tag and `latest` are both pushed (unless the version is `latest`). | 44 | - The version tag and `latest` are both pushed (unless the version is `latest`). | ||
| 43 | 45 | ||||
| 44 | ### Examples | 46 | ### Examples | ||
| @@ -55,6 +57,9 @@ gordon push myapp.example.com --no-confirm | |||||
| 55 | 57 | ||||
| 56 | # Build for ARM and pass build args | 58 | # Build for ARM and pass build args | ||
| 57 | gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | 59 | gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0 | ||
| 60 | + | ||||
| 61 | + | # Build from a custom Dockerfile path | |||
| 62 | + | gordon push myapp.example.com --build -f docker/app/Dockerfile | |||
| 58 | ``` | 63 | ``` | ||
| 59 | 64 | ||||
| 60 | ### Notes | 65 | ### Notes | ||
M docs/cli/remotes.md +43 -0
| @@ -222,6 +222,7 @@ active = "prod" | |||
| 222 | 222 | [remotes.prod] | |
| 223 | 223 | url = "https://gordon.mydomain.com" | |
| 224 | 224 | token_env = "PROD_TOKEN" | |
| 225 | + | insecure_tls = true | |
| 225 | 226 | ||
| 226 | 227 | [remotes.staging] | |
| 227 | 228 | url = "https://staging.mydomain.com" | |
| @@ -242,6 +243,12 @@ When multiple sources specify remote or token, the CLI uses this priority: | |||
| 242 | 243 | 2. `GORDON_TOKEN` environment variable | |
| 243 | 244 | 3. Token from active remote in `remotes.toml` | |
| 244 | 245 | ||
| 246 | + | **Insecure TLS:** | |
| 247 | + | 1. `--insecure` flag | |
| 248 | + | 2. `GORDON_INSECURE` environment variable (`true`/`false`) | |
| 249 | + | 3. `[client] insecure_tls` in `gordon.toml` | |
| 250 | + | 4. `insecure_tls` from the selected remote in `remotes.toml` | |
| 251 | + | ||
| 245 | 252 | This allows overriding specific values while keeping defaults: | |
| 246 | 253 | ||
| 247 | 254 | ```bash | |
| @@ -297,6 +304,42 @@ gordon remotes use prod && gordon routes list | |||
| 297 | 304 | gordon remotes use staging && gordon routes list | |
| 298 | 305 | ``` | |
| 299 | 306 | ||
| 307 | + | ### Private Admin + Wildcard App Domains | |
| 308 | + | ||
| 309 | + | Recommended default: use your normal Gordon admin domain with a valid public certificate. | |
| 310 | + | ||
| 311 | + | ```toml | |
| 312 | + | # ~/.config/gordon/remotes.toml | |
| 313 | + | active = "prod" | |
| 314 | + | ||
| 315 | + | [remotes.prod] | |
| 316 | + | url = "https://gordon.example.com" | |
| 317 | + | token_env = "GORDON_TOKEN" | |
| 318 | + | ``` | |
| 319 | + | ||
| 320 | + | If your admin/API path is private over Tailscale, you have two options: | |
| 321 | + | ||
| 322 | + | 1. Use `insecure_tls = true` for that remote. | |
| 323 | + | 2. Prefer a Tailscale-issued cert on the machine's `*.ts.net` name and keep verification enabled. | |
| 324 | + | ||
| 325 | + | Tailscale certificates are issued for tailnet MagicDNS names (for example `host-name.tailnet.ts.net`), not your custom domain. Enable HTTPS certificates in the Tailscale admin DNS settings, then generate files on the host: | |
| 326 | + | ||
| 327 | + | ```bash | |
| 328 | + | sudo tailscale cert --cert-file /etc/gordon/tls/cert.pem --key-file /etc/gordon/tls/key.pem host-name.tailnet.ts.net | |
| 329 | + | ``` | |
| 330 | + | ||
| 331 | + | Configure Gordon to use them: | |
| 332 | + | ||
| 333 | + | ```toml | |
| 334 | + | [server] | |
| 335 | + | tls_enabled = true | |
| 336 | + | tls_port = 443 | |
| 337 | + | tls_cert_file = "/etc/gordon/tls/cert.pem" | |
| 338 | + | tls_key_file = "/etc/gordon/tls/key.pem" | |
| 339 | + | ``` | |
| 340 | + | ||
| 341 | + | `insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`. | |
| 342 | + | ||
| 300 | 343 | ## Related | |
| 301 | 344 | ||
| 302 | 345 | - [CLI Overview](./index.md) | |
| @@ -222,6 +222,7 @@ active = "prod" | |||||
| 222 | [remotes.prod] | 222 | [remotes.prod] | ||
| 223 | url = "https://gordon.mydomain.com" | 223 | url = "https://gordon.mydomain.com" | ||
| 224 | token_env = "PROD_TOKEN" | 224 | token_env = "PROD_TOKEN" | ||
| 225 | + | insecure_tls = true | |||
| 225 | 226 | ||||
| 226 | [remotes.staging] | 227 | [remotes.staging] | ||
| 227 | url = "https://staging.mydomain.com" | 228 | url = "https://staging.mydomain.com" | ||
| @@ -242,6 +243,12 @@ When multiple sources specify remote or token, the CLI uses this priority: | |||||
| 242 | 2. `GORDON_TOKEN` environment variable | 243 | 2. `GORDON_TOKEN` environment variable | ||
| 243 | 3. Token from active remote in `remotes.toml` | 244 | 3. Token from active remote in `remotes.toml` | ||
| 244 | 245 | ||||
| 246 | + | **Insecure TLS:** | |||
| 247 | + | 1. `--insecure` flag | |||
| 248 | + | 2. `GORDON_INSECURE` environment variable (`true`/`false`) | |||
| 249 | + | 3. `[client] insecure_tls` in `gordon.toml` | |||
| 250 | + | 4. `insecure_tls` from the selected remote in `remotes.toml` | |||
| 251 | + | ||||
| 245 | This allows overriding specific values while keeping defaults: | 252 | This allows overriding specific values while keeping defaults: | ||
| 246 | 253 | ||||
| 247 | ```bash | 254 | ```bash | ||
| @@ -297,6 +304,42 @@ gordon remotes use prod && gordon routes list | |||||
| 297 | gordon remotes use staging && gordon routes list | 304 | gordon remotes use staging && gordon routes list | ||
| 298 | ``` | 305 | ``` | ||
| 299 | 306 | ||||
| 307 | + | ### Private Admin + Wildcard App Domains | |||
| 308 | + | ||||
| 309 | + | Recommended default: use your normal Gordon admin domain with a valid public certificate. | |||
| 310 | + | ||||
| 311 | + | ```toml | |||
| 312 | + | # ~/.config/gordon/remotes.toml | |||
| 313 | + | active = "prod" | |||
| 314 | + | ||||
| 315 | + | [remotes.prod] | |||
| 316 | + | url = "https://gordon.example.com" | |||
| 317 | + | token_env = "GORDON_TOKEN" | |||
| 318 | + | ``` | |||
| 319 | + | ||||
| 320 | + | If your admin/API path is private over Tailscale, you have two options: | |||
| 321 | + | ||||
| 322 | + | 1. Use `insecure_tls = true` for that remote. | |||
| 323 | + | 2. Prefer a Tailscale-issued cert on the machine's `*.ts.net` name and keep verification enabled. | |||
| 324 | + | ||||
| 325 | + | Tailscale certificates are issued for tailnet MagicDNS names (for example `host-name.tailnet.ts.net`), not your custom domain. Enable HTTPS certificates in the Tailscale admin DNS settings, then generate files on the host: | |||
| 326 | + | ||||
| 327 | + | ```bash | |||
| 328 | + | sudo tailscale cert --cert-file /etc/gordon/tls/cert.pem --key-file /etc/gordon/tls/key.pem host-name.tailnet.ts.net | |||
| 329 | + | ``` | |||
| 330 | + | ||||
| 331 | + | Configure Gordon to use them: | |||
| 332 | + | ||||
| 333 | + | ```toml | |||
| 334 | + | [server] | |||
| 335 | + | tls_enabled = true | |||
| 336 | + | tls_port = 443 | |||
| 337 | + | tls_cert_file = "/etc/gordon/tls/cert.pem" | |||
| 338 | + | tls_key_file = "/etc/gordon/tls/key.pem" | |||
| 339 | + | ``` | |||
| 340 | + | ||||
| 341 | + | `insecure_tls` only affects CLI -> Gordon admin HTTPS verification. It does not change runtime routing: Gordon reverse proxy and container routes can still serve wildcard app domains like `*.example.com`. | |||
| 342 | + | ||||
| 300 | ## Related | 343 | ## Related | ||
| 301 | 344 | ||||
| 302 | - [CLI Overview](./index.md) | 345 | - [CLI Overview](./index.md) | ||
M docs/cli/restart.md +3 -1
| @@ -44,7 +44,9 @@ gordon restart myapp.example.com --with-attachments | |||
| 44 | 44 | ||
| 45 | 45 | ### Notes | |
| 46 | 46 | ||
| 47 | - | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 47 | + | - Local mode works when run on the Gordon host. It uses the same deploy-signal path as `gordon deploy <domain>`. | |
| 48 | + | - `--with-attachments` is only supported in remote mode. | |
| 49 | + | - In remote mode, target your Gordon admin endpoint (for example `https://gordon.example.com`), not the registry host (for example `https://reg.example.com`). | |
| 48 | 50 | ||
| 49 | 51 | ## Related | |
| 50 | 52 | ||
| @@ -44,7 +44,9 @@ gordon restart myapp.example.com --with-attachments | |||||
| 44 | 44 | ||||
| 45 | ### Notes | 45 | ### Notes | ||
| 46 | 46 | ||||
| 47 | - | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | 47 | + | - Local mode works when run on the Gordon host. It uses the same deploy-signal path as `gordon deploy <domain>`. |
| 48 | + | - `--with-attachments` is only supported in remote mode. | |||
| 49 | + | - In remote mode, target your Gordon admin endpoint (for example `https://gordon.example.com`), not the registry host (for example `https://reg.example.com`). | |||
| 48 | 50 | ||||
| 49 | ## Related | 51 | ## Related | ||
| 50 | 52 | ||||
M docs/config/deploy.md +13 -0
| @@ -7,6 +7,7 @@ Controls how Gordon pulls images during deployment. | |||
| 7 | 7 | ```toml | |
| 8 | 8 | [deploy] | |
| 9 | 9 | pull_policy = "if-tag-changed" | |
| 10 | + | readiness_delay = "5s" | |
| 10 | 11 | ``` | |
| 11 | 12 | ||
| 12 | 13 | ## Settings | |
| @@ -21,6 +22,18 @@ How to decide when to pull an image: | |||
| 21 | 22 | ||
| 22 | 23 | Default: `if-tag-changed` | |
| 23 | 24 | ||
| 25 | + | ### `deploy.readiness_delay` | |
| 26 | + | ||
| 27 | + | How long Gordon waits after a container first reports `running` before it is | |
| 28 | + | considered ready for traffic. | |
| 29 | + | ||
| 30 | + | - Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`). | |
| 31 | + | - If the container briefly exits right after this delay, Gordon now waits up to | |
| 32 | + | 30 additional seconds for it to recover before failing the deploy. | |
| 33 | + | ||
| 34 | + | Default: `"5s"` | |
| 35 | + | ||
| 24 | 36 | ## Notes | |
| 25 | 37 | ||
| 26 | 38 | - Changes to `deploy.pull_policy` require a restart to take effect. | |
| 39 | + | - Changes to `deploy.readiness_delay` require a restart to take effect. | |
| @@ -7,6 +7,7 @@ Controls how Gordon pulls images during deployment. | |||||
| 7 | ```toml | 7 | ```toml | ||
| 8 | [deploy] | 8 | [deploy] | ||
| 9 | pull_policy = "if-tag-changed" | 9 | pull_policy = "if-tag-changed" | ||
| 10 | + | readiness_delay = "5s" | |||
| 10 | ``` | 11 | ``` | ||
| 11 | 12 | ||||
| 12 | ## Settings | 13 | ## Settings | ||
| @@ -21,6 +22,18 @@ How to decide when to pull an image: | |||||
| 21 | 22 | ||||
| 22 | Default: `if-tag-changed` | 23 | Default: `if-tag-changed` | ||
| 23 | 24 | ||||
| 25 | + | ### `deploy.readiness_delay` | |||
| 26 | + | ||||
| 27 | + | How long Gordon waits after a container first reports `running` before it is | |||
| 28 | + | considered ready for traffic. | |||
| 29 | + | ||||
| 30 | + | - Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`). | |||
| 31 | + | - If the container briefly exits right after this delay, Gordon now waits up to | |||
| 32 | + | 30 additional seconds for it to recover before failing the deploy. | |||
| 33 | + | ||||
| 34 | + | Default: `"5s"` | |||
| 35 | + | ||||
| 24 | ## Notes | 36 | ## Notes | ||
| 25 | 37 | ||||
| 26 | - Changes to `deploy.pull_policy` require a restart to take effect. | 38 | - Changes to `deploy.pull_policy` require a restart to take effect. | ||
| 39 | + | - Changes to `deploy.readiness_delay` require a restart to take effect. | |||
M docs/config/index.md +2 -0
| @@ -145,6 +145,7 @@ enabled = false # Auto-create routes from image names | |||
| 145 | 145 | | `api.rate_limit.burst` | `100` | | |
| 146 | 146 | | `api.rate_limit.trusted_proxies` | `[]` | | |
| 147 | 147 | | `deploy.pull_policy` | `"if-tag-changed"` | | |
| 148 | + | | `deploy.readiness_delay` | `"5s"` | | |
| 148 | 149 | | `logging.level` | `"info"` | | |
| 149 | 150 | | `logging.format` | `"console"` | | |
| 150 | 151 | | `logging.file.enabled` | `false` | | |
| @@ -173,6 +174,7 @@ The following settings require a restart to take effect: | |||
| 173 | 174 | - `server.data_dir` | |
| 174 | 175 | - `auth` settings | |
| 175 | 176 | - `deploy.pull_policy` | |
| 177 | + | - `deploy.readiness_delay` | |
| 176 | 178 | ||
| 177 | 179 | ## Environment Variable Override | |
| 178 | 180 | ||
| @@ -145,6 +145,7 @@ enabled = false # Auto-create routes from image names | |||||
| 145 | | `api.rate_limit.burst` | `100` | | 145 | | `api.rate_limit.burst` | `100` | | ||
| 146 | | `api.rate_limit.trusted_proxies` | `[]` | | 146 | | `api.rate_limit.trusted_proxies` | `[]` | | ||
| 147 | | `deploy.pull_policy` | `"if-tag-changed"` | | 147 | | `deploy.pull_policy` | `"if-tag-changed"` | | ||
| 148 | + | | `deploy.readiness_delay` | `"5s"` | | |||
| 148 | | `logging.level` | `"info"` | | 149 | | `logging.level` | `"info"` | | ||
| 149 | | `logging.format` | `"console"` | | 150 | | `logging.format` | `"console"` | | ||
| 150 | | `logging.file.enabled` | `false` | | 151 | | `logging.file.enabled` | `false` | | ||
| @@ -173,6 +174,7 @@ The following settings require a restart to take effect: | |||||
| 173 | - `server.data_dir` | 174 | - `server.data_dir` | ||
| 174 | - `auth` settings | 175 | - `auth` settings | ||
| 175 | - `deploy.pull_policy` | 176 | - `deploy.pull_policy` | ||
| 177 | + | - `deploy.readiness_delay` | |||
| 176 | 178 | ||||
| 177 | ## Environment Variable Override | 179 | ## Environment Variable Override | ||
| 178 | 180 | ||||
M docs/config/reference.md +3 -0
| @@ -13,6 +13,7 @@ port = 80 # HTTP proxy port | |||
| 13 | 13 | registry_port = 5000 # Container registry port | |
| 14 | 14 | gordon_domain = "" # Required: Gordon domain (registry + API) | |
| 15 | 15 | data_dir = "~/.gordon" # Data directory (varies by install type) | |
| 16 | + | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |
| 16 | 17 | ||
| 17 | 18 | # ============================================================================= | |
| 18 | 19 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | |
| @@ -125,6 +126,7 @@ preserve = true # Keep volumes when containers are | |||
| 125 | 126 | | `server.registry_port` | `5000` | Container registry port | | |
| 126 | 127 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | |
| 127 | 128 | | `server.data_dir` | `~/.gordon` | Data directory | | |
| 129 | + | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |
| 128 | 130 | | `auth.enabled` | `true` | Enable authentication | | |
| 129 | 131 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 130 | 132 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -144,6 +146,7 @@ preserve = true # Keep volumes when containers are | |||
| 144 | 146 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | |
| 145 | 147 | | `logging.container_logs.max_age` | `28` | 28 days | | |
| 146 | 148 | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | |
| 149 | + | | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready | | |
| 147 | 150 | | `auto_route.enabled` | `false` | Auto-route disabled | | |
| 148 | 151 | | `network_isolation.enabled` | `false` | Isolation disabled | | |
| 149 | 152 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | |
| @@ -13,6 +13,7 @@ port = 80 # HTTP proxy port | |||||
| 13 | registry_port = 5000 # Container registry port | 13 | registry_port = 5000 # Container registry port | ||
| 14 | gordon_domain = "" # Required: Gordon domain (registry + API) | 14 | gordon_domain = "" # Required: Gordon domain (registry + API) | ||
| 15 | data_dir = "~/.gordon" # Data directory (varies by install type) | 15 | data_dir = "~/.gordon" # Data directory (varies by install type) | ||
| 16 | + | max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk | |||
| 16 | 17 | ||||
| 17 | # ============================================================================= | 18 | # ============================================================================= | ||
| 18 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | 19 | # AUTHENTICATION (required - Gordon won't start without credentials configured) | ||
| @@ -125,6 +126,7 @@ preserve = true # Keep volumes when containers are | |||||
| 125 | | `server.registry_port` | `5000` | Container registry port | | 126 | | `server.registry_port` | `5000` | Container registry port | | ||
| 126 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | 127 | | `server.gordon_domain` | `""` | **Required** - Gordon domain | | ||
| 127 | | `server.data_dir` | `~/.gordon` | Data directory | | 128 | | `server.data_dir` | `~/.gordon` | Data directory | | ||
| 129 | + | | `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk | | |||
| 128 | | `auth.enabled` | `true` | Enable authentication | | 130 | | `auth.enabled` | `true` | Enable authentication | | ||
| 129 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 131 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 130 | | `auth.token_expiry` | `"720h"` | 30 days | | 132 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
| @@ -144,6 +146,7 @@ preserve = true # Keep volumes when containers are | |||||
| 144 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | 146 | | `logging.container_logs.max_backups` | `3` | Keep 3 old files | | ||
| 145 | | `logging.container_logs.max_age` | `28` | 28 days | | 147 | | `logging.container_logs.max_age` | `28` | 28 days | | ||
| 146 | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | 148 | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | ||
| 149 | + | | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready | | |||
| 147 | | `auto_route.enabled` | `false` | Auto-route disabled | | 150 | | `auto_route.enabled` | `false` | Auto-route disabled | | ||
| 148 | | `network_isolation.enabled` | `false` | Isolation disabled | | 151 | | `network_isolation.enabled` | `false` | Isolation disabled | | ||
| 149 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | 152 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | ||
M docs/config/server.md +27 -1
| @@ -22,6 +22,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||
| 22 | 22 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | |
| 23 | 23 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | |
| 24 | 24 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | |
| 25 | + | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |
| 25 | 26 | ||
| 26 | 27 | ## Port Configuration | |
| 27 | 28 | ||
| @@ -140,7 +141,32 @@ data_dir = "~/.gordon" # Default for user installations | |||
| 140 | 141 | ||
| 141 | 142 | > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments. | |
| 142 | 143 | ||
| 143 | - | ## Examples | |
| 144 | + | ## Maximum Registry Blob Chunk Size | |
| 145 | + | ||
| 146 | + | The `max_blob_chunk_size` setting limits the size of a single blob upload request to the registry (`PATCH`/`PUT` under `/v2/*/blobs/uploads/*`): | |
| 147 | + | ||
| 148 | + | ```toml | |
| 149 | + | [server] | |
| 150 | + | max_blob_chunk_size = "95MB" # Default | |
| 151 | + | ``` | |
| 152 | + | ||
| 153 | + | **Purpose:** Prevents excessive memory usage during blob uploads while allowing large real-world image layers. | |
| 154 | + | ||
| 155 | + | **Supported units:** B, KB, MB, GB, TB (case-insensitive, decimal/1024-based) | |
| 156 | + | ||
| 157 | + | **Examples:** | |
| 158 | + | ||
| 159 | + | ```toml | |
| 160 | + | max_blob_chunk_size = "256MB" # Stricter limit | |
| 161 | + | max_blob_chunk_size = "1GB" # Allow larger layers | |
| 162 | + | ``` | |
| 163 | + | ||
| 164 | + | **Behavior:** | |
| 165 | + | - When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large` | |
| 166 | + | - Docker/Podman may send a full layer in one request, so set this high enough for your base images | |
| 167 | + | - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly | |
| 168 | + | ||
| 169 | + | ## Examples | |
| 144 | 170 | ||
| 145 | 171 | ### Development Configuration | |
| 146 | 172 | ||
| @@ -22,6 +22,7 @@ gordon_domain = "gordon.mydomain.com" # Gordon domain (required) | |||||
| 22 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | 22 | | `registry_domain` | string | - | Deprecated alias for `gordon_domain` | | ||
| 23 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | 23 | | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files | | ||
| 24 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | 24 | | `max_proxy_body_size` | string | `"512MB"` | Maximum request body size for proxied requests | | ||
| 25 | + | | `max_blob_chunk_size` | string | `"95MB"` | Maximum request body size for a single registry blob upload chunk | | |||
| 25 | 26 | ||||
| 26 | ## Port Configuration | 27 | ## Port Configuration | ||
| 27 | 28 | ||||
| @@ -140,7 +141,32 @@ data_dir = "~/.gordon" # Default for user installations | |||||
| 140 | 141 | ||||
| 141 | > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments. | 142 | > **Warning:** Setting this to `0` (no limit) allows unlimited upload sizes, which can lead to disk exhaustion and DoS vulnerabilities. Always set a reasonable limit for production deployments. | ||
| 142 | 143 | ||||
| 143 | - | ## Examples | 144 | + | ## Maximum Registry Blob Chunk Size |
| 145 | + | ||||
| 146 | + | The `max_blob_chunk_size` setting limits the size of a single blob upload request to the registry (`PATCH`/`PUT` under `/v2/*/blobs/uploads/*`): | |||
| 147 | + | ||||
| 148 | + | ```toml | |||
| 149 | + | [server] | |||
| 150 | + | max_blob_chunk_size = "95MB" # Default | |||
| 151 | + | ``` | |||
| 152 | + | ||||
| 153 | + | **Purpose:** Prevents excessive memory usage during blob uploads while allowing large real-world image layers. | |||
| 154 | + | ||||
| 155 | + | **Supported units:** B, KB, MB, GB, TB (case-insensitive, decimal/1024-based) | |||
| 156 | + | ||||
| 157 | + | **Examples:** | |||
| 158 | + | ||||
| 159 | + | ```toml | |||
| 160 | + | max_blob_chunk_size = "256MB" # Stricter limit | |||
| 161 | + | max_blob_chunk_size = "1GB" # Allow larger layers | |||
| 162 | + | ``` | |||
| 163 | + | ||||
| 164 | + | **Behavior:** | |||
| 165 | + | - When a blob chunk exceeds the limit, Gordon returns `413 Request Entity Too Large` | |||
| 166 | + | - Docker/Podman may send a full layer in one request, so set this high enough for your base images | |||
| 167 | + | - Default (`95MB`) is compatible with Cloudflare and most CDN proxies; increase if pushing very large layers directly | |||
| 168 | + | ||||
| 169 | + | ## Examples | |||
| 144 | 170 | ||||
| 145 | ### Development Configuration | 171 | ### Development Configuration | ||
| 146 | 172 | ||||
M internal/adapters/in/cli/auth.go +6 -3
| @@ -297,7 +297,8 @@ func runAuthLogin(remoteName, username, password string) error { | |||
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | 299 | // Create remote client | |
| 300 | - | client := remote.NewClient(remoteConfig.URL) | |
| 300 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) | |
| 301 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...) | |
| 301 | 302 | ||
| 302 | 303 | // Authenticate | |
| 303 | 304 | ctx := context.Background() | |
| @@ -332,7 +333,8 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||
| 332 | 333 | return err | |
| 333 | 334 | } | |
| 334 | 335 | ||
| 335 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | |
| 336 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) | |
| 337 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...) | |
| 336 | 338 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 337 | 339 | defer cancel() | |
| 338 | 340 | verified := true | |
| @@ -758,7 +760,8 @@ func runAuthStatus(remoteNameArg string) error { | |||
| 758 | 760 | } | |
| 759 | 761 | ||
| 760 | 762 | // Create client | |
| 761 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token)) | |
| 763 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName) | |
| 764 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...) | |
| 762 | 765 | ||
| 763 | 766 | // Verify auth | |
| 764 | 767 | ctx := context.Background() | |
| @@ -297,7 +297,8 @@ func runAuthLogin(remoteName, username, password string) error { | |||||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | // Create remote client | 299 | // Create remote client | ||
| 300 | - | client := remote.NewClient(remoteConfig.URL) | 300 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) |
| 301 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...) | |||
| 301 | 302 | ||||
| 302 | // Authenticate | 303 | // Authenticate | ||
| 303 | ctx := context.Background() | 304 | ctx := context.Background() | ||
| @@ -332,7 +333,8 @@ func runAuthLoginWithToken(remoteName, token string) error { | |||||
| 332 | return err | 333 | return err | ||
| 333 | } | 334 | } | ||
| 334 | 335 | ||||
| 335 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(token)) | 336 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName) |
| 337 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...) | |||
| 336 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 338 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| 337 | defer cancel() | 339 | defer cancel() | ||
| 338 | verified := true | 340 | verified := true | ||
| @@ -758,7 +760,8 @@ func runAuthStatus(remoteNameArg string) error { | |||||
| 758 | } | 760 | } | ||
| 759 | 761 | ||||
| 760 | // Create client | 762 | // Create client | ||
| 761 | - | client := remote.NewClient(remoteConfig.URL, remote.WithToken(remoteConfig.Token)) | 763 | + | insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, remoteName) |
| 764 | + | client := remote.NewClient(remoteConfig.URL, remoteClientOptions(remoteConfig.Token, insecureTLS)...) | |||
| 762 | 765 | ||||
| 763 | // Verify auth | 766 | // Verify auth | ||
| 764 | ctx := context.Background() | 767 | ctx := context.Background() | ||
M internal/adapters/in/cli/deploy.go +9 -0
| @@ -35,6 +35,15 @@ Examples: | |||
| 35 | 35 | // Remote deployment via admin API | |
| 36 | 36 | result, err := client.Deploy(ctx, deployDomain) | |
| 37 | 37 | if err != nil { | |
| 38 | + | if shouldFallbackToLocal(err) { | |
| 39 | + | domain, localErr := app.SendDeploySignal(deployDomain) | |
| 40 | + | if localErr == nil { | |
| 41 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))) | |
| 42 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |
| 46 | + | } | |
| 38 | 47 | return fmt.Errorf("failed to deploy: %w", err) | |
| 39 | 48 | } | |
| 40 | 49 | containerID := result.ContainerID | |
| @@ -35,6 +35,15 @@ Examples: | |||||
| 35 | // Remote deployment via admin API | 35 | // Remote deployment via admin API | ||
| 36 | result, err := client.Deploy(ctx, deployDomain) | 36 | result, err := client.Deploy(ctx, deployDomain) | ||
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | + | if shouldFallbackToLocal(err) { | |||
| 39 | + | domain, localErr := app.SendDeploySignal(deployDomain) | |||
| 40 | + | if localErr == nil { | |||
| 41 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))) | |||
| 42 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain))) | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
| 45 | + | return fmt.Errorf("failed to deploy: remote error: %w; local fallback also failed: %v", err, localErr) | |||
| 46 | + | } | |||
| 38 | return fmt.Errorf("failed to deploy: %w", err) | 47 | return fmt.Errorf("failed to deploy: %w", err) | ||
| 39 | } | 48 | } | ||
| 40 | containerID := result.ContainerID | 49 | containerID := result.ContainerID | ||
A internal/adapters/in/cli/fallback.go +43 -0
| @@ -0,0 +1,43 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import "strings" | |
| 4 | + | ||
| 5 | + | var ( | |
| 6 | + | fallbackNetworkErrors = []string{ | |
| 7 | + | "connection refused", | |
| 8 | + | "no such host", | |
| 9 | + | "i/o timeout", | |
| 10 | + | "timeout", | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | fallbackStatusCodes = []string{ | |
| 14 | + | "500", "501", "502", "503", "504", "505", | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | fallbackGatewayErrors = []string{ | |
| 18 | + | "internal server error", | |
| 19 | + | "service unavailable", | |
| 20 | + | "bad gateway", | |
| 21 | + | "gateway timeout", | |
| 22 | + | } | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | func containsAny(s string, patterns []string) bool { | |
| 26 | + | for _, p := range patterns { | |
| 27 | + | if strings.Contains(s, p) { | |
| 28 | + | return true | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | return false | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func shouldFallbackToLocal(err error) bool { | |
| 35 | + | if err == nil { | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | msg := strings.ToLower(err.Error()) | |
| 39 | + | ||
| 40 | + | return containsAny(msg, fallbackNetworkErrors) || | |
| 41 | + | containsAny(msg, fallbackStatusCodes) || | |
| 42 | + | containsAny(msg, fallbackGatewayErrors) | |
| 43 | + | } | |
| @@ -0,0 +1,43 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 5 | + | var ( | |||
| 6 | + | fallbackNetworkErrors = []string{ | |||
| 7 | + | "connection refused", | |||
| 8 | + | "no such host", | |||
| 9 | + | "i/o timeout", | |||
| 10 | + | "timeout", | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | fallbackStatusCodes = []string{ | |||
| 14 | + | "500", "501", "502", "503", "504", "505", | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | fallbackGatewayErrors = []string{ | |||
| 18 | + | "internal server error", | |||
| 19 | + | "service unavailable", | |||
| 20 | + | "bad gateway", | |||
| 21 | + | "gateway timeout", | |||
| 22 | + | } | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | func containsAny(s string, patterns []string) bool { | |||
| 26 | + | for _, p := range patterns { | |||
| 27 | + | if strings.Contains(s, p) { | |||
| 28 | + | return true | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | return false | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func shouldFallbackToLocal(err error) bool { | |||
| 35 | + | if err == nil { | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | msg := strings.ToLower(err.Error()) | |||
| 39 | + | ||||
| 40 | + | return containsAny(msg, fallbackNetworkErrors) || | |||
| 41 | + | containsAny(msg, fallbackStatusCodes) || | |||
| 42 | + | containsAny(msg, fallbackGatewayErrors) | |||
| 43 | + | } | |||
A internal/adapters/in/cli/fallback_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestShouldFallbackToLocal(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | err error | |
| 14 | + | want bool | |
| 15 | + | }{ | |
| 16 | + | {name: "nil", err: nil, want: false}, | |
| 17 | + | {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true}, | |
| 18 | + | {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true}, | |
| 19 | + | {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true}, | |
| 20 | + | {name: "timeout", err: errors.New("request timeout"), want: true}, | |
| 21 | + | {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false}, | |
| 22 | + | {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err)) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestShouldFallbackToLocal(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | err error | |||
| 14 | + | want bool | |||
| 15 | + | }{ | |||
| 16 | + | {name: "nil", err: nil, want: false}, | |||
| 17 | + | {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true}, | |||
| 18 | + | {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true}, | |||
| 19 | + | {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true}, | |||
| 20 | + | {name: "timeout", err: errors.New("request timeout"), want: true}, | |||
| 21 | + | {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false}, | |||
| 22 | + | {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err)) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
M internal/adapters/in/cli/local.go +38 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | "github.com/spf13/viper" | |
| @@ -12,6 +13,8 @@ import ( | |||
| 12 | 13 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 13 | 14 | "github.com/bnema/gordon/internal/app" | |
| 14 | 15 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 18 | "github.com/bnema/gordon/internal/usecase/config" | |
| 16 | 19 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 17 | 20 | ) | |
| @@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 82 | 85 | envDir = filepath.Join(dataDir, "env") | |
| 83 | 86 | } | |
| 84 | 87 | ||
| 85 | - | // Create domain secret store and service | |
| 86 | - | domainSecretStore, err := domainsecrets.NewFileStore(envDir, log) | |
| 88 | + | // Create domain secret store using configured backend (pass or file-based) | |
| 89 | + | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) | |
| 87 | 90 | if err != nil { | |
| 88 | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | |
| 89 | 92 | } | |
| @@ -95,3 +98,36 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 95 | 98 | dataDir: dataDir, | |
| 96 | 99 | }, nil | |
| 97 | 100 | } | |
| 101 | + | ||
| 102 | + | func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) { | |
| 103 | + | backend := resolveLocalSecretsBackend(v) | |
| 104 | + | switch backend { | |
| 105 | + | case domain.SecretsBackendPass: | |
| 106 | + | return domainsecrets.NewPassStore(log) | |
| 107 | + | case domain.SecretsBackendSops: | |
| 108 | + | return nil, fmt.Errorf("sops backend not yet supported for domain secrets") | |
| 109 | + | default: | |
| 110 | + | return domainsecrets.NewFileStore(envDir, log) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend { | |
| 115 | + | backend := strings.TrimSpace(v.GetString("auth.secrets_backend")) | |
| 116 | + | if backend == "" { | |
| 117 | + | // Legacy key support for older configs. | |
| 118 | + | backend = strings.TrimSpace(v.GetString("secrets.backend")) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | switch backend { | |
| 122 | + | case "pass": | |
| 123 | + | return domain.SecretsBackendPass | |
| 124 | + | case "sops": | |
| 125 | + | return domain.SecretsBackendSops | |
| 126 | + | case "unsafe", "": | |
| 127 | + | return domain.SecretsBackendUnsafe | |
| 128 | + | default: | |
| 129 | + | log := zerowrap.Default() | |
| 130 | + | log.Warn().Str("backend", backend).Msg("unrecognized secrets backend, falling back to unsafe") | |
| 131 | + | return domain.SecretsBackendUnsafe | |
| 132 | + | } | |
| 133 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 10 | "github.com/spf13/viper" | 11 | "github.com/spf13/viper" | ||
| @@ -12,6 +13,8 @@ import ( | |||||
| 12 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | 13 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | ||
| 13 | "github.com/bnema/gordon/internal/app" | 14 | "github.com/bnema/gordon/internal/app" | ||
| 14 | "github.com/bnema/gordon/internal/boundaries/in" | 15 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| 16 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | "github.com/bnema/gordon/internal/usecase/config" | 18 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 16 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | 19 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | ||
| 17 | ) | 20 | ) | ||
| @@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 82 | envDir = filepath.Join(dataDir, "env") | 85 | envDir = filepath.Join(dataDir, "env") | ||
| 83 | } | 86 | } | ||
| 84 | 87 | ||||
| 85 | - | // Create domain secret store and service | 88 | + | // Create domain secret store using configured backend (pass or file-based) |
| 86 | - | domainSecretStore, err := domainsecrets.NewFileStore(envDir, log) | 89 | + | domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log) |
| 87 | if err != nil { | 90 | if err != nil { | ||
| 88 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | ||
| 89 | } | 92 | } | ||
| @@ -95,3 +98,36 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 95 | dataDir: dataDir, | 98 | dataDir: dataDir, | ||
| 96 | }, nil | 99 | }, nil | ||
| 97 | } | 100 | } | ||
| 101 | + | ||||
| 102 | + | func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) { | |||
| 103 | + | backend := resolveLocalSecretsBackend(v) | |||
| 104 | + | switch backend { | |||
| 105 | + | case domain.SecretsBackendPass: | |||
| 106 | + | return domainsecrets.NewPassStore(log) | |||
| 107 | + | case domain.SecretsBackendSops: | |||
| 108 | + | return nil, fmt.Errorf("sops backend not yet supported for domain secrets") | |||
| 109 | + | default: | |||
| 110 | + | return domainsecrets.NewFileStore(envDir, log) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend { | |||
| 115 | + | backend := strings.TrimSpace(v.GetString("auth.secrets_backend")) | |||
| 116 | + | if backend == "" { | |||
| 117 | + | // Legacy key support for older configs. | |||
| 118 | + | backend = strings.TrimSpace(v.GetString("secrets.backend")) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | switch backend { | |||
| 122 | + | case "pass": | |||
| 123 | + | return domain.SecretsBackendPass | |||
| 124 | + | case "sops": | |||
| 125 | + | return domain.SecretsBackendSops | |||
| 126 | + | case "unsafe", "": | |||
| 127 | + | return domain.SecretsBackendUnsafe | |||
| 128 | + | default: | |||
| 129 | + | log := zerowrap.Default() | |||
| 130 | + | log.Warn().Str("backend", backend).Msg("unrecognized secrets backend, falling back to unsafe") | |||
| 131 | + | return domain.SecretsBackendUnsafe | |||
| 132 | + | } | |||
| 133 | + | } | |||
A internal/adapters/in/cli/local_test.go +74 -0
| @@ -0,0 +1,74 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerowrap" | |
| 7 | + | "github.com/spf13/viper" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestResolveLocalSecretsBackend(t *testing.T) { | |
| 15 | + | t.Run("auth backend takes precedence", func(t *testing.T) { | |
| 16 | + | v := viper.New() | |
| 17 | + | v.Set("auth.secrets_backend", "pass") | |
| 18 | + | v.Set("secrets.backend", "unsafe") | |
| 19 | + | ||
| 20 | + | got := resolveLocalSecretsBackend(v) | |
| 21 | + | require.Equal(t, domain.SecretsBackendPass, got) | |
| 22 | + | }) | |
| 23 | + | ||
| 24 | + | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |
| 25 | + | v := viper.New() | |
| 26 | + | v.Set("secrets.backend", "pass") | |
| 27 | + | ||
| 28 | + | got := resolveLocalSecretsBackend(v) | |
| 29 | + | require.Equal(t, domain.SecretsBackendPass, got) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |
| 33 | + | v := viper.New() | |
| 34 | + | v.Set("auth.secrets_backend", "wat") | |
| 35 | + | ||
| 36 | + | got := resolveLocalSecretsBackend(v) | |
| 37 | + | require.Equal(t, domain.SecretsBackendUnsafe, got) | |
| 38 | + | }) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) { | |
| 42 | + | // pass(1) must be available on the system for this test | |
| 43 | + | if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil { | |
| 44 | + | t.Skipf("pass store not available: %v", err) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | v := viper.New() | |
| 48 | + | v.Set("auth.secrets_backend", "pass") | |
| 49 | + | ||
| 50 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 51 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.IsType(t, &domainsecrets.PassStore{}, store) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestCreateLocalDomainSecretStore_RejectsSopsBackend(t *testing.T) { | |
| 57 | + | v := viper.New() | |
| 58 | + | v.Set("auth.secrets_backend", "sops") | |
| 59 | + | ||
| 60 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 61 | + | _, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 62 | + | require.Error(t, err) | |
| 63 | + | require.Contains(t, err.Error(), "sops backend not yet supported") | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) { | |
| 67 | + | v := viper.New() | |
| 68 | + | v.Set("auth.secrets_backend", "unsafe") | |
| 69 | + | ||
| 70 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |
| 71 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | require.IsType(t, &domainsecrets.FileStore{}, store) | |
| 74 | + | } | |
| @@ -0,0 +1,74 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerowrap" | |||
| 7 | + | "github.com/spf13/viper" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestResolveLocalSecretsBackend(t *testing.T) { | |||
| 15 | + | t.Run("auth backend takes precedence", func(t *testing.T) { | |||
| 16 | + | v := viper.New() | |||
| 17 | + | v.Set("auth.secrets_backend", "pass") | |||
| 18 | + | v.Set("secrets.backend", "unsafe") | |||
| 19 | + | ||||
| 20 | + | got := resolveLocalSecretsBackend(v) | |||
| 21 | + | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 22 | + | }) | |||
| 23 | + | ||||
| 24 | + | t.Run("falls back to legacy secrets backend key", func(t *testing.T) { | |||
| 25 | + | v := viper.New() | |||
| 26 | + | v.Set("secrets.backend", "pass") | |||
| 27 | + | ||||
| 28 | + | got := resolveLocalSecretsBackend(v) | |||
| 29 | + | require.Equal(t, domain.SecretsBackendPass, got) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | t.Run("defaults to unsafe for unknown backend", func(t *testing.T) { | |||
| 33 | + | v := viper.New() | |||
| 34 | + | v.Set("auth.secrets_backend", "wat") | |||
| 35 | + | ||||
| 36 | + | got := resolveLocalSecretsBackend(v) | |||
| 37 | + | require.Equal(t, domain.SecretsBackendUnsafe, got) | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestCreateLocalDomainSecretStore_UsesPassStoreForPass(t *testing.T) { | |||
| 42 | + | // pass(1) must be available on the system for this test | |||
| 43 | + | if _, err := domainsecrets.NewPassStore(zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})); err != nil { | |||
| 44 | + | t.Skipf("pass store not available: %v", err) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | v := viper.New() | |||
| 48 | + | v.Set("auth.secrets_backend", "pass") | |||
| 49 | + | ||||
| 50 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |||
| 51 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.IsType(t, &domainsecrets.PassStore{}, store) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestCreateLocalDomainSecretStore_RejectsSopsBackend(t *testing.T) { | |||
| 57 | + | v := viper.New() | |||
| 58 | + | v.Set("auth.secrets_backend", "sops") | |||
| 59 | + | ||||
| 60 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |||
| 61 | + | _, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |||
| 62 | + | require.Error(t, err) | |||
| 63 | + | require.Contains(t, err.Error(), "sops backend not yet supported") | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) { | |||
| 67 | + | v := viper.New() | |||
| 68 | + | v.Set("auth.secrets_backend", "unsafe") | |||
| 69 | + | ||||
| 70 | + | log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"}) | |||
| 71 | + | store, err := createLocalDomainSecretStore(v, t.TempDir(), log) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | require.IsType(t, &domainsecrets.FileStore{}, store) | |||
| 74 | + | } | |||
M internal/adapters/in/cli/push.go +39 -16
| @@ -18,12 +18,13 @@ import ( | |||
| 18 | 18 | ||
| 19 | 19 | func newPushCmd() *cobra.Command { | |
| 20 | 20 | var ( | |
| 21 | - | noDeploy bool | |
| 22 | - | noConfirm bool | |
| 23 | - | build bool | |
| 24 | - | platform string | |
| 25 | - | tag string | |
| 26 | - | buildArgs []string | |
| 21 | + | noDeploy bool | |
| 22 | + | noConfirm bool | |
| 23 | + | build bool | |
| 24 | + | platform string | |
| 25 | + | tag string | |
| 26 | + | dockerfile string | |
| 27 | + | buildArgs []string | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | 30 | cmd := &cobra.Command{ | |
| @@ -41,7 +42,7 @@ Examples: | |||
| 41 | 42 | gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 42 | 43 | Args: cobra.ExactArgs(1), | |
| 43 | 44 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 44 | - | return runPush(cmd.Context(), args[0], tag, build, platform, buildArgs, noDeploy, noConfirm) | |
| 45 | + | return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) | |
| 45 | 46 | }, | |
| 46 | 47 | } | |
| 47 | 48 | ||
| @@ -49,18 +50,40 @@ Examples: | |||
| 49 | 50 | cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt") | |
| 50 | 51 | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | |
| 51 | 52 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | |
| 53 | + | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |
| 52 | 54 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | |
| 53 | 55 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |
| 54 | 56 | ||
| 55 | 57 | return cmd | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | - | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |
| 60 | + | func resolveDockerfile(dockerfile string, build bool) (string, error) { | |
| 61 | + | if dockerfile != "" && !build { | |
| 62 | + | return "", fmt.Errorf("--file can only be used with --build") | |
| 63 | + | } | |
| 64 | + | if dockerfile == "" { | |
| 65 | + | return "Dockerfile", nil | |
| 66 | + | } | |
| 67 | + | return dockerfile, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) { | |
| 71 | + | versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version) | |
| 72 | + | latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName) | |
| 73 | + | return versionRef, latestRef | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |
| 59 | 77 | client, isRemote := GetRemoteClient() | |
| 60 | 78 | if !isRemote { | |
| 61 | 79 | return fmt.Errorf("push requires remote mode") | |
| 62 | 80 | } | |
| 63 | 81 | ||
| 82 | + | dockerfile, err := resolveDockerfile(dockerfile, build) | |
| 83 | + | if err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | ||
| 64 | 87 | route, err := client.GetRoute(ctx, pushDomain) | |
| 65 | 88 | if err != nil { | |
| 66 | 89 | return fmt.Errorf("failed to get route: %w", err) | |
| @@ -84,8 +107,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 84 | 107 | } | |
| 85 | 108 | } | |
| 86 | 109 | ||
| 87 | - | versionRef := fmt.Sprintf("%s/%s:%s", registry, imageName, version) | |
| 88 | - | latestRef := fmt.Sprintf("%s/%s:latest", registry, imageName) | |
| 110 | + | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | |
| 89 | 111 | ||
| 90 | 112 | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | |
| 91 | 113 | if version != "latest" { | |
| @@ -93,7 +115,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 93 | 115 | } | |
| 94 | 116 | ||
| 95 | 117 | if build { | |
| 96 | - | if err := buildAndPush(ctx, version, platform, buildArgs, versionRef, latestRef); err != nil { | |
| 118 | + | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | |
| 97 | 119 | return err | |
| 98 | 120 | } | |
| 99 | 121 | } else { | |
| @@ -122,9 +144,9 @@ func determineVersion(ctx context.Context, tag string) string { | |||
| 122 | 144 | return version | |
| 123 | 145 | } | |
| 124 | 146 | ||
| 125 | - | func buildAndPush(ctx context.Context, version, platform string, buildArgs []string, versionRef, latestRef string) error { | |
| 126 | - | if _, err := os.Stat("Dockerfile"); os.IsNotExist(err) { | |
| 127 | - | return fmt.Errorf("no Dockerfile found in current directory") | |
| 147 | + | func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error { | |
| 148 | + | if _, err := os.Stat(dockerfile); os.IsNotExist(err) { | |
| 149 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |
| 128 | 150 | } | |
| 129 | 151 | ||
| 130 | 152 | // Build and load into local daemon (NOT --push). | |
| @@ -132,7 +154,7 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||
| 132 | 154 | // Cloudflare's 100MB per-request limit. Loading locally then | |
| 133 | 155 | // using docker push gives us chunked uploads (~5MB per request). | |
| 134 | 156 | fmt.Println("\nBuilding image...") | |
| 135 | - | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 157 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 | |
| 136 | 158 | buildCmd.Stdout = os.Stdout | |
| 137 | 159 | buildCmd.Stderr = os.Stderr | |
| 138 | 160 | if err := buildCmd.Run(); err != nil { | |
| @@ -155,10 +177,11 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||
| 155 | 177 | // buildImageArgs constructs the docker buildx build arguments. | |
| 156 | 178 | // Uses --load instead of --push so the image is loaded into the local | |
| 157 | 179 | // daemon, allowing docker push to handle the upload with chunked requests. | |
| 158 | - | func buildImageArgs(version, platform string, buildArgs []string, versionRef, latestRef string) []string { | |
| 180 | + | func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { | |
| 159 | 181 | args := []string{ | |
| 160 | 182 | "buildx", "build", | |
| 161 | 183 | "--platform", platform, | |
| 184 | + | "-f", dockerfile, | |
| 162 | 185 | "-t", latestRef, | |
| 163 | 186 | "--build-arg", "VERSION=" + version, | |
| 164 | 187 | } | |
| @@ -18,12 +18,13 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | func newPushCmd() *cobra.Command { | 19 | func newPushCmd() *cobra.Command { | ||
| 20 | var ( | 20 | var ( | ||
| 21 | - | noDeploy bool | 21 | + | noDeploy bool |
| 22 | - | noConfirm bool | 22 | + | noConfirm bool |
| 23 | - | build bool | 23 | + | build bool |
| 24 | - | platform string | 24 | + | platform string |
| 25 | - | tag string | 25 | + | tag string |
| 26 | - | buildArgs []string | 26 | + | dockerfile string |
| 27 | + | buildArgs []string | |||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | cmd := &cobra.Command{ | 30 | cmd := &cobra.Command{ | ||
| @@ -41,7 +42,7 @@ Examples: | |||||
| 41 | gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`, | 42 | gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`, | ||
| 42 | Args: cobra.ExactArgs(1), | 43 | Args: cobra.ExactArgs(1), | ||
| 43 | RunE: func(cmd *cobra.Command, args []string) error { | 44 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 44 | - | return runPush(cmd.Context(), args[0], tag, build, platform, buildArgs, noDeploy, noConfirm) | 45 | + | return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) |
| 45 | }, | 46 | }, | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| @@ -49,18 +50,40 @@ Examples: | |||||
| 49 | cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt") | 50 | cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt") | ||
| 50 | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | 51 | cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx") | ||
| 51 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | 52 | cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)") | ||
| 53 | + | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |||
| 52 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | 54 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | ||
| 53 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | 55 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | ||
| 54 | 56 | ||||
| 55 | return cmd | 57 | return cmd | ||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 58 | - | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, buildArgs []string, noDeploy bool, noConfirm bool) error { | 60 | + | func resolveDockerfile(dockerfile string, build bool) (string, error) { |
| 61 | + | if dockerfile != "" && !build { | |||
| 62 | + | return "", fmt.Errorf("--file can only be used with --build") | |||
| 63 | + | } | |||
| 64 | + | if dockerfile == "" { | |||
| 65 | + | return "Dockerfile", nil | |||
| 66 | + | } | |||
| 67 | + | return dockerfile, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func resolveImageRefs(registry, imageName, version string) (versionRef, latestRef string) { | |||
| 71 | + | versionRef = fmt.Sprintf("%s/%s:%s", registry, imageName, version) | |||
| 72 | + | latestRef = fmt.Sprintf("%s/%s:latest", registry, imageName) | |||
| 73 | + | return versionRef, latestRef | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |||
| 59 | client, isRemote := GetRemoteClient() | 77 | client, isRemote := GetRemoteClient() | ||
| 60 | if !isRemote { | 78 | if !isRemote { | ||
| 61 | return fmt.Errorf("push requires remote mode") | 79 | return fmt.Errorf("push requires remote mode") | ||
| 62 | } | 80 | } | ||
| 63 | 81 | ||||
| 82 | + | dockerfile, err := resolveDockerfile(dockerfile, build) | |||
| 83 | + | if err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | ||||
| 64 | route, err := client.GetRoute(ctx, pushDomain) | 87 | route, err := client.GetRoute(ctx, pushDomain) | ||
| 65 | if err != nil { | 88 | if err != nil { | ||
| 66 | return fmt.Errorf("failed to get route: %w", err) | 89 | return fmt.Errorf("failed to get route: %w", err) | ||
| @@ -84,8 +107,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 84 | } | 107 | } | ||
| 85 | } | 108 | } | ||
| 86 | 109 | ||||
| 87 | - | versionRef := fmt.Sprintf("%s/%s:%s", registry, imageName, version) | 110 | + | versionRef, latestRef := resolveImageRefs(registry, imageName, version) |
| 88 | - | latestRef := fmt.Sprintf("%s/%s:latest", registry, imageName) | |||
| 89 | 111 | ||||
| 90 | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | 112 | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | ||
| 91 | if version != "latest" { | 113 | if version != "latest" { | ||
| @@ -93,7 +115,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 93 | } | 115 | } | ||
| 94 | 116 | ||||
| 95 | if build { | 117 | if build { | ||
| 96 | - | if err := buildAndPush(ctx, version, platform, buildArgs, versionRef, latestRef); err != nil { | 118 | + | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { |
| 97 | return err | 119 | return err | ||
| 98 | } | 120 | } | ||
| 99 | } else { | 121 | } else { | ||
| @@ -122,9 +144,9 @@ func determineVersion(ctx context.Context, tag string) string { | |||||
| 122 | return version | 144 | return version | ||
| 123 | } | 145 | } | ||
| 124 | 146 | ||||
| 125 | - | func buildAndPush(ctx context.Context, version, platform string, buildArgs []string, versionRef, latestRef string) error { | 147 | + | func buildAndPush(ctx context.Context, version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) error { |
| 126 | - | if _, err := os.Stat("Dockerfile"); os.IsNotExist(err) { | 148 | + | if _, err := os.Stat(dockerfile); os.IsNotExist(err) { |
| 127 | - | return fmt.Errorf("no Dockerfile found in current directory") | 149 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) |
| 128 | } | 150 | } | ||
| 129 | 151 | ||||
| 130 | // Build and load into local daemon (NOT --push). | 152 | // Build and load into local daemon (NOT --push). | ||
| @@ -132,7 +154,7 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||||
| 132 | // Cloudflare's 100MB per-request limit. Loading locally then | 154 | // Cloudflare's 100MB per-request limit. Loading locally then | ||
| 133 | // using docker push gives us chunked uploads (~5MB per request). | 155 | // using docker push gives us chunked uploads (~5MB per request). | ||
| 134 | fmt.Println("\nBuilding image...") | 156 | fmt.Println("\nBuilding image...") | ||
| 135 | - | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, buildArgs, versionRef, latestRef)...) // #nosec G204 | 157 | + | buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204 |
| 136 | buildCmd.Stdout = os.Stdout | 158 | buildCmd.Stdout = os.Stdout | ||
| 137 | buildCmd.Stderr = os.Stderr | 159 | buildCmd.Stderr = os.Stderr | ||
| 138 | if err := buildCmd.Run(); err != nil { | 160 | if err := buildCmd.Run(); err != nil { | ||
| @@ -155,10 +177,11 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str | |||||
| 155 | // buildImageArgs constructs the docker buildx build arguments. | 177 | // buildImageArgs constructs the docker buildx build arguments. | ||
| 156 | // Uses --load instead of --push so the image is loaded into the local | 178 | // Uses --load instead of --push so the image is loaded into the local | ||
| 157 | // daemon, allowing docker push to handle the upload with chunked requests. | 179 | // daemon, allowing docker push to handle the upload with chunked requests. | ||
| 158 | - | func buildImageArgs(version, platform string, buildArgs []string, versionRef, latestRef string) []string { | 180 | + | func buildImageArgs(version, platform, dockerfile string, buildArgs []string, versionRef, latestRef string) []string { |
| 159 | args := []string{ | 181 | args := []string{ | ||
| 160 | "buildx", "build", | 182 | "buildx", "build", | ||
| 161 | "--platform", platform, | 183 | "--platform", platform, | ||
| 184 | + | "-f", dockerfile, | |||
| 162 | "-t", latestRef, | 185 | "-t", latestRef, | ||
| 163 | "--build-arg", "VERSION=" + version, | 186 | "--build-arg", "VERSION=" + version, | ||
| 164 | } | 187 | } | ||
M internal/adapters/in/cli/push_test.go +10 -1
| @@ -63,9 +63,18 @@ func TestParseImageRef(t *testing.T) { | |||
| 63 | 63 | ||
| 64 | 64 | func TestBuildAndPush_BuildArgs(t *testing.T) { | |
| 65 | 65 | // Verify buildImageArgs produces --load instead of --push | |
| 66 | - | args := buildImageArgs("v1.0.0", "linux/amd64", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 66 | + | args := buildImageArgs("v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 67 | 67 | ||
| 68 | 68 | assert.Contains(t, args, "--load") | |
| 69 | 69 | assert.NotContains(t, args, "--push") | |
| 70 | 70 | assert.Contains(t, args, "--platform") | |
| 71 | + | assert.Contains(t, args, "-f") | |
| 72 | + | assert.Contains(t, args, "Dockerfile") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | |
| 76 | + | args := buildImageArgs("v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |
| 77 | + | ||
| 78 | + | assert.Contains(t, args, "-f") | |
| 79 | + | assert.Contains(t, args, "docker/app/Dockerfile") | |
| 71 | 80 | } | |
| @@ -63,9 +63,18 @@ func TestParseImageRef(t *testing.T) { | |||||
| 63 | 63 | ||||
| 64 | func TestBuildAndPush_BuildArgs(t *testing.T) { | 64 | func TestBuildAndPush_BuildArgs(t *testing.T) { | ||
| 65 | // Verify buildImageArgs produces --load instead of --push | 65 | // Verify buildImageArgs produces --load instead of --push | ||
| 66 | - | args := buildImageArgs("v1.0.0", "linux/amd64", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | 66 | + | args := buildImageArgs("v1.0.0", "linux/amd64", "Dockerfile", []string{"CGO_ENABLED=0"}, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") |
| 67 | 67 | ||||
| 68 | assert.Contains(t, args, "--load") | 68 | assert.Contains(t, args, "--load") | ||
| 69 | assert.NotContains(t, args, "--push") | 69 | assert.NotContains(t, args, "--push") | ||
| 70 | assert.Contains(t, args, "--platform") | 70 | assert.Contains(t, args, "--platform") | ||
| 71 | + | assert.Contains(t, args, "-f") | |||
| 72 | + | assert.Contains(t, args, "Dockerfile") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | |||
| 76 | + | args := buildImageArgs("v1.0.0", "linux/amd64", "docker/app/Dockerfile", nil, "reg.example.com/app:v1.0.0", "reg.example.com/app:latest") | |||
| 77 | + | ||||
| 78 | + | assert.Contains(t, args, "-f") | |||
| 79 | + | assert.Contains(t, args, "docker/app/Dockerfile") | |||
| 71 | } | 80 | } | ||
M internal/adapters/in/cli/remote/client.go +117 -13
| @@ -4,11 +4,14 @@ package remote | |||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | 6 | "context" | |
| 7 | + | "crypto/tls" | |
| 7 | 8 | "encoding/json" | |
| 9 | + | "errors" | |
| 8 | 10 | "fmt" | |
| 9 | 11 | "io" | |
| 10 | 12 | "net/http" | |
| 11 | 13 | "net/url" | |
| 14 | + | "os" | |
| 12 | 15 | "strings" | |
| 13 | 16 | "time" | |
| 14 | 17 | ||
| @@ -18,11 +21,17 @@ import ( | |||
| 18 | 21 | ||
| 19 | 22 | // Client is an HTTP client for the Gordon admin API. | |
| 20 | 23 | type Client struct { | |
| 21 | - | baseURL string | |
| 22 | - | token string | |
| 23 | - | httpClient *http.Client | |
| 24 | + | baseURL string | |
| 25 | + | token string | |
| 26 | + | httpClient *http.Client | |
| 27 | + | insecureTLS bool | |
| 24 | 28 | } | |
| 25 | 29 | ||
| 30 | + | var ( | |
| 31 | + | retryMaxAttempts = 4 | |
| 32 | + | retryBaseDelay = 250 * time.Millisecond | |
| 33 | + | ) | |
| 34 | + | ||
| 26 | 35 | // ClientOption configures the Client. | |
| 27 | 36 | type ClientOption func(*Client) | |
| 28 | 37 | ||
| @@ -42,6 +51,8 @@ func NewClient(baseURL string, opts ...ClientOption) *Client { | |||
| 42 | 51 | opt(c) | |
| 43 | 52 | } | |
| 44 | 53 | ||
| 54 | + | c.applyTLSConfig() | |
| 55 | + | ||
| 45 | 56 | return c | |
| 46 | 57 | } | |
| 47 | 58 | ||
| @@ -66,6 +77,37 @@ func WithTimeout(timeout time.Duration) ClientOption { | |||
| 66 | 77 | } | |
| 67 | 78 | } | |
| 68 | 79 | ||
| 80 | + | // WithInsecureTLS disables TLS certificate verification for remote admin API requests. | |
| 81 | + | func WithInsecureTLS(insecure bool) ClientOption { | |
| 82 | + | return func(c *Client) { | |
| 83 | + | c.insecureTLS = insecure | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (c *Client) applyTLSConfig() { | |
| 88 | + | if !c.insecureTLS { | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL) | |
| 93 | + | ||
| 94 | + | var transport *http.Transport | |
| 95 | + | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | |
| 96 | + | transport = existing.Clone() | |
| 97 | + | } else { | |
| 98 | + | transport = http.DefaultTransport.(*http.Transport).Clone() | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | if transport.TLSClientConfig == nil { | |
| 102 | + | transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} | |
| 103 | + | } else { | |
| 104 | + | transport.TLSClientConfig.MinVersion = tls.VersionTLS12 | |
| 105 | + | } | |
| 106 | + | //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments. | |
| 107 | + | transport.TLSClientConfig.InsecureSkipVerify = true | |
| 108 | + | c.httpClient.Transport = transport | |
| 109 | + | } | |
| 110 | + | ||
| 69 | 111 | // request performs an HTTP request to the admin API. | |
| 70 | 112 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | |
| 71 | 113 | url := c.baseURL + "/admin" + path | |
| @@ -100,13 +142,7 @@ func parseResponse(resp *http.Response, target any) error { | |||
| 100 | 142 | ||
| 101 | 143 | if resp.StatusCode >= 400 { | |
| 102 | 144 | body, _ := io.ReadAll(resp.Body) | |
| 103 | - | var errResp struct { | |
| 104 | - | Error string `json:"error"` | |
| 105 | - | } | |
| 106 | - | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 107 | - | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 108 | - | } | |
| 109 | - | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 145 | + | return parseErrorResponse(resp, body) | |
| 110 | 146 | } | |
| 111 | 147 | ||
| 112 | 148 | if target != nil { | |
| @@ -118,6 +154,74 @@ func parseResponse(resp *http.Response, target any) error { | |||
| 118 | 154 | return nil | |
| 119 | 155 | } | |
| 120 | 156 | ||
| 157 | + | func parseErrorResponse(resp *http.Response, body []byte) error { | |
| 158 | + | var errResp struct { | |
| 159 | + | Error string `json:"error"` | |
| 160 | + | } | |
| 161 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 162 | + | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 163 | + | } | |
| 164 | + | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func isRetryableRequestError(err error) bool { | |
| 168 | + | return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func isRetryableStatus(status int) bool { | |
| 172 | + | // Only retry gateway errors; retrying 500 on non-idempotent POSTs | |
| 173 | + | // (deploy, restart, reload) can cause duplicate state changes. | |
| 174 | + | return status == 502 || status == 503 || status == 504 | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func retryDelay(attempt int) time.Duration { | |
| 178 | + | if attempt <= 1 { | |
| 179 | + | return retryBaseDelay | |
| 180 | + | } | |
| 181 | + | return retryBaseDelay * time.Duration(1<<(attempt-1)) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // requestWithRetry performs an HTTP request and retries transient failures. | |
| 185 | + | // Retries occur on transport errors and gateway errors (502, 503, 504). | |
| 186 | + | func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) { | |
| 187 | + | var lastErr error | |
| 188 | + | ||
| 189 | + | for attempt := 1; attempt <= retryMaxAttempts; attempt++ { | |
| 190 | + | resp, err := c.request(ctx, method, path, body) | |
| 191 | + | if err != nil { | |
| 192 | + | lastErr = err | |
| 193 | + | if attempt == retryMaxAttempts || !isRetryableRequestError(err) { | |
| 194 | + | return nil, err | |
| 195 | + | } | |
| 196 | + | } else { | |
| 197 | + | if !isRetryableStatus(resp.StatusCode) { | |
| 198 | + | return resp, nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | respBody, _ := io.ReadAll(resp.Body) | |
| 202 | + | _ = resp.Body.Close() | |
| 203 | + | lastErr = parseErrorResponse(resp, respBody) | |
| 204 | + | if attempt == retryMaxAttempts { | |
| 205 | + | return nil, lastErr | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | delay := retryDelay(attempt) | |
| 210 | + | timer := time.NewTimer(delay) | |
| 211 | + | select { | |
| 212 | + | case <-ctx.Done(): | |
| 213 | + | timer.Stop() | |
| 214 | + | return nil, ctx.Err() | |
| 215 | + | case <-timer.C: | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | if lastErr != nil { | |
| 220 | + | return nil, lastErr | |
| 221 | + | } | |
| 222 | + | return nil, fmt.Errorf("request failed after retries") | |
| 223 | + | } | |
| 224 | + | ||
| 121 | 225 | // Routes API | |
| 122 | 226 | ||
| 123 | 227 | // Type aliases for API types using shared DTO package. | |
| @@ -371,7 +475,7 @@ func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) | |||
| 371 | 475 | ||
| 372 | 476 | // Reload triggers a configuration reload. | |
| 373 | 477 | func (c *Client) Reload(ctx context.Context) error { | |
| 374 | - | resp, err := c.request(ctx, http.MethodPost, "/reload", nil) | |
| 478 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil) | |
| 375 | 479 | if err != nil { | |
| 376 | 480 | return err | |
| 377 | 481 | } | |
| @@ -431,7 +535,7 @@ type DeployResult struct { | |||
| 431 | 535 | ||
| 432 | 536 | // Deploy triggers a deployment for the specified domain. | |
| 433 | 537 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | |
| 434 | - | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) | |
| 538 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) | |
| 435 | 539 | if err != nil { | |
| 436 | 540 | return nil, err | |
| 437 | 541 | } | |
| @@ -461,7 +565,7 @@ func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachme | |||
| 461 | 565 | if withAttachments { | |
| 462 | 566 | path += "?attachments=true" | |
| 463 | 567 | } | |
| 464 | - | resp, err := c.request(ctx, http.MethodPost, path, nil) | |
| 568 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil) | |
| 465 | 569 | if err != nil { | |
| 466 | 570 | return nil, err | |
| 467 | 571 | } | |
| @@ -4,11 +4,14 @@ package remote | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "crypto/tls" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 9 | + | "errors" | |||
| 8 | "fmt" | 10 | "fmt" | ||
| 9 | "io" | 11 | "io" | ||
| 10 | "net/http" | 12 | "net/http" | ||
| 11 | "net/url" | 13 | "net/url" | ||
| 14 | + | "os" | |||
| 12 | "strings" | 15 | "strings" | ||
| 13 | "time" | 16 | "time" | ||
| 14 | 17 | ||||
| @@ -18,11 +21,17 @@ import ( | |||||
| 18 | 21 | ||||
| 19 | // Client is an HTTP client for the Gordon admin API. | 22 | // Client is an HTTP client for the Gordon admin API. | ||
| 20 | type Client struct { | 23 | type Client struct { | ||
| 21 | - | baseURL string | 24 | + | baseURL string |
| 22 | - | token string | 25 | + | token string |
| 23 | - | httpClient *http.Client | 26 | + | httpClient *http.Client |
| 27 | + | insecureTLS bool | |||
| 24 | } | 28 | } | ||
| 25 | 29 | ||||
| 30 | + | var ( | |||
| 31 | + | retryMaxAttempts = 4 | |||
| 32 | + | retryBaseDelay = 250 * time.Millisecond | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 26 | // ClientOption configures the Client. | 35 | // ClientOption configures the Client. | ||
| 27 | type ClientOption func(*Client) | 36 | type ClientOption func(*Client) | ||
| 28 | 37 | ||||
| @@ -42,6 +51,8 @@ func NewClient(baseURL string, opts ...ClientOption) *Client { | |||||
| 42 | opt(c) | 51 | opt(c) | ||
| 43 | } | 52 | } | ||
| 44 | 53 | ||||
| 54 | + | c.applyTLSConfig() | |||
| 55 | + | ||||
| 45 | return c | 56 | return c | ||
| 46 | } | 57 | } | ||
| 47 | 58 | ||||
| @@ -66,6 +77,37 @@ func WithTimeout(timeout time.Duration) ClientOption { | |||||
| 66 | } | 77 | } | ||
| 67 | } | 78 | } | ||
| 68 | 79 | ||||
| 80 | + | // WithInsecureTLS disables TLS certificate verification for remote admin API requests. | |||
| 81 | + | func WithInsecureTLS(insecure bool) ClientOption { | |||
| 82 | + | return func(c *Client) { | |||
| 83 | + | c.insecureTLS = insecure | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (c *Client) applyTLSConfig() { | |||
| 88 | + | if !c.insecureTLS { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | fmt.Fprintf(os.Stderr, "WARNING: TLS certificate verification disabled for %s\n", c.baseURL) | |||
| 93 | + | ||||
| 94 | + | var transport *http.Transport | |||
| 95 | + | if existing, ok := c.httpClient.Transport.(*http.Transport); ok { | |||
| 96 | + | transport = existing.Clone() | |||
| 97 | + | } else { | |||
| 98 | + | transport = http.DefaultTransport.(*http.Transport).Clone() | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | if transport.TLSClientConfig == nil { | |||
| 102 | + | transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} | |||
| 103 | + | } else { | |||
| 104 | + | transport.TLSClientConfig.MinVersion = tls.VersionTLS12 | |||
| 105 | + | } | |||
| 106 | + | //nolint:gosec // Explicit CLI opt-in via --insecure for self-signed/private cert deployments. | |||
| 107 | + | transport.TLSClientConfig.InsecureSkipVerify = true | |||
| 108 | + | c.httpClient.Transport = transport | |||
| 109 | + | } | |||
| 110 | + | ||||
| 69 | // request performs an HTTP request to the admin API. | 111 | // request performs an HTTP request to the admin API. | ||
| 70 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | 112 | func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) { | ||
| 71 | url := c.baseURL + "/admin" + path | 113 | url := c.baseURL + "/admin" + path | ||
| @@ -100,13 +142,7 @@ func parseResponse(resp *http.Response, target any) error { | |||||
| 100 | 142 | ||||
| 101 | if resp.StatusCode >= 400 { | 143 | if resp.StatusCode >= 400 { | ||
| 102 | body, _ := io.ReadAll(resp.Body) | 144 | body, _ := io.ReadAll(resp.Body) | ||
| 103 | - | var errResp struct { | 145 | + | return parseErrorResponse(resp, body) |
| 104 | - | Error string `json:"error"` | |||
| 105 | - | } | |||
| 106 | - | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 107 | - | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 108 | - | } | |||
| 109 | - | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 110 | } | 146 | } | ||
| 111 | 147 | ||||
| 112 | if target != nil { | 148 | if target != nil { | ||
| @@ -118,6 +154,74 @@ func parseResponse(resp *http.Response, target any) error { | |||||
| 118 | return nil | 154 | return nil | ||
| 119 | } | 155 | } | ||
| 120 | 156 | ||||
| 157 | + | func parseErrorResponse(resp *http.Response, body []byte) error { | |||
| 158 | + | var errResp struct { | |||
| 159 | + | Error string `json:"error"` | |||
| 160 | + | } | |||
| 161 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 162 | + | return fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 163 | + | } | |||
| 164 | + | return fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func isRetryableRequestError(err error) bool { | |||
| 168 | + | return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func isRetryableStatus(status int) bool { | |||
| 172 | + | // Only retry gateway errors; retrying 500 on non-idempotent POSTs | |||
| 173 | + | // (deploy, restart, reload) can cause duplicate state changes. | |||
| 174 | + | return status == 502 || status == 503 || status == 504 | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func retryDelay(attempt int) time.Duration { | |||
| 178 | + | if attempt <= 1 { | |||
| 179 | + | return retryBaseDelay | |||
| 180 | + | } | |||
| 181 | + | return retryBaseDelay * time.Duration(1<<(attempt-1)) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // requestWithRetry performs an HTTP request and retries transient failures. | |||
| 185 | + | // Retries occur on transport errors and gateway errors (502, 503, 504). | |||
| 186 | + | func (c *Client) requestWithRetry(ctx context.Context, method, path string, body any) (*http.Response, error) { | |||
| 187 | + | var lastErr error | |||
| 188 | + | ||||
| 189 | + | for attempt := 1; attempt <= retryMaxAttempts; attempt++ { | |||
| 190 | + | resp, err := c.request(ctx, method, path, body) | |||
| 191 | + | if err != nil { | |||
| 192 | + | lastErr = err | |||
| 193 | + | if attempt == retryMaxAttempts || !isRetryableRequestError(err) { | |||
| 194 | + | return nil, err | |||
| 195 | + | } | |||
| 196 | + | } else { | |||
| 197 | + | if !isRetryableStatus(resp.StatusCode) { | |||
| 198 | + | return resp, nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | respBody, _ := io.ReadAll(resp.Body) | |||
| 202 | + | _ = resp.Body.Close() | |||
| 203 | + | lastErr = parseErrorResponse(resp, respBody) | |||
| 204 | + | if attempt == retryMaxAttempts { | |||
| 205 | + | return nil, lastErr | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | delay := retryDelay(attempt) | |||
| 210 | + | timer := time.NewTimer(delay) | |||
| 211 | + | select { | |||
| 212 | + | case <-ctx.Done(): | |||
| 213 | + | timer.Stop() | |||
| 214 | + | return nil, ctx.Err() | |||
| 215 | + | case <-timer.C: | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | if lastErr != nil { | |||
| 220 | + | return nil, lastErr | |||
| 221 | + | } | |||
| 222 | + | return nil, fmt.Errorf("request failed after retries") | |||
| 223 | + | } | |||
| 224 | + | ||||
| 121 | // Routes API | 225 | // Routes API | ||
| 122 | 226 | ||||
| 123 | // Type aliases for API types using shared DTO package. | 227 | // Type aliases for API types using shared DTO package. | ||
| @@ -371,7 +475,7 @@ func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) | |||||
| 371 | 475 | ||||
| 372 | // Reload triggers a configuration reload. | 476 | // Reload triggers a configuration reload. | ||
| 373 | func (c *Client) Reload(ctx context.Context) error { | 477 | func (c *Client) Reload(ctx context.Context) error { | ||
| 374 | - | resp, err := c.request(ctx, http.MethodPost, "/reload", nil) | 478 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/reload", nil) |
| 375 | if err != nil { | 479 | if err != nil { | ||
| 376 | return err | 480 | return err | ||
| 377 | } | 481 | } | ||
| @@ -431,7 +535,7 @@ type DeployResult struct { | |||||
| 431 | 535 | ||||
| 432 | // Deploy triggers a deployment for the specified domain. | 536 | // Deploy triggers a deployment for the specified domain. | ||
| 433 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | 537 | func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult, error) { | ||
| 434 | - | resp, err := c.request(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) | 538 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, "/deploy/"+url.PathEscape(deployDomain), nil) |
| 435 | if err != nil { | 539 | if err != nil { | ||
| 436 | return nil, err | 540 | return nil, err | ||
| 437 | } | 541 | } | ||
| @@ -461,7 +565,7 @@ func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachme | |||||
| 461 | if withAttachments { | 565 | if withAttachments { | ||
| 462 | path += "?attachments=true" | 566 | path += "?attachments=true" | ||
| 463 | } | 567 | } | ||
| 464 | - | resp, err := c.request(ctx, http.MethodPost, path, nil) | 568 | + | resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil) |
| 465 | if err != nil { | 569 | if err != nil { | ||
| 466 | return nil, err | 570 | return nil, err | ||
| 467 | } | 571 | } | ||
A internal/adapters/in/cli/remote/client_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func withFastRetry(t *testing.T) { | |
| 16 | + | t.Helper() | |
| 17 | + | prevAttempts := retryMaxAttempts | |
| 18 | + | prevDelay := retryBaseDelay | |
| 19 | + | retryMaxAttempts = 3 | |
| 20 | + | retryBaseDelay = 10 * time.Millisecond | |
| 21 | + | t.Cleanup(func() { | |
| 22 | + | retryMaxAttempts = prevAttempts | |
| 23 | + | retryBaseDelay = prevDelay | |
| 24 | + | }) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestClientRestartRetriesOn5xx(t *testing.T) { | |
| 28 | + | withFastRetry(t) | |
| 29 | + | ||
| 30 | + | var attempts int32 | |
| 31 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 32 | + | require.Equal(t, "/admin/restart/test.example.com", r.URL.Path) | |
| 33 | + | require.Equal(t, http.MethodPost, r.Method) | |
| 34 | + | current := atomic.AddInt32(&attempts, 1) | |
| 35 | + | if current < 3 { | |
| 36 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 37 | + | _, _ = w.Write([]byte(`{"error":"temporary outage"}`)) | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | w.Header().Set("Content-Type", "application/json") | |
| 41 | + | _, _ = w.Write([]byte(`{"status":"restarted","domain":"test.example.com"}`)) | |
| 42 | + | })) | |
| 43 | + | defer srv.Close() | |
| 44 | + | ||
| 45 | + | client := NewClient(srv.URL) | |
| 46 | + | result, err := client.Restart(context.Background(), "test.example.com", false) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.NotNil(t, result) | |
| 49 | + | assert.Equal(t, "test.example.com", result.Domain) | |
| 50 | + | assert.EqualValues(t, 3, atomic.LoadInt32(&attempts)) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestClientReloadRetriesOn5xx(t *testing.T) { | |
| 54 | + | withFastRetry(t) | |
| 55 | + | ||
| 56 | + | var attempts int32 | |
| 57 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 58 | + | require.Equal(t, "/admin/reload", r.URL.Path) | |
| 59 | + | require.Equal(t, http.MethodPost, r.Method) | |
| 60 | + | current := atomic.AddInt32(&attempts, 1) | |
| 61 | + | if current < 2 { | |
| 62 | + | w.WriteHeader(http.StatusBadGateway) | |
| 63 | + | _, _ = w.Write([]byte(`upstream down`)) | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | w.Header().Set("Content-Type", "application/json") | |
| 67 | + | _, _ = w.Write([]byte(`{"status":"reloaded"}`)) | |
| 68 | + | })) | |
| 69 | + | defer srv.Close() | |
| 70 | + | ||
| 71 | + | client := NewClient(srv.URL) | |
| 72 | + | err := client.Reload(context.Background()) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | assert.EqualValues(t, 2, atomic.LoadInt32(&attempts)) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestClientDeployReturnsErrorAfterRetryExhaustion(t *testing.T) { | |
| 78 | + | withFastRetry(t) | |
| 79 | + | ||
| 80 | + | var attempts int32 | |
| 81 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 82 | + | require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path) | |
| 83 | + | require.Equal(t, http.MethodPost, r.Method) | |
| 84 | + | atomic.AddInt32(&attempts, 1) | |
| 85 | + | w.WriteHeader(http.StatusBadGateway) | |
| 86 | + | _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`)) | |
| 87 | + | })) | |
| 88 | + | defer srv.Close() | |
| 89 | + | ||
| 90 | + | client := NewClient(srv.URL) | |
| 91 | + | _, err := client.Deploy(context.Background(), "test.example.com") | |
| 92 | + | require.Error(t, err) | |
| 93 | + | assert.Contains(t, err.Error(), "502 Bad Gateway: upstream unavailable") | |
| 94 | + | assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts)) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |
| 98 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 99 | + | require.Equal(t, "/admin/status", r.URL.Path) | |
| 100 | + | w.Header().Set("Content-Type", "application/json") | |
| 101 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |
| 102 | + | })) | |
| 103 | + | defer srv.Close() | |
| 104 | + | ||
| 105 | + | _, err := NewClient(srv.URL).GetStatus(context.Background()) | |
| 106 | + | require.Error(t, err) | |
| 107 | + | ||
| 108 | + | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func withFastRetry(t *testing.T) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | prevAttempts := retryMaxAttempts | |||
| 18 | + | prevDelay := retryBaseDelay | |||
| 19 | + | retryMaxAttempts = 3 | |||
| 20 | + | retryBaseDelay = 10 * time.Millisecond | |||
| 21 | + | t.Cleanup(func() { | |||
| 22 | + | retryMaxAttempts = prevAttempts | |||
| 23 | + | retryBaseDelay = prevDelay | |||
| 24 | + | }) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestClientRestartRetriesOn5xx(t *testing.T) { | |||
| 28 | + | withFastRetry(t) | |||
| 29 | + | ||||
| 30 | + | var attempts int32 | |||
| 31 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 32 | + | require.Equal(t, "/admin/restart/test.example.com", r.URL.Path) | |||
| 33 | + | require.Equal(t, http.MethodPost, r.Method) | |||
| 34 | + | current := atomic.AddInt32(&attempts, 1) | |||
| 35 | + | if current < 3 { | |||
| 36 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 37 | + | _, _ = w.Write([]byte(`{"error":"temporary outage"}`)) | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | w.Header().Set("Content-Type", "application/json") | |||
| 41 | + | _, _ = w.Write([]byte(`{"status":"restarted","domain":"test.example.com"}`)) | |||
| 42 | + | })) | |||
| 43 | + | defer srv.Close() | |||
| 44 | + | ||||
| 45 | + | client := NewClient(srv.URL) | |||
| 46 | + | result, err := client.Restart(context.Background(), "test.example.com", false) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.NotNil(t, result) | |||
| 49 | + | assert.Equal(t, "test.example.com", result.Domain) | |||
| 50 | + | assert.EqualValues(t, 3, atomic.LoadInt32(&attempts)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestClientReloadRetriesOn5xx(t *testing.T) { | |||
| 54 | + | withFastRetry(t) | |||
| 55 | + | ||||
| 56 | + | var attempts int32 | |||
| 57 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 58 | + | require.Equal(t, "/admin/reload", r.URL.Path) | |||
| 59 | + | require.Equal(t, http.MethodPost, r.Method) | |||
| 60 | + | current := atomic.AddInt32(&attempts, 1) | |||
| 61 | + | if current < 2 { | |||
| 62 | + | w.WriteHeader(http.StatusBadGateway) | |||
| 63 | + | _, _ = w.Write([]byte(`upstream down`)) | |||
| 64 | + | return | |||
| 65 | + | } | |||
| 66 | + | w.Header().Set("Content-Type", "application/json") | |||
| 67 | + | _, _ = w.Write([]byte(`{"status":"reloaded"}`)) | |||
| 68 | + | })) | |||
| 69 | + | defer srv.Close() | |||
| 70 | + | ||||
| 71 | + | client := NewClient(srv.URL) | |||
| 72 | + | err := client.Reload(context.Background()) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | assert.EqualValues(t, 2, atomic.LoadInt32(&attempts)) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestClientDeployReturnsErrorAfterRetryExhaustion(t *testing.T) { | |||
| 78 | + | withFastRetry(t) | |||
| 79 | + | ||||
| 80 | + | var attempts int32 | |||
| 81 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 82 | + | require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path) | |||
| 83 | + | require.Equal(t, http.MethodPost, r.Method) | |||
| 84 | + | atomic.AddInt32(&attempts, 1) | |||
| 85 | + | w.WriteHeader(http.StatusBadGateway) | |||
| 86 | + | _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`)) | |||
| 87 | + | })) | |||
| 88 | + | defer srv.Close() | |||
| 89 | + | ||||
| 90 | + | client := NewClient(srv.URL) | |||
| 91 | + | _, err := client.Deploy(context.Background(), "test.example.com") | |||
| 92 | + | require.Error(t, err) | |||
| 93 | + | assert.Contains(t, err.Error(), "502 Bad Gateway: upstream unavailable") | |||
| 94 | + | assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts)) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 98 | + | srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 99 | + | require.Equal(t, "/admin/status", r.URL.Path) | |||
| 100 | + | w.Header().Set("Content-Type", "application/json") | |||
| 101 | + | _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`)) | |||
| 102 | + | })) | |||
| 103 | + | defer srv.Close() | |||
| 104 | + | ||||
| 105 | + | _, err := NewClient(srv.URL).GetStatus(context.Background()) | |||
| 106 | + | require.Error(t, err) | |||
| 107 | + | ||||
| 108 | + | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | } | |||
M internal/adapters/in/cli/remote/config.go +53 -16
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "strconv" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/pelletier/go-toml/v2" | |
| 9 | 10 | ) | |
| @@ -17,17 +18,19 @@ type ClientConfig struct { | |||
| 17 | 18 | ||
| 18 | 19 | // ClientSettings represents the [client] section. | |
| 19 | 20 | type ClientSettings struct { | |
| 20 | - | Mode string `toml:"mode"` // "local" (default) or "remote" | |
| 21 | - | Remote string `toml:"remote"` // Remote Gordon URL | |
| 22 | - | Token string `toml:"token"` // Auth token | |
| 23 | - | TokenEnv string `toml:"token_env"` // Env var name for token | |
| 21 | + | Mode string `toml:"mode"` // "local" (default) or "remote" | |
| 22 | + | Remote string `toml:"remote"` // Remote Gordon URL | |
| 23 | + | Token string `toml:"token"` // Auth token | |
| 24 | + | TokenEnv string `toml:"token_env"` // Env var name for token | |
| 25 | + | InsecureTLS bool `toml:"insecure_tls"` // Skip TLS verification for remote admin API | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | // RemoteEntry represents a saved remote in [remotes.*]. | |
| 27 | 29 | type RemoteEntry struct { | |
| 28 | - | URL string `toml:"url"` | |
| 29 | - | Token string `toml:"token,omitempty"` | |
| 30 | - | TokenEnv string `toml:"token_env,omitempty"` | |
| 30 | + | URL string `toml:"url"` | |
| 31 | + | Token string `toml:"token,omitempty"` | |
| 32 | + | TokenEnv string `toml:"token_env,omitempty"` | |
| 33 | + | InsecureTLS bool `toml:"insecure_tls,omitempty"` | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // DefaultClientConfigPath returns the default client config path. | |
| @@ -134,43 +137,44 @@ func SaveRemotes(path string, config *ClientConfig) error { | |||
| 134 | 137 | ||
| 135 | 138 | // ResolveRemote resolves the remote URL and token from configuration. | |
| 136 | 139 | // Precedence: flag > env > config > active remote | |
| 137 | - | func ResolveRemote(flagRemote, flagToken string) (url, token string, isRemote bool) { | |
| 140 | + | func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) { | |
| 138 | 141 | config, _ := LoadClientConfig("") | |
| 139 | 142 | remotes, _ := LoadRemotes("") | |
| 140 | 143 | ||
| 141 | - | url, isRemote = resolveRemoteURL(flagRemote, config, remotes) | |
| 144 | + | url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes) | |
| 142 | 145 | if isRemote { | |
| 143 | 146 | token = resolveToken(flagToken, config, remotes) | |
| 147 | + | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | |
| 144 | 148 | } | |
| 145 | 149 | ||
| 146 | - | return url, token, isRemote | |
| 150 | + | return url, token, insecureTLS, isRemote | |
| 147 | 151 | } | |
| 148 | 152 | ||
| 149 | 153 | // resolveRemoteURL resolves the remote URL from various sources. | |
| 150 | - | func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (string, bool) { | |
| 154 | + | func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (url, remoteName string, isRemote bool) { | |
| 151 | 155 | // 1. Check flag | |
| 152 | 156 | if flagRemote != "" { | |
| 153 | - | return flagRemote, true | |
| 157 | + | return flagRemote, "", true | |
| 154 | 158 | } | |
| 155 | 159 | ||
| 156 | 160 | // 2. Check environment variable | |
| 157 | 161 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | |
| 158 | - | return envRemote, true | |
| 162 | + | return envRemote, "", true | |
| 159 | 163 | } | |
| 160 | 164 | ||
| 161 | 165 | // 3. Check client config | |
| 162 | 166 | if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" { | |
| 163 | - | return config.Client.Remote, true | |
| 167 | + | return config.Client.Remote, "", true | |
| 164 | 168 | } | |
| 165 | 169 | ||
| 166 | 170 | // 4. Check active remote | |
| 167 | 171 | if remotes != nil && remotes.Active != "" { | |
| 168 | 172 | if remote, ok := remotes.Remotes[remotes.Active]; ok { | |
| 169 | - | return remote.URL, true | |
| 173 | + | return remote.URL, remotes.Active, true | |
| 170 | 174 | } | |
| 171 | 175 | } | |
| 172 | 176 | ||
| 173 | - | return "", false | |
| 177 | + | return "", "", false | |
| 174 | 178 | } | |
| 175 | 179 | ||
| 176 | 180 | // resolveToken resolves the authentication token from various sources. | |
| @@ -210,6 +214,39 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) | |||
| 210 | 214 | return "" | |
| 211 | 215 | } | |
| 212 | 216 | ||
| 217 | + | // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote. | |
| 218 | + | // Precedence: flag > env > client config > specific remote config. | |
| 219 | + | func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool { | |
| 220 | + | config, _ := LoadClientConfig("") | |
| 221 | + | remotes, _ := LoadRemotes("") | |
| 222 | + | return resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func resolveInsecureTLS(flagInsecure bool, config *ClientConfig, remotes *ClientConfig, remoteName string) bool { | |
| 226 | + | if flagInsecure { | |
| 227 | + | return true | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | if env := os.Getenv("GORDON_INSECURE"); env != "" { | |
| 231 | + | if value, err := strconv.ParseBool(env); err == nil { | |
| 232 | + | return value | |
| 233 | + | } | |
| 234 | + | fmt.Fprintf(os.Stderr, "WARNING: invalid GORDON_INSECURE value %q, ignoring\n", env) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | if config != nil && config.Client.InsecureTLS { | |
| 238 | + | return true | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | if remoteName != "" && remotes != nil { | |
| 242 | + | if remote, ok := remotes.Remotes[remoteName]; ok && remote.InsecureTLS { | |
| 243 | + | return true | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | return false | |
| 248 | + | } | |
| 249 | + | ||
| 213 | 250 | // AddRemote adds a new remote to the remotes configuration. | |
| 214 | 251 | func AddRemote(name, url, token string) error { | |
| 215 | 252 | config, err := LoadRemotes("") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | "strconv" | |||
| 7 | 8 | ||||
| 8 | "github.com/pelletier/go-toml/v2" | 9 | "github.com/pelletier/go-toml/v2" | ||
| 9 | ) | 10 | ) | ||
| @@ -17,17 +18,19 @@ type ClientConfig struct { | |||||
| 17 | 18 | ||||
| 18 | // ClientSettings represents the [client] section. | 19 | // ClientSettings represents the [client] section. | ||
| 19 | type ClientSettings struct { | 20 | type ClientSettings struct { | ||
| 20 | - | Mode string `toml:"mode"` // "local" (default) or "remote" | 21 | + | Mode string `toml:"mode"` // "local" (default) or "remote" |
| 21 | - | Remote string `toml:"remote"` // Remote Gordon URL | 22 | + | Remote string `toml:"remote"` // Remote Gordon URL |
| 22 | - | Token string `toml:"token"` // Auth token | 23 | + | Token string `toml:"token"` // Auth token |
| 23 | - | TokenEnv string `toml:"token_env"` // Env var name for token | 24 | + | TokenEnv string `toml:"token_env"` // Env var name for token |
| 25 | + | InsecureTLS bool `toml:"insecure_tls"` // Skip TLS verification for remote admin API | |||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | // RemoteEntry represents a saved remote in [remotes.*]. | 28 | // RemoteEntry represents a saved remote in [remotes.*]. | ||
| 27 | type RemoteEntry struct { | 29 | type RemoteEntry struct { | ||
| 28 | - | URL string `toml:"url"` | 30 | + | URL string `toml:"url"` |
| 29 | - | Token string `toml:"token,omitempty"` | 31 | + | Token string `toml:"token,omitempty"` |
| 30 | - | TokenEnv string `toml:"token_env,omitempty"` | 32 | + | TokenEnv string `toml:"token_env,omitempty"` |
| 33 | + | InsecureTLS bool `toml:"insecure_tls,omitempty"` | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // DefaultClientConfigPath returns the default client config path. | 36 | // DefaultClientConfigPath returns the default client config path. | ||
| @@ -134,43 +137,44 @@ func SaveRemotes(path string, config *ClientConfig) error { | |||||
| 134 | 137 | ||||
| 135 | // ResolveRemote resolves the remote URL and token from configuration. | 138 | // ResolveRemote resolves the remote URL and token from configuration. | ||
| 136 | // Precedence: flag > env > config > active remote | 139 | // Precedence: flag > env > config > active remote | ||
| 137 | - | func ResolveRemote(flagRemote, flagToken string) (url, token string, isRemote bool) { | 140 | + | func ResolveRemote(flagRemote, flagToken string, flagInsecure bool) (url, token string, insecureTLS, isRemote bool) { |
| 138 | config, _ := LoadClientConfig("") | 141 | config, _ := LoadClientConfig("") | ||
| 139 | remotes, _ := LoadRemotes("") | 142 | remotes, _ := LoadRemotes("") | ||
| 140 | 143 | ||||
| 141 | - | url, isRemote = resolveRemoteURL(flagRemote, config, remotes) | 144 | + | url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes) |
| 142 | if isRemote { | 145 | if isRemote { | ||
| 143 | token = resolveToken(flagToken, config, remotes) | 146 | token = resolveToken(flagToken, config, remotes) | ||
| 147 | + | insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | |||
| 144 | } | 148 | } | ||
| 145 | 149 | ||||
| 146 | - | return url, token, isRemote | 150 | + | return url, token, insecureTLS, isRemote |
| 147 | } | 151 | } | ||
| 148 | 152 | ||||
| 149 | // resolveRemoteURL resolves the remote URL from various sources. | 153 | // resolveRemoteURL resolves the remote URL from various sources. | ||
| 150 | - | func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (string, bool) { | 154 | + | func resolveRemoteURL(flagRemote string, config *ClientConfig, remotes *ClientConfig) (url, remoteName string, isRemote bool) { |
| 151 | // 1. Check flag | 155 | // 1. Check flag | ||
| 152 | if flagRemote != "" { | 156 | if flagRemote != "" { | ||
| 153 | - | return flagRemote, true | 157 | + | return flagRemote, "", true |
| 154 | } | 158 | } | ||
| 155 | 159 | ||||
| 156 | // 2. Check environment variable | 160 | // 2. Check environment variable | ||
| 157 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | 161 | if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" { | ||
| 158 | - | return envRemote, true | 162 | + | return envRemote, "", true |
| 159 | } | 163 | } | ||
| 160 | 164 | ||||
| 161 | // 3. Check client config | 165 | // 3. Check client config | ||
| 162 | if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" { | 166 | if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" { | ||
| 163 | - | return config.Client.Remote, true | 167 | + | return config.Client.Remote, "", true |
| 164 | } | 168 | } | ||
| 165 | 169 | ||||
| 166 | // 4. Check active remote | 170 | // 4. Check active remote | ||
| 167 | if remotes != nil && remotes.Active != "" { | 171 | if remotes != nil && remotes.Active != "" { | ||
| 168 | if remote, ok := remotes.Remotes[remotes.Active]; ok { | 172 | if remote, ok := remotes.Remotes[remotes.Active]; ok { | ||
| 169 | - | return remote.URL, true | 173 | + | return remote.URL, remotes.Active, true |
| 170 | } | 174 | } | ||
| 171 | } | 175 | } | ||
| 172 | 176 | ||||
| 173 | - | return "", false | 177 | + | return "", "", false |
| 174 | } | 178 | } | ||
| 175 | 179 | ||||
| 176 | // resolveToken resolves the authentication token from various sources. | 180 | // resolveToken resolves the authentication token from various sources. | ||
| @@ -210,6 +214,39 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig) | |||||
| 210 | return "" | 214 | return "" | ||
| 211 | } | 215 | } | ||
| 212 | 216 | ||||
| 217 | + | // ResolveInsecureTLSForRemote resolves insecure TLS behavior for a named remote. | |||
| 218 | + | // Precedence: flag > env > client config > specific remote config. | |||
| 219 | + | func ResolveInsecureTLSForRemote(flagInsecure bool, remoteName string) bool { | |||
| 220 | + | config, _ := LoadClientConfig("") | |||
| 221 | + | remotes, _ := LoadRemotes("") | |||
| 222 | + | return resolveInsecureTLS(flagInsecure, config, remotes, remoteName) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func resolveInsecureTLS(flagInsecure bool, config *ClientConfig, remotes *ClientConfig, remoteName string) bool { | |||
| 226 | + | if flagInsecure { | |||
| 227 | + | return true | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | if env := os.Getenv("GORDON_INSECURE"); env != "" { | |||
| 231 | + | if value, err := strconv.ParseBool(env); err == nil { | |||
| 232 | + | return value | |||
| 233 | + | } | |||
| 234 | + | fmt.Fprintf(os.Stderr, "WARNING: invalid GORDON_INSECURE value %q, ignoring\n", env) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | if config != nil && config.Client.InsecureTLS { | |||
| 238 | + | return true | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | if remoteName != "" && remotes != nil { | |||
| 242 | + | if remote, ok := remotes.Remotes[remoteName]; ok && remote.InsecureTLS { | |||
| 243 | + | return true | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | return false | |||
| 248 | + | } | |||
| 249 | + | ||||
| 213 | // AddRemote adds a new remote to the remotes configuration. | 250 | // AddRemote adds a new remote to the remotes configuration. | ||
| 214 | func AddRemote(name, url, token string) error { | 251 | func AddRemote(name, url, token string) error { | ||
| 215 | config, err := LoadRemotes("") | 252 | config, err := LoadRemotes("") | ||
A internal/adapters/in/cli/remote/config_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package remote | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestResolveRemote_InsecureTLSFromClientConfig(t *testing.T) { | |
| 10 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 11 | + | ||
| 12 | + | cfg := []byte(` | |
| 13 | + | [client] | |
| 14 | + | mode = "remote" | |
| 15 | + | remote = "https://gordon.example.com" | |
| 16 | + | insecure_tls = true | |
| 17 | + | `) | |
| 18 | + | path := DefaultClientConfigPath() | |
| 19 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |
| 20 | + | t.Fatalf("mkdir config dir: %v", err) | |
| 21 | + | } | |
| 22 | + | if err := os.WriteFile(path, cfg, 0o600); err != nil { | |
| 23 | + | t.Fatalf("write config: %v", err) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | url, _, insecure, isRemote := ResolveRemote("", "", false) | |
| 27 | + | if !isRemote { | |
| 28 | + | t.Fatalf("expected remote mode") | |
| 29 | + | } | |
| 30 | + | if url != "https://gordon.example.com" { | |
| 31 | + | t.Fatalf("unexpected url: %s", url) | |
| 32 | + | } | |
| 33 | + | if !insecure { | |
| 34 | + | t.Fatalf("expected insecure TLS true from client config") | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestResolveInsecureTLSForRemote_FromRemoteEntry(t *testing.T) { | |
| 39 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 40 | + | ||
| 41 | + | remotes := &ClientConfig{ | |
| 42 | + | Active: "prod", | |
| 43 | + | Remotes: map[string]RemoteEntry{ | |
| 44 | + | "prod": { | |
| 45 | + | URL: "https://gordon.example.com", | |
| 46 | + | InsecureTLS: true, | |
| 47 | + | }, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | if err := SaveRemotes("", remotes); err != nil { | |
| 51 | + | t.Fatalf("save remotes: %v", err) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if !ResolveInsecureTLSForRemote(false, "prod") { | |
| 55 | + | t.Fatalf("expected insecure TLS true from remote entry") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestResolveInsecureTLSForRemote_DefaultSecure(t *testing.T) { | |
| 60 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 61 | + | ||
| 62 | + | // With no flag, no env, and no config, TLS should be secure by default | |
| 63 | + | if ResolveInsecureTLSForRemote(false, "") { | |
| 64 | + | t.Fatalf("expected insecure TLS false by default") | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestResolveInsecureTLSForRemote_EnvAndFlagPrecedence(t *testing.T) { | |
| 69 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |
| 70 | + | t.Setenv("GORDON_INSECURE", "true") | |
| 71 | + | ||
| 72 | + | // env=true should enable when flag is false | |
| 73 | + | if !ResolveInsecureTLSForRemote(false, "") { | |
| 74 | + | t.Fatalf("expected insecure TLS true from env") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // flag=true should always enable | |
| 78 | + | if !ResolveInsecureTLSForRemote(true, "") { | |
| 79 | + | t.Fatalf("expected insecure TLS true from flag") | |
| 80 | + | } | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package remote | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestResolveRemote_InsecureTLSFromClientConfig(t *testing.T) { | |||
| 10 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 11 | + | ||||
| 12 | + | cfg := []byte(` | |||
| 13 | + | [client] | |||
| 14 | + | mode = "remote" | |||
| 15 | + | remote = "https://gordon.example.com" | |||
| 16 | + | insecure_tls = true | |||
| 17 | + | `) | |||
| 18 | + | path := DefaultClientConfigPath() | |||
| 19 | + | if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { | |||
| 20 | + | t.Fatalf("mkdir config dir: %v", err) | |||
| 21 | + | } | |||
| 22 | + | if err := os.WriteFile(path, cfg, 0o600); err != nil { | |||
| 23 | + | t.Fatalf("write config: %v", err) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | url, _, insecure, isRemote := ResolveRemote("", "", false) | |||
| 27 | + | if !isRemote { | |||
| 28 | + | t.Fatalf("expected remote mode") | |||
| 29 | + | } | |||
| 30 | + | if url != "https://gordon.example.com" { | |||
| 31 | + | t.Fatalf("unexpected url: %s", url) | |||
| 32 | + | } | |||
| 33 | + | if !insecure { | |||
| 34 | + | t.Fatalf("expected insecure TLS true from client config") | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestResolveInsecureTLSForRemote_FromRemoteEntry(t *testing.T) { | |||
| 39 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 40 | + | ||||
| 41 | + | remotes := &ClientConfig{ | |||
| 42 | + | Active: "prod", | |||
| 43 | + | Remotes: map[string]RemoteEntry{ | |||
| 44 | + | "prod": { | |||
| 45 | + | URL: "https://gordon.example.com", | |||
| 46 | + | InsecureTLS: true, | |||
| 47 | + | }, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | if err := SaveRemotes("", remotes); err != nil { | |||
| 51 | + | t.Fatalf("save remotes: %v", err) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | if !ResolveInsecureTLSForRemote(false, "prod") { | |||
| 55 | + | t.Fatalf("expected insecure TLS true from remote entry") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestResolveInsecureTLSForRemote_DefaultSecure(t *testing.T) { | |||
| 60 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 61 | + | ||||
| 62 | + | // With no flag, no env, and no config, TLS should be secure by default | |||
| 63 | + | if ResolveInsecureTLSForRemote(false, "") { | |||
| 64 | + | t.Fatalf("expected insecure TLS false by default") | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestResolveInsecureTLSForRemote_EnvAndFlagPrecedence(t *testing.T) { | |||
| 69 | + | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 70 | + | t.Setenv("GORDON_INSECURE", "true") | |||
| 71 | + | ||||
| 72 | + | // env=true should enable when flag is false | |||
| 73 | + | if !ResolveInsecureTLSForRemote(false, "") { | |||
| 74 | + | t.Fatalf("expected insecure TLS true from env") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // flag=true should always enable | |||
| 78 | + | if !ResolveInsecureTLSForRemote(true, "") { | |||
| 79 | + | t.Fatalf("expected insecure TLS true from flag") | |||
| 80 | + | } | |||
| 81 | + | } | |||
M internal/adapters/in/cli/restart.go +19 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/spf13/cobra" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 9 | + | "github.com/bnema/gordon/internal/app" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | func newRestartCmd() *cobra.Command { | |
| @@ -29,11 +30,28 @@ Examples: | |||
| 29 | 30 | ||
| 30 | 31 | client, isRemote := GetRemoteClient() | |
| 31 | 32 | if !isRemote { | |
| 32 | - | return fmt.Errorf("restart requires remote mode") | |
| 33 | + | if withAttachments { | |
| 34 | + | return fmt.Errorf("local restart does not support --with-attachments; use --remote") | |
| 35 | + | } | |
| 36 | + | domain, err := app.SendDeploySignal(restartDomain) | |
| 37 | + | if err != nil { | |
| 38 | + | return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err) | |
| 39 | + | } | |
| 40 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |
| 41 | + | return nil | |
| 33 | 42 | } | |
| 34 | 43 | ||
| 35 | 44 | result, err := client.Restart(ctx, restartDomain, withAttachments) | |
| 36 | 45 | if err != nil { | |
| 46 | + | if !withAttachments && shouldFallbackToLocal(err) { | |
| 47 | + | domain, localErr := app.SendDeploySignal(restartDomain) | |
| 48 | + | if localErr == nil { | |
| 49 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |
| 50 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | return fmt.Errorf("remote restart failed: %w; local fallback failed: %v", err, localErr) | |
| 54 | + | } | |
| 37 | 55 | return fmt.Errorf("failed to restart: %w", err) | |
| 38 | 56 | } | |
| 39 | 57 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "github.com/spf13/cobra" | 6 | "github.com/spf13/cobra" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 8 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 9 | + | "github.com/bnema/gordon/internal/app" | |||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | func newRestartCmd() *cobra.Command { | 12 | func newRestartCmd() *cobra.Command { | ||
| @@ -29,11 +30,28 @@ Examples: | |||||
| 29 | 30 | ||||
| 30 | client, isRemote := GetRemoteClient() | 31 | client, isRemote := GetRemoteClient() | ||
| 31 | if !isRemote { | 32 | if !isRemote { | ||
| 32 | - | return fmt.Errorf("restart requires remote mode") | 33 | + | if withAttachments { |
| 34 | + | return fmt.Errorf("local restart does not support --with-attachments; use --remote") | |||
| 35 | + | } | |||
| 36 | + | domain, err := app.SendDeploySignal(restartDomain) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return fmt.Errorf("failed to trigger local restart via deploy signal: %w", err) | |||
| 39 | + | } | |||
| 40 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |||
| 41 | + | return nil | |||
| 33 | } | 42 | } | ||
| 34 | 43 | ||||
| 35 | result, err := client.Restart(ctx, restartDomain, withAttachments) | 44 | result, err := client.Restart(ctx, restartDomain, withAttachments) | ||
| 36 | if err != nil { | 45 | if err != nil { | ||
| 46 | + | if !withAttachments && shouldFallbackToLocal(err) { | |||
| 47 | + | domain, localErr := app.SendDeploySignal(restartDomain) | |||
| 48 | + | if localErr == nil { | |||
| 49 | + | fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err))) | |||
| 50 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain))) | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | return fmt.Errorf("remote restart failed: %w; local fallback failed: %v", err, localErr) | |||
| 54 | + | } | |||
| 37 | return fmt.Errorf("failed to restart: %w", err) | 55 | return fmt.Errorf("failed to restart: %w", err) | ||
| 38 | } | 56 | } | ||
| 39 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | 57 | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | ||
M internal/adapters/in/cli/root.go +27 -5
| @@ -22,8 +22,9 @@ var ( | |||
| 22 | 22 | BuildDate = "unknown" | |
| 23 | 23 | ||
| 24 | 24 | // Global flags for remote targeting | |
| 25 | - | remoteFlag string | |
| 26 | - | tokenFlag string | |
| 25 | + | remoteFlag string | |
| 26 | + | tokenFlag string | |
| 27 | + | insecureTLSFlag bool | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | 30 | // Command group IDs | |
| @@ -60,6 +61,7 @@ Commands are organized by where they run: | |||
| 60 | 61 | // Add persistent flags for remote targeting | |
| 61 | 62 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | |
| 62 | 63 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | |
| 64 | + | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | |
| 63 | 65 | ||
| 64 | 66 | // Server-only commands (must run on the Gordon host) | |
| 65 | 67 | serveCmd := newServeCmd() | |
| @@ -134,18 +136,29 @@ Commands are organized by where they run: | |||
| 134 | 136 | // GetRemoteClient returns a remote client if targeting a remote instance, | |
| 135 | 137 | // or nil if running locally. | |
| 136 | 138 | func GetRemoteClient() (*remote.Client, bool) { | |
| 137 | - | url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |
| 139 | + | url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 138 | 140 | if !isRemote { | |
| 139 | 141 | return nil, false | |
| 140 | 142 | } | |
| 141 | 143 | ||
| 142 | - | client := remote.NewClient(url, remote.WithToken(token)) | |
| 144 | + | client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...) | |
| 143 | 145 | return client, true | |
| 144 | 146 | } | |
| 145 | 147 | ||
| 148 | + | func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption { | |
| 149 | + | opts := make([]remote.ClientOption, 0, 2) | |
| 150 | + | if token != "" { | |
| 151 | + | opts = append(opts, remote.WithToken(token)) | |
| 152 | + | } | |
| 153 | + | if insecureTLS { | |
| 154 | + | opts = append(opts, remote.WithInsecureTLS(true)) | |
| 155 | + | } | |
| 156 | + | return opts | |
| 157 | + | } | |
| 158 | + | ||
| 146 | 159 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | |
| 147 | 160 | func IsRemoteMode() bool { | |
| 148 | - | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | |
| 161 | + | _, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 149 | 162 | return isRemote | |
| 150 | 163 | } | |
| 151 | 164 | ||
| @@ -190,6 +203,15 @@ func runReload() error { | |||
| 190 | 203 | // runReloadRemote triggers a reload on a remote Gordon instance. | |
| 191 | 204 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | |
| 192 | 205 | if err := client.Reload(ctx); err != nil { | |
| 206 | + | if shouldFallbackToLocal(err) { | |
| 207 | + | localErr := runReload() | |
| 208 | + | if localErr == nil { | |
| 209 | + | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |
| 210 | + | fmt.Println("Configuration reloaded successfully") | |
| 211 | + | return nil | |
| 212 | + | } | |
| 213 | + | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | |
| 214 | + | } | |
| 193 | 215 | return fmt.Errorf("failed to reload: %w", err) | |
| 194 | 216 | } | |
| 195 | 217 | fmt.Println("Configuration reloaded successfully") | |
| @@ -22,8 +22,9 @@ var ( | |||||
| 22 | BuildDate = "unknown" | 22 | BuildDate = "unknown" | ||
| 23 | 23 | ||||
| 24 | // Global flags for remote targeting | 24 | // Global flags for remote targeting | ||
| 25 | - | remoteFlag string | 25 | + | remoteFlag string |
| 26 | - | tokenFlag string | 26 | + | tokenFlag string |
| 27 | + | insecureTLSFlag bool | |||
| 27 | ) | 28 | ) | ||
| 28 | 29 | ||||
| 29 | // Command group IDs | 30 | // Command group IDs | ||
| @@ -60,6 +61,7 @@ Commands are organized by where they run: | |||||
| 60 | // Add persistent flags for remote targeting | 61 | // Add persistent flags for remote targeting | ||
| 61 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | 62 | rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)") | ||
| 62 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | 63 | rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote") | ||
| 64 | + | rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints") | |||
| 63 | 65 | ||||
| 64 | // Server-only commands (must run on the Gordon host) | 66 | // Server-only commands (must run on the Gordon host) | ||
| 65 | serveCmd := newServeCmd() | 67 | serveCmd := newServeCmd() | ||
| @@ -134,18 +136,29 @@ Commands are organized by where they run: | |||||
| 134 | // GetRemoteClient returns a remote client if targeting a remote instance, | 136 | // GetRemoteClient returns a remote client if targeting a remote instance, | ||
| 135 | // or nil if running locally. | 137 | // or nil if running locally. | ||
| 136 | func GetRemoteClient() (*remote.Client, bool) { | 138 | func GetRemoteClient() (*remote.Client, bool) { | ||
| 137 | - | url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | 139 | + | url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) |
| 138 | if !isRemote { | 140 | if !isRemote { | ||
| 139 | return nil, false | 141 | return nil, false | ||
| 140 | } | 142 | } | ||
| 141 | 143 | ||||
| 142 | - | client := remote.NewClient(url, remote.WithToken(token)) | 144 | + | client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...) |
| 143 | return client, true | 145 | return client, true | ||
| 144 | } | 146 | } | ||
| 145 | 147 | ||||
| 148 | + | func remoteClientOptions(token string, insecureTLS bool) []remote.ClientOption { | |||
| 149 | + | opts := make([]remote.ClientOption, 0, 2) | |||
| 150 | + | if token != "" { | |||
| 151 | + | opts = append(opts, remote.WithToken(token)) | |||
| 152 | + | } | |||
| 153 | + | if insecureTLS { | |||
| 154 | + | opts = append(opts, remote.WithInsecureTLS(true)) | |||
| 155 | + | } | |||
| 156 | + | return opts | |||
| 157 | + | } | |||
| 158 | + | ||||
| 146 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | 159 | // IsRemoteMode returns true if CLI is targeting a remote Gordon instance. | ||
| 147 | func IsRemoteMode() bool { | 160 | func IsRemoteMode() bool { | ||
| 148 | - | _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag) | 161 | + | _, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) |
| 149 | return isRemote | 162 | return isRemote | ||
| 150 | } | 163 | } | ||
| 151 | 164 | ||||
| @@ -190,6 +203,15 @@ func runReload() error { | |||||
| 190 | // runReloadRemote triggers a reload on a remote Gordon instance. | 203 | // runReloadRemote triggers a reload on a remote Gordon instance. | ||
| 191 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | 204 | func runReloadRemote(ctx context.Context, client *remote.Client) error { | ||
| 192 | if err := client.Reload(ctx); err != nil { | 205 | if err := client.Reload(ctx); err != nil { | ||
| 206 | + | if shouldFallbackToLocal(err) { | |||
| 207 | + | localErr := runReload() | |||
| 208 | + | if localErr == nil { | |||
| 209 | + | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |||
| 210 | + | fmt.Println("Configuration reloaded successfully") | |||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | |||
| 214 | + | } | |||
| 193 | return fmt.Errorf("failed to reload: %w", err) | 215 | return fmt.Errorf("failed to reload: %w", err) | ||
| 194 | } | 216 | } | ||
| 195 | fmt.Println("Configuration reloaded successfully") | 217 | fmt.Println("Configuration reloaded successfully") | ||
M internal/adapters/in/http/admin/handler.go +2 -2
| @@ -837,8 +837,8 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 837 | 837 | return | |
| 838 | 838 | } | |
| 839 | 839 | ||
| 840 | - | // Deploy the container | |
| 841 | - | container, err := h.containerSvc.Deploy(ctx, *route) | |
| 840 | + | // Deploy is an internal server-side action: pull from local registry path. | |
| 841 | + | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 842 | 842 | if err != nil { | |
| 843 | 843 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | |
| 844 | 844 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | |
| @@ -837,8 +837,8 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 837 | return | 837 | return | ||
| 838 | } | 838 | } | ||
| 839 | 839 | ||||
| 840 | - | // Deploy the container | 840 | + | // Deploy is an internal server-side action: pull from local registry path. |
| 841 | - | container, err := h.containerSvc.Deploy(ctx, *route) | 841 | + | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) |
| 842 | if err != nil { | 842 | if err != nil { | ||
| 843 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | 843 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | ||
| 844 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | 844 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | ||
M internal/adapters/in/http/registry/handler.go +22 -21
| @@ -22,26 +22,33 @@ import ( | |||
| 22 | 22 | const ( | |
| 23 | 23 | // MaxManifestSize limits manifest uploads to 10MB. | |
| 24 | 24 | MaxManifestSize = 10 * 1024 * 1024 | |
| 25 | - | // MaxBlobChunkSize limits individual blob chunks to 95MB. | |
| 26 | - | // Kept below 100MB to leave headroom for HTTP overhead when behind | |
| 27 | - | // proxies like Cloudflare that enforce a 100MB per-request limit. | |
| 28 | - | MaxBlobChunkSize = 95 * 1024 * 1024 | |
| 25 | + | // DefaultMaxBlobChunkSize is the default maximum size for a single blob upload chunk. | |
| 26 | + | // Kept at 95MB to stay within Cloudflare's 100MB per-request limit. | |
| 27 | + | // Users who need larger chunks can configure it explicitly via max_blob_chunk_size. | |
| 28 | + | DefaultMaxBlobChunkSize = 95 * 1024 * 1024 | |
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | 31 | // Handler implements the HTTP handler for Docker Registry API v2. | |
| 32 | 32 | type Handler struct { | |
| 33 | - | registrySvc in.RegistryService | |
| 34 | - | log zerowrap.Logger | |
| 33 | + | registrySvc in.RegistryService | |
| 34 | + | log zerowrap.Logger | |
| 35 | + | maxBlobChunkSize int64 | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | // NewHandler creates a new registry HTTP handler. | |
| 38 | 39 | func NewHandler( | |
| 39 | 40 | registrySvc in.RegistryService, | |
| 40 | 41 | log zerowrap.Logger, | |
| 42 | + | maxBlobChunkSize int64, | |
| 41 | 43 | ) *Handler { | |
| 44 | + | if maxBlobChunkSize <= 0 { | |
| 45 | + | maxBlobChunkSize = DefaultMaxBlobChunkSize | |
| 46 | + | } | |
| 47 | + | ||
| 42 | 48 | return &Handler{ | |
| 43 | - | registrySvc: registrySvc, | |
| 44 | - | log: log, | |
| 49 | + | registrySvc: registrySvc, | |
| 50 | + | log: log, | |
| 51 | + | maxBlobChunkSize: maxBlobChunkSize, | |
| 45 | 52 | } | |
| 46 | 53 | } | |
| 47 | 54 | ||
| @@ -400,26 +407,20 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||
| 400 | 407 | } | |
| 401 | 408 | } | |
| 402 | 409 | ||
| 403 | - | // Limit blob chunk size to prevent memory exhaustion | |
| 404 | - | r.Body = http.MaxBytesReader(w, r.Body, MaxBlobChunkSize) | |
| 410 | + | // Limit blob chunk size to prevent excessive uploads. | |
| 411 | + | // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit. | |
| 412 | + | r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize) | |
| 405 | 413 | ||
| 406 | - | // Read the chunk from the request body | |
| 407 | - | chunk, err := io.ReadAll(r.Body) | |
| 414 | + | // Stream the body directly to storage — memory usage is bounded to a small | |
| 415 | + | // copy buffer (~32KB) regardless of chunk size. | |
| 416 | + | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body) | |
| 408 | 417 | if err != nil { | |
| 409 | 418 | var maxBytesErr *http.MaxBytesError | |
| 410 | 419 | if errors.As(err, &maxBytesErr) { | |
| 411 | - | log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large") | |
| 420 | + | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") | |
| 412 | 421 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | |
| 413 | 422 | return | |
| 414 | 423 | } | |
| 415 | - | log.Error().Err(err).Msg("failed to read blob chunk") | |
| 416 | - | h.sendRegistryError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "invalid blob chunk") | |
| 417 | - | return | |
| 418 | - | } | |
| 419 | - | ||
| 420 | - | // Append the chunk to the upload | |
| 421 | - | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk) | |
| 422 | - | if err != nil { | |
| 423 | 424 | log.Error().Err(err).Msg("failed to append blob chunk") | |
| 424 | 425 | h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk") | |
| 425 | 426 | return | |
| @@ -22,26 +22,33 @@ import ( | |||||
| 22 | const ( | 22 | const ( | ||
| 23 | // MaxManifestSize limits manifest uploads to 10MB. | 23 | // MaxManifestSize limits manifest uploads to 10MB. | ||
| 24 | MaxManifestSize = 10 * 1024 * 1024 | 24 | MaxManifestSize = 10 * 1024 * 1024 | ||
| 25 | - | // MaxBlobChunkSize limits individual blob chunks to 95MB. | 25 | + | // DefaultMaxBlobChunkSize is the default maximum size for a single blob upload chunk. |
| 26 | - | // Kept below 100MB to leave headroom for HTTP overhead when behind | 26 | + | // Kept at 95MB to stay within Cloudflare's 100MB per-request limit. |
| 27 | - | // proxies like Cloudflare that enforce a 100MB per-request limit. | 27 | + | // Users who need larger chunks can configure it explicitly via max_blob_chunk_size. |
| 28 | - | MaxBlobChunkSize = 95 * 1024 * 1024 | 28 | + | DefaultMaxBlobChunkSize = 95 * 1024 * 1024 |
| 29 | ) | 29 | ) | ||
| 30 | 30 | ||||
| 31 | // Handler implements the HTTP handler for Docker Registry API v2. | 31 | // Handler implements the HTTP handler for Docker Registry API v2. | ||
| 32 | type Handler struct { | 32 | type Handler struct { | ||
| 33 | - | registrySvc in.RegistryService | 33 | + | registrySvc in.RegistryService |
| 34 | - | log zerowrap.Logger | 34 | + | log zerowrap.Logger |
| 35 | + | maxBlobChunkSize int64 | |||
| 35 | } | 36 | } | ||
| 36 | 37 | ||||
| 37 | // NewHandler creates a new registry HTTP handler. | 38 | // NewHandler creates a new registry HTTP handler. | ||
| 38 | func NewHandler( | 39 | func NewHandler( | ||
| 39 | registrySvc in.RegistryService, | 40 | registrySvc in.RegistryService, | ||
| 40 | log zerowrap.Logger, | 41 | log zerowrap.Logger, | ||
| 42 | + | maxBlobChunkSize int64, | |||
| 41 | ) *Handler { | 43 | ) *Handler { | ||
| 44 | + | if maxBlobChunkSize <= 0 { | |||
| 45 | + | maxBlobChunkSize = DefaultMaxBlobChunkSize | |||
| 46 | + | } | |||
| 47 | + | ||||
| 42 | return &Handler{ | 48 | return &Handler{ | ||
| 43 | - | registrySvc: registrySvc, | 49 | + | registrySvc: registrySvc, |
| 44 | - | log: log, | 50 | + | log: log, |
| 51 | + | maxBlobChunkSize: maxBlobChunkSize, | |||
| 45 | } | 52 | } | ||
| 46 | } | 53 | } | ||
| 47 | 54 | ||||
| @@ -400,26 +407,20 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |||||
| 400 | } | 407 | } | ||
| 401 | } | 408 | } | ||
| 402 | 409 | ||||
| 403 | - | // Limit blob chunk size to prevent memory exhaustion | 410 | + | // Limit blob chunk size to prevent excessive uploads. |
| 404 | - | r.Body = http.MaxBytesReader(w, r.Body, MaxBlobChunkSize) | 411 | + | // MaxBytesReader wraps the body so the downstream io.Copy stops at the limit. |
| 412 | + | r.Body = http.MaxBytesReader(w, r.Body, h.maxBlobChunkSize) | |||
| 405 | 413 | ||||
| 406 | - | // Read the chunk from the request body | 414 | + | // Stream the body directly to storage — memory usage is bounded to a small |
| 407 | - | chunk, err := io.ReadAll(r.Body) | 415 | + | // copy buffer (~32KB) regardless of chunk size. |
| 416 | + | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body) | |||
| 408 | if err != nil { | 417 | if err != nil { | ||
| 409 | var maxBytesErr *http.MaxBytesError | 418 | var maxBytesErr *http.MaxBytesError | ||
| 410 | if errors.As(err, &maxBytesErr) { | 419 | if errors.As(err, &maxBytesErr) { | ||
| 411 | - | log.Warn().Int64("max_size", MaxBlobChunkSize).Msg("blob chunk too large") | 420 | + | log.Warn().Int64("max_size", h.maxBlobChunkSize).Msg("blob chunk too large") |
| 412 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | 421 | h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size") | ||
| 413 | return | 422 | return | ||
| 414 | } | 423 | } | ||
| 415 | - | log.Error().Err(err).Msg("failed to read blob chunk") | |||
| 416 | - | h.sendRegistryError(w, http.StatusBadRequest, "BLOB_UPLOAD_INVALID", "invalid blob chunk") | |||
| 417 | - | return | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | // Append the chunk to the upload | |||
| 421 | - | length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, chunk) | |||
| 422 | - | if err != nil { | |||
| 423 | log.Error().Err(err).Msg("failed to append blob chunk") | 424 | log.Error().Err(err).Msg("failed to append blob chunk") | ||
| 424 | h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk") | 425 | h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk") | ||
| 425 | return | 426 | return | ||
M internal/adapters/in/http/registry/handler_test.go +54 -33
| @@ -2,7 +2,9 @@ package registry | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 7 | + | "io" | |
| 6 | 8 | "net/http" | |
| 7 | 9 | "net/http/httptest" | |
| 8 | 10 | "os" | |
| @@ -23,7 +25,7 @@ func testLogger() zerowrap.Logger { | |||
| 23 | 25 | func TestHandler_Base(t *testing.T) { | |
| 24 | 26 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 25 | 27 | ||
| 26 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 28 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 27 | 29 | ||
| 28 | 30 | req := httptest.NewRequest("GET", "/v2/", nil) | |
| 29 | 31 | rec := httptest.NewRecorder() | |
| @@ -37,7 +39,7 @@ func TestHandler_Base(t *testing.T) { | |||
| 37 | 39 | func TestHandler_NotFound(t *testing.T) { | |
| 38 | 40 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 39 | 41 | ||
| 40 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 42 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 41 | 43 | ||
| 42 | 44 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | |
| 43 | 45 | rec := httptest.NewRecorder() | |
| @@ -50,7 +52,7 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 50 | 52 | func TestHandler_GetManifest_Success(t *testing.T) { | |
| 51 | 53 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 52 | 54 | ||
| 53 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 55 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 54 | 56 | ||
| 55 | 57 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 56 | 58 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | |
| @@ -73,7 +75,7 @@ func TestHandler_GetManifest_Success(t *testing.T) { | |||
| 73 | 75 | func TestHandler_GetManifest_HEAD(t *testing.T) { | |
| 74 | 76 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 75 | 77 | ||
| 76 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 78 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 77 | 79 | ||
| 78 | 80 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 79 | 81 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | |
| @@ -97,7 +99,7 @@ func TestHandler_GetManifest_HEAD(t *testing.T) { | |||
| 97 | 99 | func TestHandler_GetManifest_NotFound(t *testing.T) { | |
| 98 | 100 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 99 | 101 | ||
| 100 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 102 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 101 | 103 | ||
| 102 | 104 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError) | |
| 103 | 105 | ||
| @@ -113,7 +115,7 @@ func TestHandler_GetManifest_NotFound(t *testing.T) { | |||
| 113 | 115 | func TestHandler_GetManifest_NestedName(t *testing.T) { | |
| 114 | 116 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 115 | 117 | ||
| 116 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 118 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 117 | 119 | ||
| 118 | 120 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 119 | 121 | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | |
| @@ -134,7 +136,7 @@ func TestHandler_GetManifest_NestedName(t *testing.T) { | |||
| 134 | 136 | func TestHandler_PutManifest_Success(t *testing.T) { | |
| 135 | 137 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 136 | 138 | ||
| 137 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 139 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 138 | 140 | ||
| 139 | 141 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 140 | 142 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | |
| @@ -155,7 +157,7 @@ func TestHandler_PutManifest_Success(t *testing.T) { | |||
| 155 | 157 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |
| 156 | 158 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 157 | 159 | ||
| 158 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 160 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 159 | 161 | ||
| 160 | 162 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 161 | 163 | ||
| @@ -171,7 +173,7 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |||
| 171 | 173 | func TestHandler_PutManifest_StorageError(t *testing.T) { | |
| 172 | 174 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 173 | 175 | ||
| 174 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 176 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 175 | 177 | ||
| 176 | 178 | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 177 | 179 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) | |
| @@ -188,7 +190,7 @@ func TestHandler_PutManifest_StorageError(t *testing.T) { | |||
| 188 | 190 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |
| 189 | 191 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 190 | 192 | ||
| 191 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 193 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 192 | 194 | ||
| 193 | 195 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | |
| 194 | 196 | rec := httptest.NewRecorder() | |
| @@ -201,7 +203,7 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |||
| 201 | 203 | func TestHandler_GetBlob_Success(t *testing.T) { | |
| 202 | 204 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 203 | 205 | ||
| 204 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 206 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 205 | 207 | ||
| 206 | 208 | // Create a temp file for http.ServeFile | |
| 207 | 209 | tmpFile, err := os.CreateTemp("", "blob-*") | |
| @@ -226,7 +228,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 226 | 228 | func TestHandler_GetBlob_NotFound(t *testing.T) { | |
| 227 | 229 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 228 | 230 | ||
| 229 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 231 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 230 | 232 | ||
| 231 | 233 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 232 | 234 | ||
| @@ -241,7 +243,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||
| 241 | 243 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |
| 242 | 244 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 243 | 245 | ||
| 244 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 246 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 245 | 247 | ||
| 246 | 248 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 247 | 249 | rec := httptest.NewRecorder() | |
| @@ -255,7 +257,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |||
| 255 | 257 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | |
| 256 | 258 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 257 | 259 | ||
| 258 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 260 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 259 | 261 | ||
| 260 | 262 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | |
| 261 | 263 | ||
| @@ -272,7 +274,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) { | |||
| 272 | 274 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | |
| 273 | 275 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 274 | 276 | ||
| 275 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 277 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 276 | 278 | ||
| 277 | 279 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError) | |
| 278 | 280 | ||
| @@ -288,10 +290,10 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) { | |||
| 288 | 290 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | |
| 289 | 291 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 290 | 292 | ||
| 291 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 293 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 292 | 294 | ||
| 293 | 295 | chunkData := []byte("chunk content") | |
| 294 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | |
| 296 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | |
| 295 | 297 | ||
| 296 | 298 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | |
| 297 | 299 | rec := httptest.NewRecorder() | |
| @@ -305,10 +307,10 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) { | |||
| 305 | 307 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |
| 306 | 308 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 307 | 309 | ||
| 308 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 310 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 309 | 311 | ||
| 310 | 312 | chunkData := []byte("final chunk") | |
| 311 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | |
| 313 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | |
| 312 | 314 | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | |
| 313 | 315 | ||
| 314 | 316 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | |
| @@ -324,7 +326,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |||
| 324 | 326 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | |
| 325 | 327 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 326 | 328 | ||
| 327 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 329 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 328 | 330 | ||
| 329 | 331 | chunkData := []byte("final chunk") | |
| 330 | 332 | // Note: Invalid digest format is rejected by validation before reaching storage layer | |
| @@ -342,7 +344,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | |||
| 342 | 344 | func TestHandler_ListTags_Success(t *testing.T) { | |
| 343 | 345 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 344 | 346 | ||
| 345 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 347 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 346 | 348 | ||
| 347 | 349 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil) | |
| 348 | 350 | ||
| @@ -366,7 +368,7 @@ func TestHandler_ListTags_Success(t *testing.T) { | |||
| 366 | 368 | func TestHandler_ListTags_NotFound(t *testing.T) { | |
| 367 | 369 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 368 | 370 | ||
| 369 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 371 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 370 | 372 | ||
| 371 | 373 | registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError) | |
| 372 | 374 | ||
| @@ -382,7 +384,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) { | |||
| 382 | 384 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |
| 383 | 385 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 384 | 386 | ||
| 385 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 387 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 386 | 388 | ||
| 387 | 389 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | |
| 388 | 390 | rec := httptest.NewRecorder() | |
| @@ -396,7 +398,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |||
| 396 | 398 | func TestHandler_RegisterRoutes(t *testing.T) { | |
| 397 | 399 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 398 | 400 | ||
| 399 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 401 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 400 | 402 | ||
| 401 | 403 | mux := http.NewServeMux() | |
| 402 | 404 | handler.RegisterRoutes(mux) | |
| @@ -412,7 +414,7 @@ func TestHandler_RegisterRoutes(t *testing.T) { | |||
| 412 | 414 | ||
| 413 | 415 | func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) { | |
| 414 | 416 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 415 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 417 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 416 | 418 | ||
| 417 | 419 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | |
| 418 | 420 | ||
| @@ -427,10 +429,10 @@ func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) { | |||
| 427 | 429 | ||
| 428 | 430 | func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | |
| 429 | 431 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 430 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 432 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 431 | 433 | ||
| 432 | 434 | chunkData := []byte("chunk content") | |
| 433 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | |
| 435 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | |
| 434 | 436 | ||
| 435 | 437 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | |
| 436 | 438 | rec := httptest.NewRecorder() | |
| @@ -441,18 +443,37 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | |||
| 441 | 443 | assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID")) | |
| 442 | 444 | } | |
| 443 | 445 | ||
| 444 | - | func TestMaxBlobChunkSize_Under100MB(t *testing.T) { | |
| 445 | - | // Verify the constant leaves headroom below Cloudflare's 100MB limit | |
| 446 | - | assert.Less(t, int64(MaxBlobChunkSize), int64(100*1024*1024)) | |
| 447 | - | assert.Greater(t, int64(MaxBlobChunkSize), int64(90*1024*1024)) | |
| 446 | + | func TestDefaultMaxBlobChunkSize_Is95MB(t *testing.T) { | |
| 447 | + | assert.Equal(t, int64(95*1024*1024), int64(DefaultMaxBlobChunkSize)) | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.T) { | |
| 451 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 452 | + | handler := NewHandler(registrySvc, testLogger(), 5) | |
| 453 | + | ||
| 454 | + | // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk. | |
| 455 | + | // The mock must read the body to trigger MaxBytesError, which the handler detects. | |
| 456 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything). | |
| 457 | + | Run(func(_ context.Context, _ string, _ string, data io.Reader) { | |
| 458 | + | // Drain the reader to trigger MaxBytesError | |
| 459 | + | _, _ = io.ReadAll(data) | |
| 460 | + | }). | |
| 461 | + | Return(int64(0), &http.MaxBytesError{Limit: 5}) | |
| 462 | + | ||
| 463 | + | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content"))) | |
| 464 | + | rec := httptest.NewRecorder() | |
| 465 | + | ||
| 466 | + | handler.ServeHTTP(rec, req) | |
| 467 | + | ||
| 468 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) | |
| 448 | 469 | } | |
| 449 | 470 | ||
| 450 | 471 | func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) { | |
| 451 | 472 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 452 | - | handler := NewHandler(registrySvc, testLogger()) | |
| 473 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) | |
| 453 | 474 | ||
| 454 | 475 | chunkData := []byte("chunk content") | |
| 455 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | |
| 476 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) | |
| 456 | 477 | ||
| 457 | 478 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | |
| 458 | 479 | rec := httptest.NewRecorder() | |
| @@ -2,7 +2,9 @@ package registry | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "io" | |||
| 6 | "net/http" | 8 | "net/http" | ||
| 7 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| 8 | "os" | 10 | "os" | ||
| @@ -23,7 +25,7 @@ func testLogger() zerowrap.Logger { | |||||
| 23 | func TestHandler_Base(t *testing.T) { | 25 | func TestHandler_Base(t *testing.T) { | ||
| 24 | registrySvc := inmocks.NewMockRegistryService(t) | 26 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 25 | 27 | ||||
| 26 | - | handler := NewHandler(registrySvc, testLogger()) | 28 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 27 | 29 | ||||
| 28 | req := httptest.NewRequest("GET", "/v2/", nil) | 30 | req := httptest.NewRequest("GET", "/v2/", nil) | ||
| 29 | rec := httptest.NewRecorder() | 31 | rec := httptest.NewRecorder() | ||
| @@ -37,7 +39,7 @@ func TestHandler_Base(t *testing.T) { | |||||
| 37 | func TestHandler_NotFound(t *testing.T) { | 39 | func TestHandler_NotFound(t *testing.T) { | ||
| 38 | registrySvc := inmocks.NewMockRegistryService(t) | 40 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 39 | 41 | ||||
| 40 | - | handler := NewHandler(registrySvc, testLogger()) | 42 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 41 | 43 | ||||
| 42 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | 44 | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | ||
| 43 | rec := httptest.NewRecorder() | 45 | rec := httptest.NewRecorder() | ||
| @@ -50,7 +52,7 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 50 | func TestHandler_GetManifest_Success(t *testing.T) { | 52 | func TestHandler_GetManifest_Success(t *testing.T) { | ||
| 51 | registrySvc := inmocks.NewMockRegistryService(t) | 53 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 52 | 54 | ||||
| 53 | - | handler := NewHandler(registrySvc, testLogger()) | 55 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 54 | 56 | ||||
| 55 | manifestData := []byte(`{"schemaVersion": 2}`) | 57 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 56 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | 58 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | ||
| @@ -73,7 +75,7 @@ func TestHandler_GetManifest_Success(t *testing.T) { | |||||
| 73 | func TestHandler_GetManifest_HEAD(t *testing.T) { | 75 | func TestHandler_GetManifest_HEAD(t *testing.T) { | ||
| 74 | registrySvc := inmocks.NewMockRegistryService(t) | 76 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 75 | 77 | ||||
| 76 | - | handler := NewHandler(registrySvc, testLogger()) | 78 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 77 | 79 | ||||
| 78 | manifestData := []byte(`{"schemaVersion": 2}`) | 80 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 79 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | 81 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{ | ||
| @@ -97,7 +99,7 @@ func TestHandler_GetManifest_HEAD(t *testing.T) { | |||||
| 97 | func TestHandler_GetManifest_NotFound(t *testing.T) { | 99 | func TestHandler_GetManifest_NotFound(t *testing.T) { | ||
| 98 | registrySvc := inmocks.NewMockRegistryService(t) | 100 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 99 | 101 | ||||
| 100 | - | handler := NewHandler(registrySvc, testLogger()) | 102 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 101 | 103 | ||||
| 102 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError) | 104 | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError) | ||
| 103 | 105 | ||||
| @@ -113,7 +115,7 @@ func TestHandler_GetManifest_NotFound(t *testing.T) { | |||||
| 113 | func TestHandler_GetManifest_NestedName(t *testing.T) { | 115 | func TestHandler_GetManifest_NestedName(t *testing.T) { | ||
| 114 | registrySvc := inmocks.NewMockRegistryService(t) | 116 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 115 | 117 | ||||
| 116 | - | handler := NewHandler(registrySvc, testLogger()) | 118 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 117 | 119 | ||||
| 118 | manifestData := []byte(`{"schemaVersion": 2}`) | 120 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 119 | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | 121 | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | ||
| @@ -134,7 +136,7 @@ func TestHandler_GetManifest_NestedName(t *testing.T) { | |||||
| 134 | func TestHandler_PutManifest_Success(t *testing.T) { | 136 | func TestHandler_PutManifest_Success(t *testing.T) { | ||
| 135 | registrySvc := inmocks.NewMockRegistryService(t) | 137 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 136 | 138 | ||||
| 137 | - | handler := NewHandler(registrySvc, testLogger()) | 139 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 138 | 140 | ||||
| 139 | manifestData := []byte(`{"schemaVersion": 2}`) | 141 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 140 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | 142 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | ||
| @@ -155,7 +157,7 @@ func TestHandler_PutManifest_Success(t *testing.T) { | |||||
| 155 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | 157 | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | ||
| 156 | registrySvc := inmocks.NewMockRegistryService(t) | 158 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 157 | 159 | ||||
| 158 | - | handler := NewHandler(registrySvc, testLogger()) | 160 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 159 | 161 | ||||
| 160 | manifestData := []byte(`{"schemaVersion": 2}`) | 162 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 161 | 163 | ||||
| @@ -171,7 +173,7 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |||||
| 171 | func TestHandler_PutManifest_StorageError(t *testing.T) { | 173 | func TestHandler_PutManifest_StorageError(t *testing.T) { | ||
| 172 | registrySvc := inmocks.NewMockRegistryService(t) | 174 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 173 | 175 | ||||
| 174 | - | handler := NewHandler(registrySvc, testLogger()) | 176 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 175 | 177 | ||||
| 176 | manifestData := []byte(`{"schemaVersion": 2}`) | 178 | manifestData := []byte(`{"schemaVersion": 2}`) | ||
| 177 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) | 179 | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) | ||
| @@ -188,7 +190,7 @@ func TestHandler_PutManifest_StorageError(t *testing.T) { | |||||
| 188 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | 190 | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | ||
| 189 | registrySvc := inmocks.NewMockRegistryService(t) | 191 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 190 | 192 | ||||
| 191 | - | handler := NewHandler(registrySvc, testLogger()) | 193 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 192 | 194 | ||||
| 193 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | 195 | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | ||
| 194 | rec := httptest.NewRecorder() | 196 | rec := httptest.NewRecorder() | ||
| @@ -201,7 +203,7 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |||||
| 201 | func TestHandler_GetBlob_Success(t *testing.T) { | 203 | func TestHandler_GetBlob_Success(t *testing.T) { | ||
| 202 | registrySvc := inmocks.NewMockRegistryService(t) | 204 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 203 | 205 | ||||
| 204 | - | handler := NewHandler(registrySvc, testLogger()) | 206 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 205 | 207 | ||||
| 206 | // Create a temp file for http.ServeFile | 208 | // Create a temp file for http.ServeFile | ||
| 207 | tmpFile, err := os.CreateTemp("", "blob-*") | 209 | tmpFile, err := os.CreateTemp("", "blob-*") | ||
| @@ -226,7 +228,7 @@ func TestHandler_GetBlob_Success(t *testing.T) { | |||||
| 226 | func TestHandler_GetBlob_NotFound(t *testing.T) { | 228 | func TestHandler_GetBlob_NotFound(t *testing.T) { | ||
| 227 | registrySvc := inmocks.NewMockRegistryService(t) | 229 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 228 | 230 | ||||
| 229 | - | handler := NewHandler(registrySvc, testLogger()) | 231 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 230 | 232 | ||||
| 231 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | 233 | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | ||
| 232 | 234 | ||||
| @@ -241,7 +243,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) { | |||||
| 241 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | 243 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | ||
| 242 | registrySvc := inmocks.NewMockRegistryService(t) | 244 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 243 | 245 | ||||
| 244 | - | handler := NewHandler(registrySvc, testLogger()) | 246 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 245 | 247 | ||||
| 246 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | 248 | req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | ||
| 247 | rec := httptest.NewRecorder() | 249 | rec := httptest.NewRecorder() | ||
| @@ -255,7 +257,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |||||
| 255 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | 257 | func TestHandler_StartBlobUpload_Success(t *testing.T) { | ||
| 256 | registrySvc := inmocks.NewMockRegistryService(t) | 258 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 257 | 259 | ||||
| 258 | - | handler := NewHandler(registrySvc, testLogger()) | 260 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 259 | 261 | ||||
| 260 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | 262 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | ||
| 261 | 263 | ||||
| @@ -272,7 +274,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) { | |||||
| 272 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | 274 | func TestHandler_StartBlobUpload_Error(t *testing.T) { | ||
| 273 | registrySvc := inmocks.NewMockRegistryService(t) | 275 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 274 | 276 | ||||
| 275 | - | handler := NewHandler(registrySvc, testLogger()) | 277 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 276 | 278 | ||||
| 277 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError) | 279 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError) | ||
| 278 | 280 | ||||
| @@ -288,10 +290,10 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) { | |||||
| 288 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | 290 | func TestHandler_BlobUpload_PATCH(t *testing.T) { | ||
| 289 | registrySvc := inmocks.NewMockRegistryService(t) | 291 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 290 | 292 | ||||
| 291 | - | handler := NewHandler(registrySvc, testLogger()) | 293 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 292 | 294 | ||||
| 293 | chunkData := []byte("chunk content") | 295 | chunkData := []byte("chunk content") | ||
| 294 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | 296 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) |
| 295 | 297 | ||||
| 296 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 298 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 297 | rec := httptest.NewRecorder() | 299 | rec := httptest.NewRecorder() | ||
| @@ -305,10 +307,10 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) { | |||||
| 305 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | 307 | func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | ||
| 306 | registrySvc := inmocks.NewMockRegistryService(t) | 308 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 307 | 309 | ||||
| 308 | - | handler := NewHandler(registrySvc, testLogger()) | 310 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 309 | 311 | ||||
| 310 | chunkData := []byte("final chunk") | 312 | chunkData := []byte("final chunk") | ||
| 311 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | 313 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) |
| 312 | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | 314 | registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil) | ||
| 313 | 315 | ||||
| 314 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | 316 | req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData)) | ||
| @@ -324,7 +326,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) { | |||||
| 324 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | 326 | func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | ||
| 325 | registrySvc := inmocks.NewMockRegistryService(t) | 327 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 326 | 328 | ||||
| 327 | - | handler := NewHandler(registrySvc, testLogger()) | 329 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 328 | 330 | ||||
| 329 | chunkData := []byte("final chunk") | 331 | chunkData := []byte("final chunk") | ||
| 330 | // Note: Invalid digest format is rejected by validation before reaching storage layer | 332 | // Note: Invalid digest format is rejected by validation before reaching storage layer | ||
| @@ -342,7 +344,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) { | |||||
| 342 | func TestHandler_ListTags_Success(t *testing.T) { | 344 | func TestHandler_ListTags_Success(t *testing.T) { | ||
| 343 | registrySvc := inmocks.NewMockRegistryService(t) | 345 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 344 | 346 | ||||
| 345 | - | handler := NewHandler(registrySvc, testLogger()) | 347 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 346 | 348 | ||||
| 347 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil) | 349 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil) | ||
| 348 | 350 | ||||
| @@ -366,7 +368,7 @@ func TestHandler_ListTags_Success(t *testing.T) { | |||||
| 366 | func TestHandler_ListTags_NotFound(t *testing.T) { | 368 | func TestHandler_ListTags_NotFound(t *testing.T) { | ||
| 367 | registrySvc := inmocks.NewMockRegistryService(t) | 369 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 368 | 370 | ||||
| 369 | - | handler := NewHandler(registrySvc, testLogger()) | 371 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 370 | 372 | ||||
| 371 | registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError) | 373 | registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError) | ||
| 372 | 374 | ||||
| @@ -382,7 +384,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) { | |||||
| 382 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | 384 | func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | ||
| 383 | registrySvc := inmocks.NewMockRegistryService(t) | 385 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 384 | 386 | ||||
| 385 | - | handler := NewHandler(registrySvc, testLogger()) | 387 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 386 | 388 | ||||
| 387 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | 389 | req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil) | ||
| 388 | rec := httptest.NewRecorder() | 390 | rec := httptest.NewRecorder() | ||
| @@ -396,7 +398,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) { | |||||
| 396 | func TestHandler_RegisterRoutes(t *testing.T) { | 398 | func TestHandler_RegisterRoutes(t *testing.T) { | ||
| 397 | registrySvc := inmocks.NewMockRegistryService(t) | 399 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 398 | 400 | ||||
| 399 | - | handler := NewHandler(registrySvc, testLogger()) | 401 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 400 | 402 | ||||
| 401 | mux := http.NewServeMux() | 403 | mux := http.NewServeMux() | ||
| 402 | handler.RegisterRoutes(mux) | 404 | handler.RegisterRoutes(mux) | ||
| @@ -412,7 +414,7 @@ func TestHandler_RegisterRoutes(t *testing.T) { | |||||
| 412 | 414 | ||||
| 413 | func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) { | 415 | func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) { | ||
| 414 | registrySvc := inmocks.NewMockRegistryService(t) | 416 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 415 | - | handler := NewHandler(registrySvc, testLogger()) | 417 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 416 | 418 | ||||
| 417 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | 419 | registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil) | ||
| 418 | 420 | ||||
| @@ -427,10 +429,10 @@ func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) { | |||||
| 427 | 429 | ||||
| 428 | func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | 430 | func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | ||
| 429 | registrySvc := inmocks.NewMockRegistryService(t) | 431 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 430 | - | handler := NewHandler(registrySvc, testLogger()) | 432 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 431 | 433 | ||||
| 432 | chunkData := []byte("chunk content") | 434 | chunkData := []byte("chunk content") | ||
| 433 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | 435 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) |
| 434 | 436 | ||||
| 435 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 437 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 436 | rec := httptest.NewRecorder() | 438 | rec := httptest.NewRecorder() | ||
| @@ -441,18 +443,37 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) { | |||||
| 441 | assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID")) | 443 | assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID")) | ||
| 442 | } | 444 | } | ||
| 443 | 445 | ||||
| 444 | - | func TestMaxBlobChunkSize_Under100MB(t *testing.T) { | 446 | + | func TestDefaultMaxBlobChunkSize_Is95MB(t *testing.T) { |
| 445 | - | // Verify the constant leaves headroom below Cloudflare's 100MB limit | 447 | + | assert.Equal(t, int64(95*1024*1024), int64(DefaultMaxBlobChunkSize)) |
| 446 | - | assert.Less(t, int64(MaxBlobChunkSize), int64(100*1024*1024)) | 448 | + | } |
| 447 | - | assert.Greater(t, int64(MaxBlobChunkSize), int64(90*1024*1024)) | 449 | + | |
| 450 | + | func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.T) { | |||
| 451 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 452 | + | handler := NewHandler(registrySvc, testLogger(), 5) | |||
| 453 | + | ||||
| 454 | + | // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk. | |||
| 455 | + | // The mock must read the body to trigger MaxBytesError, which the handler detects. | |||
| 456 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything). | |||
| 457 | + | Run(func(_ context.Context, _ string, _ string, data io.Reader) { | |||
| 458 | + | // Drain the reader to trigger MaxBytesError | |||
| 459 | + | _, _ = io.ReadAll(data) | |||
| 460 | + | }). | |||
| 461 | + | Return(int64(0), &http.MaxBytesError{Limit: 5}) | |||
| 462 | + | ||||
| 463 | + | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content"))) | |||
| 464 | + | rec := httptest.NewRecorder() | |||
| 465 | + | ||||
| 466 | + | handler.ServeHTTP(rec, req) | |||
| 467 | + | ||||
| 468 | + | assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code) | |||
| 448 | } | 469 | } | ||
| 449 | 470 | ||||
| 450 | func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) { | 471 | func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) { | ||
| 451 | registrySvc := inmocks.NewMockRegistryService(t) | 472 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 452 | - | handler := NewHandler(registrySvc, testLogger()) | 473 | + | handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize) |
| 453 | 474 | ||||
| 454 | chunkData := []byte("chunk content") | 475 | chunkData := []byte("chunk content") | ||
| 455 | - | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", chunkData).Return(int64(len(chunkData)), nil) | 476 | + | registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil) |
| 456 | 477 | ||||
| 457 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | 478 | req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData)) | ||
| 458 | rec := httptest.NewRecorder() | 479 | rec := httptest.NewRecorder() | ||
M internal/adapters/out/domainsecrets/pass_store.go +87 -9
| @@ -63,20 +63,41 @@ func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |||
| 63 | 63 | if err != nil { | |
| 64 | 64 | return nil, err | |
| 65 | 65 | } | |
| 66 | - | if !exists { | |
| 67 | - | return []string{}, nil | |
| 66 | + | keys := []string{} | |
| 67 | + | if exists { | |
| 68 | + | for _, line := range strings.Split(content, "\n") { | |
| 69 | + | key := strings.TrimSpace(line) | |
| 70 | + | if key == "" { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | keys = append(keys, key) | |
| 74 | + | } | |
| 68 | 75 | } | |
| 76 | + | sort.Strings(keys) | |
| 69 | 77 | ||
| 70 | - | keys := []string{} | |
| 71 | - | for _, line := range strings.Split(content, "\n") { | |
| 72 | - | key := strings.TrimSpace(line) | |
| 73 | - | if key == "" { | |
| 74 | - | continue | |
| 78 | + | // Recover keys that may exist in pass but are missing from the manifest. | |
| 79 | + | discovered, err := s.listDomainKeys(domainName) | |
| 80 | + | if err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | merged, changed := mergeUniqueKeys(keys, discovered) | |
| 85 | + | if changed { | |
| 86 | + | // Best-effort self-heal of stale manifest. | |
| 87 | + | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |
| 88 | + | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |
| 89 | + | writeCancel() | |
| 90 | + | if writeErr != nil { | |
| 91 | + | s.log.Warn(). | |
| 92 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 93 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 94 | + | Str("domain", domainName). | |
| 95 | + | Err(writeErr). | |
| 96 | + | Msg("failed to self-heal pass manifest, continuing with recovered keys") | |
| 75 | 97 | } | |
| 76 | - | keys = append(keys, key) | |
| 77 | 98 | } | |
| 78 | 99 | ||
| 79 | - | return keys, nil | |
| 100 | + | return merged, nil | |
| 80 | 101 | } | |
| 81 | 102 | ||
| 82 | 103 | // GetAll returns all secrets for a domain as a key-value map. | |
| @@ -496,6 +517,35 @@ func (s *PassStore) manifestPath(domainName string) (string, error) { | |||
| 496 | 517 | return path, nil | |
| 497 | 518 | } | |
| 498 | 519 | ||
| 520 | + | func (s *PassStore) listDomainKeys(domainName string) ([]string, error) { | |
| 521 | + | basePath, err := s.domainPath(domainName) | |
| 522 | + | if err != nil { | |
| 523 | + | return nil, err | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |
| 527 | + | defer cancel() | |
| 528 | + | ||
| 529 | + | entries, err := s.listTopLevelEntries(ctx, basePath) | |
| 530 | + | if err != nil { | |
| 531 | + | return nil, err | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | keys := make([]string, 0, len(entries)) | |
| 535 | + | for _, entry := range entries { | |
| 536 | + | if entry == ".keys" { | |
| 537 | + | continue | |
| 538 | + | } | |
| 539 | + | if err := domain.ValidateEnvKey(entry); err != nil { | |
| 540 | + | continue | |
| 541 | + | } | |
| 542 | + | keys = append(keys, entry) | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | sort.Strings(keys) | |
| 546 | + | return keys, nil | |
| 547 | + | } | |
| 548 | + | ||
| 499 | 549 | func (s *PassStore) attachmentPath(containerName string) (string, error) { | |
| 500 | 550 | if err := domain.ValidateContainerName(containerName); err != nil { | |
| 501 | 551 | return "", err | |
| @@ -725,6 +775,34 @@ func passEntryMissing(output string) bool { | |||
| 725 | 775 | strings.Contains(lower, "password store is empty") | |
| 726 | 776 | } | |
| 727 | 777 | ||
| 778 | + | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | |
| 779 | + | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | |
| 780 | + | for _, key := range primary { | |
| 781 | + | mergedSet[key] = struct{}{} | |
| 782 | + | } | |
| 783 | + | for _, key := range secondary { | |
| 784 | + | mergedSet[key] = struct{}{} | |
| 785 | + | } | |
| 786 | + | ||
| 787 | + | merged := make([]string, 0, len(mergedSet)) | |
| 788 | + | for key := range mergedSet { | |
| 789 | + | merged = append(merged, key) | |
| 790 | + | } | |
| 791 | + | sort.Strings(merged) | |
| 792 | + | ||
| 793 | + | if len(merged) != len(primary) { | |
| 794 | + | return merged, true | |
| 795 | + | } | |
| 796 | + | ||
| 797 | + | for i := range primary { | |
| 798 | + | if primary[i] != merged[i] { | |
| 799 | + | return merged, true | |
| 800 | + | } | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | return merged, false | |
| 804 | + | } | |
| 805 | + | ||
| 728 | 806 | func (s *PassStore) cleanupInsertedPaths(paths []string) { | |
| 729 | 807 | if len(paths) == 0 { | |
| 730 | 808 | return | |
| @@ -63,20 +63,41 @@ func (s *PassStore) ListKeys(domainName string) ([]string, error) { | |||||
| 63 | if err != nil { | 63 | if err != nil { | ||
| 64 | return nil, err | 64 | return nil, err | ||
| 65 | } | 65 | } | ||
| 66 | - | if !exists { | 66 | + | keys := []string{} |
| 67 | - | return []string{}, nil | 67 | + | if exists { |
| 68 | + | for _, line := range strings.Split(content, "\n") { | |||
| 69 | + | key := strings.TrimSpace(line) | |||
| 70 | + | if key == "" { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | keys = append(keys, key) | |||
| 74 | + | } | |||
| 68 | } | 75 | } | ||
| 76 | + | sort.Strings(keys) | |||
| 69 | 77 | ||||
| 70 | - | keys := []string{} | 78 | + | // Recover keys that may exist in pass but are missing from the manifest. |
| 71 | - | for _, line := range strings.Split(content, "\n") { | 79 | + | discovered, err := s.listDomainKeys(domainName) |
| 72 | - | key := strings.TrimSpace(line) | 80 | + | if err != nil { |
| 73 | - | if key == "" { | 81 | + | return nil, err |
| 74 | - | continue | 82 | + | } |
| 83 | + | ||||
| 84 | + | merged, changed := mergeUniqueKeys(keys, discovered) | |||
| 85 | + | if changed { | |||
| 86 | + | // Best-effort self-heal of stale manifest. | |||
| 87 | + | writeCtx, writeCancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 88 | + | writeErr := s.passInsert(writeCtx, manifestPath, strings.Join(merged, "\n")) | |||
| 89 | + | writeCancel() | |||
| 90 | + | if writeErr != nil { | |||
| 91 | + | s.log.Warn(). | |||
| 92 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 93 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |||
| 94 | + | Str("domain", domainName). | |||
| 95 | + | Err(writeErr). | |||
| 96 | + | Msg("failed to self-heal pass manifest, continuing with recovered keys") | |||
| 75 | } | 97 | } | ||
| 76 | - | keys = append(keys, key) | |||
| 77 | } | 98 | } | ||
| 78 | 99 | ||||
| 79 | - | return keys, nil | 100 | + | return merged, nil |
| 80 | } | 101 | } | ||
| 81 | 102 | ||||
| 82 | // GetAll returns all secrets for a domain as a key-value map. | 103 | // GetAll returns all secrets for a domain as a key-value map. | ||
| @@ -496,6 +517,35 @@ func (s *PassStore) manifestPath(domainName string) (string, error) { | |||||
| 496 | return path, nil | 517 | return path, nil | ||
| 497 | } | 518 | } | ||
| 498 | 519 | ||||
| 520 | + | func (s *PassStore) listDomainKeys(domainName string) ([]string, error) { | |||
| 521 | + | basePath, err := s.domainPath(domainName) | |||
| 522 | + | if err != nil { | |||
| 523 | + | return nil, err | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | ctx, cancel := context.WithTimeout(context.Background(), s.timeout) | |||
| 527 | + | defer cancel() | |||
| 528 | + | ||||
| 529 | + | entries, err := s.listTopLevelEntries(ctx, basePath) | |||
| 530 | + | if err != nil { | |||
| 531 | + | return nil, err | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | keys := make([]string, 0, len(entries)) | |||
| 535 | + | for _, entry := range entries { | |||
| 536 | + | if entry == ".keys" { | |||
| 537 | + | continue | |||
| 538 | + | } | |||
| 539 | + | if err := domain.ValidateEnvKey(entry); err != nil { | |||
| 540 | + | continue | |||
| 541 | + | } | |||
| 542 | + | keys = append(keys, entry) | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | sort.Strings(keys) | |||
| 546 | + | return keys, nil | |||
| 547 | + | } | |||
| 548 | + | ||||
| 499 | func (s *PassStore) attachmentPath(containerName string) (string, error) { | 549 | func (s *PassStore) attachmentPath(containerName string) (string, error) { | ||
| 500 | if err := domain.ValidateContainerName(containerName); err != nil { | 550 | if err := domain.ValidateContainerName(containerName); err != nil { | ||
| 501 | return "", err | 551 | return "", err | ||
| @@ -725,6 +775,34 @@ func passEntryMissing(output string) bool { | |||||
| 725 | strings.Contains(lower, "password store is empty") | 775 | strings.Contains(lower, "password store is empty") | ||
| 726 | } | 776 | } | ||
| 727 | 777 | ||||
| 778 | + | func mergeUniqueKeys(primary, secondary []string) ([]string, bool) { | |||
| 779 | + | mergedSet := make(map[string]struct{}, len(primary)+len(secondary)) | |||
| 780 | + | for _, key := range primary { | |||
| 781 | + | mergedSet[key] = struct{}{} | |||
| 782 | + | } | |||
| 783 | + | for _, key := range secondary { | |||
| 784 | + | mergedSet[key] = struct{}{} | |||
| 785 | + | } | |||
| 786 | + | ||||
| 787 | + | merged := make([]string, 0, len(mergedSet)) | |||
| 788 | + | for key := range mergedSet { | |||
| 789 | + | merged = append(merged, key) | |||
| 790 | + | } | |||
| 791 | + | sort.Strings(merged) | |||
| 792 | + | ||||
| 793 | + | if len(merged) != len(primary) { | |||
| 794 | + | return merged, true | |||
| 795 | + | } | |||
| 796 | + | ||||
| 797 | + | for i := range primary { | |||
| 798 | + | if primary[i] != merged[i] { | |||
| 799 | + | return merged, true | |||
| 800 | + | } | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | return merged, false | |||
| 804 | + | } | |||
| 805 | + | ||||
| 728 | func (s *PassStore) cleanupInsertedPaths(paths []string) { | 806 | func (s *PassStore) cleanupInsertedPaths(paths []string) { | ||
| 729 | if len(paths) == 0 { | 807 | if len(paths) == 0 { | ||
| 730 | return | 808 | return | ||
M internal/adapters/out/domainsecrets/pass_store_test.go +131 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os/exec" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -19,6 +20,25 @@ func passCmd(args ...string) error { | |||
| 19 | 20 | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 23 | + | func passInsertValue(path, value string) error { | |
| 24 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 25 | + | defer cancel() | |
| 26 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |
| 27 | + | cmd.Stdin = strings.NewReader(value) | |
| 28 | + | _, err := cmd.CombinedOutput() | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func passShow(path string) (string, error) { | |
| 33 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 34 | + | defer cancel() | |
| 35 | + | out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput() | |
| 36 | + | if err != nil { | |
| 37 | + | return "", err | |
| 38 | + | } | |
| 39 | + | return strings.TrimSpace(string(out)), nil | |
| 40 | + | } | |
| 41 | + | ||
| 22 | 42 | func requirePass(t *testing.T) { | |
| 23 | 43 | if err := passCmd("version"); err != nil { | |
| 24 | 44 | t.Skip("pass not available") | |
| @@ -157,6 +177,117 @@ func TestPassStore_DeleteAttachment(t *testing.T) { | |||
| 157 | 177 | assert.False(t, exists) | |
| 158 | 178 | } | |
| 159 | 179 | ||
| 180 | + | func TestPassStore_Delete_Idempotent(t *testing.T) { | |
| 181 | + | requirePass(t) | |
| 182 | + | ||
| 183 | + | store, err := NewPassStore(testLogger()) | |
| 184 | + | require.NoError(t, err) | |
| 185 | + | ||
| 186 | + | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |
| 187 | + | keys := []string{"API_KEY"} | |
| 188 | + | defer cleanupPassDomain(t, domainName, keys) | |
| 189 | + | ||
| 190 | + | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | ||
| 193 | + | err = store.Delete(domainName, "API_KEY") | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | ||
| 196 | + | // Second delete of an already-removed key must be a no-op. | |
| 197 | + | err = store.Delete(domainName, "API_KEY") | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | ||
| 200 | + | values, err := store.GetAll(domainName) | |
| 201 | + | require.NoError(t, err) | |
| 202 | + | assert.NotContains(t, values, "API_KEY") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |
| 206 | + | requirePass(t) | |
| 207 | + | ||
| 208 | + | store, err := NewPassStore(testLogger()) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | ||
| 211 | + | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |
| 212 | + | keys := []string{"POSTGRES_PASSWORD"} | |
| 213 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 214 | + | ||
| 215 | + | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | ||
| 218 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | ||
| 221 | + | // Second delete of an already-removed key must be a no-op. | |
| 222 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |
| 223 | + | require.NoError(t, err) | |
| 224 | + | ||
| 225 | + | values, err := store.GetAllAttachment(containerName) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |
| 231 | + | requirePass(t) | |
| 232 | + | ||
| 233 | + | store, err := NewPassStore(testLogger()) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | ||
| 236 | + | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |
| 237 | + | keys := []string{"REDIS_PASSWORD"} | |
| 238 | + | defer CleanupPassAttachment(t, containerName, keys) | |
| 239 | + | ||
| 240 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | ||
| 243 | + | // Re-setting same key with a different value should deterministically overwrite. | |
| 244 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | ||
| 247 | + | values, err := store.GetAllAttachment(containerName) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | |
| 253 | + | requirePass(t) | |
| 254 | + | ||
| 255 | + | store, err := NewPassStore(testLogger()) | |
| 256 | + | require.NoError(t, err) | |
| 257 | + | ||
| 258 | + | domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano()) | |
| 259 | + | keys := []string{"EXISTING", "ORIGIN"} | |
| 260 | + | defer cleanupPassDomain(t, domainName, keys) | |
| 261 | + | ||
| 262 | + | err = store.Set(domainName, map[string]string{"EXISTING": "present"}) | |
| 263 | + | require.NoError(t, err) | |
| 264 | + | ||
| 265 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 266 | + | require.NoError(t, err) | |
| 267 | + | ||
| 268 | + | orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain) | |
| 269 | + | err = passInsertValue(orphanPath, "https://example.com") | |
| 270 | + | require.NoError(t, err) | |
| 271 | + | ||
| 272 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |
| 273 | + | err = passInsertValue(manifestPath, "EXISTING\n") | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | ||
| 276 | + | listed, err := store.ListKeys(domainName) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed) | |
| 279 | + | ||
| 280 | + | values, err := store.GetAll(domainName) | |
| 281 | + | require.NoError(t, err) | |
| 282 | + | assert.Equal(t, "present", values["EXISTING"]) | |
| 283 | + | assert.Equal(t, "https://example.com", values["ORIGIN"]) | |
| 284 | + | ||
| 285 | + | manifest, err := passShow(manifestPath) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | assert.Contains(t, manifest, "EXISTING") | |
| 288 | + | assert.Contains(t, manifest, "ORIGIN") | |
| 289 | + | } | |
| 290 | + | ||
| 160 | 291 | func TestParsePassListOutput(t *testing.T) { | |
| 161 | 292 | tests := []struct { | |
| 162 | 293 | name string | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os/exec" | 6 | "os/exec" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -19,6 +20,25 @@ func passCmd(args ...string) error { | |||||
| 19 | return exec.CommandContext(ctx, "pass", args...).Run() | 20 | return exec.CommandContext(ctx, "pass", args...).Run() | ||
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 23 | + | func passInsertValue(path, value string) error { | |||
| 24 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 25 | + | defer cancel() | |||
| 26 | + | cmd := exec.CommandContext(ctx, "pass", "insert", "-m", "-f", path) | |||
| 27 | + | cmd.Stdin = strings.NewReader(value) | |||
| 28 | + | _, err := cmd.CombinedOutput() | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func passShow(path string) (string, error) { | |||
| 33 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 34 | + | defer cancel() | |||
| 35 | + | out, err := exec.CommandContext(ctx, "pass", "show", path).CombinedOutput() | |||
| 36 | + | if err != nil { | |||
| 37 | + | return "", err | |||
| 38 | + | } | |||
| 39 | + | return strings.TrimSpace(string(out)), nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 22 | func requirePass(t *testing.T) { | 42 | func requirePass(t *testing.T) { | ||
| 23 | if err := passCmd("version"); err != nil { | 43 | if err := passCmd("version"); err != nil { | ||
| 24 | t.Skip("pass not available") | 44 | t.Skip("pass not available") | ||
| @@ -157,6 +177,117 @@ func TestPassStore_DeleteAttachment(t *testing.T) { | |||||
| 157 | assert.False(t, exists) | 177 | assert.False(t, exists) | ||
| 158 | } | 178 | } | ||
| 159 | 179 | ||||
| 180 | + | func TestPassStore_Delete_Idempotent(t *testing.T) { | |||
| 181 | + | requirePass(t) | |||
| 182 | + | ||||
| 183 | + | store, err := NewPassStore(testLogger()) | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | ||||
| 186 | + | domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano()) | |||
| 187 | + | keys := []string{"API_KEY"} | |||
| 188 | + | defer cleanupPassDomain(t, domainName, keys) | |||
| 189 | + | ||||
| 190 | + | err = store.Set(domainName, map[string]string{"API_KEY": "alpha"}) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | ||||
| 193 | + | err = store.Delete(domainName, "API_KEY") | |||
| 194 | + | require.NoError(t, err) | |||
| 195 | + | ||||
| 196 | + | // Second delete of an already-removed key must be a no-op. | |||
| 197 | + | err = store.Delete(domainName, "API_KEY") | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | ||||
| 200 | + | values, err := store.GetAll(domainName) | |||
| 201 | + | require.NoError(t, err) | |||
| 202 | + | assert.NotContains(t, values, "API_KEY") | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) { | |||
| 206 | + | requirePass(t) | |||
| 207 | + | ||||
| 208 | + | store, err := NewPassStore(testLogger()) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | ||||
| 211 | + | containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano()) | |||
| 212 | + | keys := []string{"POSTGRES_PASSWORD"} | |||
| 213 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 214 | + | ||||
| 215 | + | err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"}) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | ||||
| 218 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | ||||
| 221 | + | // Second delete of an already-removed key must be a no-op. | |||
| 222 | + | err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD") | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | ||||
| 225 | + | values, err := store.GetAllAttachment(containerName) | |||
| 226 | + | require.NoError(t, err) | |||
| 227 | + | assert.NotContains(t, values, "POSTGRES_PASSWORD") | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) { | |||
| 231 | + | requirePass(t) | |||
| 232 | + | ||||
| 233 | + | store, err := NewPassStore(testLogger()) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | ||||
| 236 | + | containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano()) | |||
| 237 | + | keys := []string{"REDIS_PASSWORD"} | |||
| 238 | + | defer CleanupPassAttachment(t, containerName, keys) | |||
| 239 | + | ||||
| 240 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"}) | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | ||||
| 243 | + | // Re-setting same key with a different value should deterministically overwrite. | |||
| 244 | + | err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"}) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | ||||
| 247 | + | values, err := store.GetAllAttachment(containerName) | |||
| 248 | + | require.NoError(t, err) | |||
| 249 | + | assert.Equal(t, "second", values["REDIS_PASSWORD"]) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) { | |||
| 253 | + | requirePass(t) | |||
| 254 | + | ||||
| 255 | + | store, err := NewPassStore(testLogger()) | |||
| 256 | + | require.NoError(t, err) | |||
| 257 | + | ||||
| 258 | + | domainName := fmt.Sprintf("orphan.%d.example.com", time.Now().UnixNano()) | |||
| 259 | + | keys := []string{"EXISTING", "ORIGIN"} | |||
| 260 | + | defer cleanupPassDomain(t, domainName, keys) | |||
| 261 | + | ||||
| 262 | + | err = store.Set(domainName, map[string]string{"EXISTING": "present"}) | |||
| 263 | + | require.NoError(t, err) | |||
| 264 | + | ||||
| 265 | + | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |||
| 266 | + | require.NoError(t, err) | |||
| 267 | + | ||||
| 268 | + | orphanPath := fmt.Sprintf("%s/%s/ORIGIN", PassDomainSecretsPath, safeDomain) | |||
| 269 | + | err = passInsertValue(orphanPath, "https://example.com") | |||
| 270 | + | require.NoError(t, err) | |||
| 271 | + | ||||
| 272 | + | manifestPath := fmt.Sprintf("%s/%s/.keys", PassDomainSecretsPath, safeDomain) | |||
| 273 | + | err = passInsertValue(manifestPath, "EXISTING\n") | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | ||||
| 276 | + | listed, err := store.ListKeys(domainName) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | assert.ElementsMatch(t, []string{"EXISTING", "ORIGIN"}, listed) | |||
| 279 | + | ||||
| 280 | + | values, err := store.GetAll(domainName) | |||
| 281 | + | require.NoError(t, err) | |||
| 282 | + | assert.Equal(t, "present", values["EXISTING"]) | |||
| 283 | + | assert.Equal(t, "https://example.com", values["ORIGIN"]) | |||
| 284 | + | ||||
| 285 | + | manifest, err := passShow(manifestPath) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | assert.Contains(t, manifest, "EXISTING") | |||
| 288 | + | assert.Contains(t, manifest, "ORIGIN") | |||
| 289 | + | } | |||
| 290 | + | ||||
| 160 | func TestParsePassListOutput(t *testing.T) { | 291 | func TestParsePassListOutput(t *testing.T) { | ||
| 161 | tests := []struct { | 292 | tests := []struct { | ||
| 162 | name string | 293 | name string | ||
M internal/adapters/out/filesystem/blob.go +3 -3
| @@ -197,7 +197,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | |||
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | 199 | // AppendBlobChunk appends data to an in-progress upload. | |
| 200 | - | func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) { | |
| 200 | + | func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) { | |
| 201 | 201 | uploadPath, err := s.getUploadPath(uuid) | |
| 202 | 202 | if err != nil { | |
| 203 | 203 | return 0, fmt.Errorf("invalid upload path: %w", err) | |
| @@ -212,7 +212,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e | |||
| 212 | 212 | } | |
| 213 | 213 | defer file.Close() | |
| 214 | 214 | ||
| 215 | - | written, err := file.Write(chunk) | |
| 215 | + | written, err := io.Copy(file, data) | |
| 216 | 216 | if err != nil { | |
| 217 | 217 | return 0, fmt.Errorf("failed to write chunk to upload file: %w", err) | |
| 218 | 218 | } | |
| @@ -227,7 +227,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e | |||
| 227 | 227 | Str(zerowrap.FieldAdapter, "filesystem"). | |
| 228 | 228 | Str("uuid", uuid). | |
| 229 | 229 | Str("name", name). | |
| 230 | - | Int("chunk_size", written). | |
| 230 | + | Int64("chunk_size", written). | |
| 231 | 231 | Int64("total_size", fi.Size()). | |
| 232 | 232 | Msg("appended chunk to blob upload") | |
| 233 | 233 | ||
| @@ -197,7 +197,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | |||||
| 197 | } | 197 | } | ||
| 198 | 198 | ||||
| 199 | // AppendBlobChunk appends data to an in-progress upload. | 199 | // AppendBlobChunk appends data to an in-progress upload. | ||
| 200 | - | func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) { | 200 | + | func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) { |
| 201 | uploadPath, err := s.getUploadPath(uuid) | 201 | uploadPath, err := s.getUploadPath(uuid) | ||
| 202 | if err != nil { | 202 | if err != nil { | ||
| 203 | return 0, fmt.Errorf("invalid upload path: %w", err) | 203 | return 0, fmt.Errorf("invalid upload path: %w", err) | ||
| @@ -212,7 +212,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e | |||||
| 212 | } | 212 | } | ||
| 213 | defer file.Close() | 213 | defer file.Close() | ||
| 214 | 214 | ||||
| 215 | - | written, err := file.Write(chunk) | 215 | + | written, err := io.Copy(file, data) |
| 216 | if err != nil { | 216 | if err != nil { | ||
| 217 | return 0, fmt.Errorf("failed to write chunk to upload file: %w", err) | 217 | return 0, fmt.Errorf("failed to write chunk to upload file: %w", err) | ||
| 218 | } | 218 | } | ||
| @@ -227,7 +227,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e | |||||
| 227 | Str(zerowrap.FieldAdapter, "filesystem"). | 227 | Str(zerowrap.FieldAdapter, "filesystem"). | ||
| 228 | Str("uuid", uuid). | 228 | Str("uuid", uuid). | ||
| 229 | Str("name", name). | 229 | Str("name", name). | ||
| 230 | - | Int("chunk_size", written). | 230 | + | Int64("chunk_size", written). |
| 231 | Int64("total_size", fi.Size()). | 231 | Int64("total_size", fi.Size()). | ||
| 232 | Msg("appended chunk to blob upload") | 232 | Msg("appended chunk to blob upload") | ||
| 233 | 233 | ||||
M internal/adapters/out/filesystem/blob_test.go +5 -5
| @@ -242,13 +242,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) { | |||
| 242 | 242 | ||
| 243 | 243 | // Append first chunk | |
| 244 | 244 | chunk1 := []byte("first chunk") | |
| 245 | - | size1, err := storage.AppendBlobChunk("myapp", uuid, chunk1) | |
| 245 | + | size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1)) | |
| 246 | 246 | require.NoError(t, err) | |
| 247 | 247 | assert.Equal(t, int64(len(chunk1)), size1) | |
| 248 | 248 | ||
| 249 | 249 | // Append second chunk | |
| 250 | 250 | chunk2 := []byte(" second chunk") | |
| 251 | - | size2, err := storage.AppendBlobChunk("myapp", uuid, chunk2) | |
| 251 | + | size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2)) | |
| 252 | 252 | require.NoError(t, err) | |
| 253 | 253 | assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2) | |
| 254 | 254 | } | |
| @@ -260,7 +260,7 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) { | |||
| 260 | 260 | storage, err := NewBlobStorage(tmpDir, log) | |
| 261 | 261 | require.NoError(t, err) | |
| 262 | 262 | ||
| 263 | - | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", []byte("data")) | |
| 263 | + | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data"))) | |
| 264 | 264 | ||
| 265 | 265 | assert.Error(t, err) | |
| 266 | 266 | assert.Contains(t, err.Error(), "upload not found") | |
| @@ -312,7 +312,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) { | |||
| 312 | 312 | ||
| 313 | 313 | // Append data - content that hashes to testDigest1 | |
| 314 | 314 | blobData := []byte("complete blob content") | |
| 315 | - | _, err = storage.AppendBlobChunk("myapp", uuid, blobData) | |
| 315 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData)) | |
| 316 | 316 | require.NoError(t, err) | |
| 317 | 317 | ||
| 318 | 318 | // Finish upload - use testDigest1 (validation will pass, but hash won't match) | |
| @@ -439,7 +439,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) { | |||
| 439 | 439 | ||
| 440 | 440 | var totalSize int64 | |
| 441 | 441 | for _, chunk := range chunks { | |
| 442 | - | size, err := storage.AppendBlobChunk(repoName, uuid, chunk) | |
| 442 | + | size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk)) | |
| 443 | 443 | require.NoError(t, err) | |
| 444 | 444 | totalSize = size | |
| 445 | 445 | } | |
| @@ -242,13 +242,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) { | |||||
| 242 | 242 | ||||
| 243 | // Append first chunk | 243 | // Append first chunk | ||
| 244 | chunk1 := []byte("first chunk") | 244 | chunk1 := []byte("first chunk") | ||
| 245 | - | size1, err := storage.AppendBlobChunk("myapp", uuid, chunk1) | 245 | + | size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1)) |
| 246 | require.NoError(t, err) | 246 | require.NoError(t, err) | ||
| 247 | assert.Equal(t, int64(len(chunk1)), size1) | 247 | assert.Equal(t, int64(len(chunk1)), size1) | ||
| 248 | 248 | ||||
| 249 | // Append second chunk | 249 | // Append second chunk | ||
| 250 | chunk2 := []byte(" second chunk") | 250 | chunk2 := []byte(" second chunk") | ||
| 251 | - | size2, err := storage.AppendBlobChunk("myapp", uuid, chunk2) | 251 | + | size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2)) |
| 252 | require.NoError(t, err) | 252 | require.NoError(t, err) | ||
| 253 | assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2) | 253 | assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2) | ||
| 254 | } | 254 | } | ||
| @@ -260,7 +260,7 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) { | |||||
| 260 | storage, err := NewBlobStorage(tmpDir, log) | 260 | storage, err := NewBlobStorage(tmpDir, log) | ||
| 261 | require.NoError(t, err) | 261 | require.NoError(t, err) | ||
| 262 | 262 | ||||
| 263 | - | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", []byte("data")) | 263 | + | _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data"))) |
| 264 | 264 | ||||
| 265 | assert.Error(t, err) | 265 | assert.Error(t, err) | ||
| 266 | assert.Contains(t, err.Error(), "upload not found") | 266 | assert.Contains(t, err.Error(), "upload not found") | ||
| @@ -312,7 +312,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) { | |||||
| 312 | 312 | ||||
| 313 | // Append data - content that hashes to testDigest1 | 313 | // Append data - content that hashes to testDigest1 | ||
| 314 | blobData := []byte("complete blob content") | 314 | blobData := []byte("complete blob content") | ||
| 315 | - | _, err = storage.AppendBlobChunk("myapp", uuid, blobData) | 315 | + | _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData)) |
| 316 | require.NoError(t, err) | 316 | require.NoError(t, err) | ||
| 317 | 317 | ||||
| 318 | // Finish upload - use testDigest1 (validation will pass, but hash won't match) | 318 | // Finish upload - use testDigest1 (validation will pass, but hash won't match) | ||
| @@ -439,7 +439,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) { | |||||
| 439 | 439 | ||||
| 440 | var totalSize int64 | 440 | var totalSize int64 | ||
| 441 | for _, chunk := range chunks { | 441 | for _, chunk := range chunks { | ||
| 442 | - | size, err := storage.AppendBlobChunk(repoName, uuid, chunk) | 442 | + | size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk)) |
| 443 | require.NoError(t, err) | 443 | require.NoError(t, err) | ||
| 444 | totalSize = size | 444 | totalSize = size | ||
| 445 | } | 445 | } | ||
M internal/app/run.go +130 -66
| @@ -6,12 +6,14 @@ import ( | |||
| 6 | 6 | "crypto/rand" | |
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "encoding/json" | |
| 9 | + | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "net" | |
| 11 | 12 | "net/http" | |
| 12 | 13 | "os" | |
| 13 | 14 | "os/signal" | |
| 14 | 15 | "path/filepath" | |
| 16 | + | "strconv" | |
| 15 | 17 | "strings" | |
| 16 | 18 | "syscall" | |
| 17 | 19 | "time" | |
| @@ -68,6 +70,7 @@ type Config struct { | |||
| 68 | 70 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | |
| 69 | 71 | DataDir string `mapstructure:"data_dir"` | |
| 70 | 72 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | |
| 73 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |
| 71 | 74 | } `mapstructure:"server"` | |
| 72 | 75 | ||
| 73 | 76 | Logging struct { | |
| @@ -117,23 +120,24 @@ type Config struct { | |||
| 117 | 120 | ||
| 118 | 121 | // services holds all the services used by the application. | |
| 119 | 122 | type services struct { | |
| 120 | - | runtime *docker.Runtime | |
| 121 | - | eventBus *eventbus.InMemory | |
| 122 | - | blobStorage *filesystem.BlobStorage | |
| 123 | - | manifestStorage *filesystem.ManifestStorage | |
| 124 | - | envLoader out.EnvLoader | |
| 125 | - | logWriter *logwriter.LogWriter | |
| 126 | - | tokenStore out.TokenStore | |
| 127 | - | configSvc *config.Service | |
| 128 | - | containerSvc *container.Service | |
| 129 | - | registrySvc *registrySvc.Service | |
| 130 | - | proxySvc *proxy.Service | |
| 131 | - | authSvc *auth.Service | |
| 132 | - | authHandler *authhandler.Handler | |
| 133 | - | adminHandler *admin.Handler | |
| 134 | - | internalRegUser string | |
| 135 | - | internalRegPass string | |
| 136 | - | envDir string | |
| 123 | + | runtime *docker.Runtime | |
| 124 | + | eventBus *eventbus.InMemory | |
| 125 | + | blobStorage *filesystem.BlobStorage | |
| 126 | + | manifestStorage *filesystem.ManifestStorage | |
| 127 | + | envLoader out.EnvLoader | |
| 128 | + | logWriter *logwriter.LogWriter | |
| 129 | + | tokenStore out.TokenStore | |
| 130 | + | configSvc *config.Service | |
| 131 | + | containerSvc *container.Service | |
| 132 | + | registrySvc *registrySvc.Service | |
| 133 | + | proxySvc *proxy.Service | |
| 134 | + | authSvc *auth.Service | |
| 135 | + | authHandler *authhandler.Handler | |
| 136 | + | adminHandler *admin.Handler | |
| 137 | + | internalRegUser string | |
| 138 | + | internalRegPass string | |
| 139 | + | envDir string | |
| 140 | + | maxBlobChunkSize int64 | |
| 137 | 141 | } | |
| 138 | 142 | ||
| 139 | 143 | // Run initializes and starts the Gordon application. | |
| @@ -306,6 +310,18 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 306 | 310 | maxProxyBodySize = parsedSize | |
| 307 | 311 | } | |
| 308 | 312 | ||
| 313 | + | // Parse max_blob_chunk_size config (default: 95MB) | |
| 314 | + | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |
| 315 | + | if cfg.Server.MaxBlobChunkSize != "" { | |
| 316 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |
| 317 | + | if err != nil { | |
| 318 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |
| 319 | + | } | |
| 320 | + | maxBlobChunkSize = parsedSize | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | svc.maxBlobChunkSize = maxBlobChunkSize | |
| 324 | + | ||
| 309 | 325 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | |
| 310 | 326 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 311 | 327 | RegistryPort: cfg.Server.RegistryPort, | |
| @@ -853,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 853 | 869 | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | |
| 854 | 870 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 855 | 871 | Attachments: svc.configSvc.GetAttachments(), | |
| 872 | + | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |
| 856 | 873 | } | |
| 857 | 874 | ||
| 858 | 875 | if cfg.Auth.Enabled && svc.authSvc != nil { | |
| @@ -971,7 +988,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||
| 971 | 988 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | |
| 972 | 989 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | |
| 973 | 990 | ||
| 974 | - | registryHandler := registry.NewHandler(svc.registrySvc, log) | |
| 991 | + | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) | |
| 975 | 992 | ||
| 976 | 993 | registryMiddlewares := []func(http.Handler) http.Handler{ | |
| 977 | 994 | middleware.PanicRecovery(log), | |
| @@ -1186,24 +1203,9 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||
| 1186 | 1203 | ||
| 1187 | 1204 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | |
| 1188 | 1205 | func SendReloadSignal() error { | |
| 1189 | - | pidFile := findPidFile() | |
| 1190 | - | if pidFile == "" { | |
| 1191 | - | return fmt.Errorf("gordon PID file not found, is Gordon running?") | |
| 1192 | - | } | |
| 1193 | - | ||
| 1194 | - | pidBytes, err := os.ReadFile(pidFile) | |
| 1206 | + | process, _, err := findRunningProcess() | |
| 1195 | 1207 | if err != nil { | |
| 1196 | - | return fmt.Errorf("failed to read PID file: %w", err) | |
| 1197 | - | } | |
| 1198 | - | ||
| 1199 | - | var pid int | |
| 1200 | - | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |
| 1201 | - | return fmt.Errorf("failed to parse PID: %w", err) | |
| 1202 | - | } | |
| 1203 | - | ||
| 1204 | - | process, err := os.FindProcess(pid) | |
| 1205 | - | if err != nil { | |
| 1206 | - | return fmt.Errorf("failed to find process: %w", err) | |
| 1208 | + | return err | |
| 1207 | 1209 | } | |
| 1208 | 1210 | ||
| 1209 | 1211 | if err := process.Signal(syscall.SIGUSR1); err != nil { | |
| @@ -1256,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) { | |||
| 1256 | 1258 | } | |
| 1257 | 1259 | ||
| 1258 | 1260 | // Find PID and send SIGUSR2 | |
| 1259 | - | pidFile := findPidFile() | |
| 1260 | - | if pidFile == "" { | |
| 1261 | - | _ = os.Remove(deployFile) | |
| 1262 | - | return "", fmt.Errorf("gordon PID file not found, is Gordon running?") | |
| 1263 | - | } | |
| 1264 | - | ||
| 1265 | - | pidBytes, err := os.ReadFile(pidFile) | |
| 1261 | + | process, _, err := findRunningProcess() | |
| 1266 | 1262 | if err != nil { | |
| 1267 | 1263 | _ = os.Remove(deployFile) | |
| 1268 | - | return "", fmt.Errorf("failed to read PID file: %w", err) | |
| 1269 | - | } | |
| 1270 | - | ||
| 1271 | - | var pid int | |
| 1272 | - | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |
| 1273 | - | _ = os.Remove(deployFile) | |
| 1274 | - | return "", fmt.Errorf("failed to parse PID: %w", err) | |
| 1275 | - | } | |
| 1276 | - | ||
| 1277 | - | process, err := os.FindProcess(pid) | |
| 1278 | - | if err != nil { | |
| 1279 | - | _ = os.Remove(deployFile) | |
| 1280 | - | return "", fmt.Errorf("failed to find process: %w", err) | |
| 1264 | + | return "", err | |
| 1281 | 1265 | } | |
| 1282 | 1266 | ||
| 1283 | 1267 | if err := process.Signal(syscall.SIGUSR2); err != nil { | |
| @@ -1357,34 +1341,113 @@ func removePidFile(pidFile string, log zerowrap.Logger) { | |||
| 1357 | 1341 | } | |
| 1358 | 1342 | } | |
| 1359 | 1343 | ||
| 1360 | - | // findPidFile finds the Gordon PID file. | |
| 1361 | - | // Checks secure locations first, then falls back to legacy /tmp location. | |
| 1362 | - | func findPidFile() string { | |
| 1344 | + | func pidFileLocations() []string { | |
| 1363 | 1345 | var locations []string | |
| 1346 | + | seen := make(map[string]struct{}) | |
| 1347 | + | add := func(path string) { | |
| 1348 | + | if path == "" { | |
| 1349 | + | return | |
| 1350 | + | } | |
| 1351 | + | if _, ok := seen[path]; ok { | |
| 1352 | + | return | |
| 1353 | + | } | |
| 1354 | + | seen[path] = struct{}{} | |
| 1355 | + | locations = append(locations, path) | |
| 1356 | + | } | |
| 1364 | 1357 | ||
| 1365 | 1358 | // Check secure runtime directory first | |
| 1366 | 1359 | if runtimeDir, err := getSecureRuntimeDir(); err == nil { | |
| 1367 | - | locations = append(locations, filepath.Join(runtimeDir, "gordon.pid")) | |
| 1360 | + | add(filepath.Join(runtimeDir, "gordon.pid")) | |
| 1361 | + | } | |
| 1362 | + | ||
| 1363 | + | // Also check canonical /run/user/<uid> runtime path. This handles cases where | |
| 1364 | + | // Gordon started under systemd user services with runtime dir available, but | |
| 1365 | + | // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set. | |
| 1366 | + | runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid") | |
| 1367 | + | add(runtimeByUID) | |
| 1368 | + | ||
| 1369 | + | // Check explicit XDG_RUNTIME_DIR if present in this process env. | |
| 1370 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |
| 1371 | + | add(filepath.Join(runtimeDir, "gordon.pid")) | |
| 1368 | 1372 | } | |
| 1369 | 1373 | ||
| 1370 | 1374 | // Check home directory | |
| 1371 | 1375 | if homeDir, err := os.UserHomeDir(); err == nil { | |
| 1372 | - | locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid")) | |
| 1376 | + | add(filepath.Join(homeDir, ".gordon", "gordon.pid")) | |
| 1373 | 1377 | // Legacy location for backward compatibility | |
| 1374 | - | locations = append(locations, filepath.Join(homeDir, ".gordon.pid")) | |
| 1378 | + | add(filepath.Join(homeDir, ".gordon.pid")) | |
| 1375 | 1379 | } | |
| 1376 | 1380 | ||
| 1377 | 1381 | // Legacy /tmp locations for backward compatibility | |
| 1378 | - | locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid")) | |
| 1379 | - | locations = append(locations, "/tmp/gordon.pid") | |
| 1382 | + | add(filepath.Join(os.TempDir(), "gordon.pid")) | |
| 1383 | + | add("/tmp/gordon.pid") | |
| 1384 | + | ||
| 1385 | + | return locations | |
| 1386 | + | } | |
| 1387 | + | ||
| 1388 | + | // findRunningPidFile returns the first PID file whose PID belongs to a live process. | |
| 1389 | + | // Stale/invalid PID files are ignored and removed when possible. | |
| 1390 | + | func findRunningPidFile() (string, int, error) { | |
| 1391 | + | return findRunningPidFileInLocations(pidFileLocations()) | |
| 1392 | + | } | |
| 1393 | + | ||
| 1394 | + | func findRunningPidFileInLocations(locations []string) (string, int, error) { | |
| 1395 | + | foundAny := false | |
| 1380 | 1396 | ||
| 1381 | 1397 | for _, location := range locations { | |
| 1382 | - | if _, err := os.Stat(location); err == nil { | |
| 1383 | - | return location | |
| 1398 | + | pidBytes, err := os.ReadFile(location) | |
| 1399 | + | if err != nil { | |
| 1400 | + | continue | |
| 1384 | 1401 | } | |
| 1402 | + | ||
| 1403 | + | foundAny = true | |
| 1404 | + | ||
| 1405 | + | var pid int | |
| 1406 | + | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 { | |
| 1407 | + | _ = os.Remove(location) | |
| 1408 | + | continue | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | if isProcessAlive(pid) { | |
| 1412 | + | return location, pid, nil | |
| 1413 | + | } | |
| 1414 | + | ||
| 1415 | + | _ = os.Remove(location) | |
| 1385 | 1416 | } | |
| 1386 | 1417 | ||
| 1387 | - | return "" | |
| 1418 | + | if foundAny { | |
| 1419 | + | return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?") | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?") | |
| 1423 | + | } | |
| 1424 | + | ||
| 1425 | + | func findRunningProcess() (*os.Process, int, error) { | |
| 1426 | + | _, pid, err := findRunningPidFile() | |
| 1427 | + | if err != nil { | |
| 1428 | + | return nil, 0, err | |
| 1429 | + | } | |
| 1430 | + | ||
| 1431 | + | process, err := os.FindProcess(pid) | |
| 1432 | + | if err != nil { | |
| 1433 | + | return nil, 0, fmt.Errorf("failed to find process: %w", err) | |
| 1434 | + | } | |
| 1435 | + | ||
| 1436 | + | return process, pid, nil | |
| 1437 | + | } | |
| 1438 | + | ||
| 1439 | + | func isProcessAlive(pid int) bool { | |
| 1440 | + | process, err := os.FindProcess(pid) | |
| 1441 | + | if err != nil { | |
| 1442 | + | return false | |
| 1443 | + | } | |
| 1444 | + | ||
| 1445 | + | err = process.Signal(syscall.Signal(0)) | |
| 1446 | + | if err == nil { | |
| 1447 | + | return true | |
| 1448 | + | } | |
| 1449 | + | ||
| 1450 | + | return errors.Is(err, syscall.EPERM) | |
| 1388 | 1451 | } | |
| 1389 | 1452 | ||
| 1390 | 1453 | // loadConfig loads configuration from file and sets defaults. | |
| @@ -1421,6 +1484,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1421 | 1484 | v.SetDefault("volumes.prefix", "gordon") | |
| 1422 | 1485 | v.SetDefault("volumes.preserve", true) | |
| 1423 | 1486 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1487 | + | v.SetDefault("deploy.readiness_delay", "5s") | |
| 1424 | 1488 | ||
| 1425 | 1489 | ConfigureViper(v, configPath) | |
| 1426 | 1490 | ||
| @@ -6,12 +6,14 @@ import ( | |||||
| 6 | "crypto/rand" | 6 | "crypto/rand" | ||
| 7 | "encoding/hex" | 7 | "encoding/hex" | ||
| 8 | "encoding/json" | 8 | "encoding/json" | ||
| 9 | + | "errors" | |||
| 9 | "fmt" | 10 | "fmt" | ||
| 10 | "net" | 11 | "net" | ||
| 11 | "net/http" | 12 | "net/http" | ||
| 12 | "os" | 13 | "os" | ||
| 13 | "os/signal" | 14 | "os/signal" | ||
| 14 | "path/filepath" | 15 | "path/filepath" | ||
| 16 | + | "strconv" | |||
| 15 | "strings" | 17 | "strings" | ||
| 16 | "syscall" | 18 | "syscall" | ||
| 17 | "time" | 19 | "time" | ||
| @@ -68,6 +70,7 @@ type Config struct { | |||||
| 68 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | 70 | RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain | ||
| 69 | DataDir string `mapstructure:"data_dir"` | 71 | DataDir string `mapstructure:"data_dir"` | ||
| 70 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | 72 | MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB" | ||
| 73 | + | MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB" | |||
| 71 | } `mapstructure:"server"` | 74 | } `mapstructure:"server"` | ||
| 72 | 75 | ||||
| 73 | Logging struct { | 76 | Logging struct { | ||
| @@ -117,23 +120,24 @@ type Config struct { | |||||
| 117 | 120 | ||||
| 118 | // services holds all the services used by the application. | 121 | // services holds all the services used by the application. | ||
| 119 | type services struct { | 122 | type services struct { | ||
| 120 | - | runtime *docker.Runtime | 123 | + | runtime *docker.Runtime |
| 121 | - | eventBus *eventbus.InMemory | 124 | + | eventBus *eventbus.InMemory |
| 122 | - | blobStorage *filesystem.BlobStorage | 125 | + | blobStorage *filesystem.BlobStorage |
| 123 | - | manifestStorage *filesystem.ManifestStorage | 126 | + | manifestStorage *filesystem.ManifestStorage |
| 124 | - | envLoader out.EnvLoader | 127 | + | envLoader out.EnvLoader |
| 125 | - | logWriter *logwriter.LogWriter | 128 | + | logWriter *logwriter.LogWriter |
| 126 | - | tokenStore out.TokenStore | 129 | + | tokenStore out.TokenStore |
| 127 | - | configSvc *config.Service | 130 | + | configSvc *config.Service |
| 128 | - | containerSvc *container.Service | 131 | + | containerSvc *container.Service |
| 129 | - | registrySvc *registrySvc.Service | 132 | + | registrySvc *registrySvc.Service |
| 130 | - | proxySvc *proxy.Service | 133 | + | proxySvc *proxy.Service |
| 131 | - | authSvc *auth.Service | 134 | + | authSvc *auth.Service |
| 132 | - | authHandler *authhandler.Handler | 135 | + | authHandler *authhandler.Handler |
| 133 | - | adminHandler *admin.Handler | 136 | + | adminHandler *admin.Handler |
| 134 | - | internalRegUser string | 137 | + | internalRegUser string |
| 135 | - | internalRegPass string | 138 | + | internalRegPass string |
| 136 | - | envDir string | 139 | + | envDir string |
| 140 | + | maxBlobChunkSize int64 | |||
| 137 | } | 141 | } | ||
| 138 | 142 | ||||
| 139 | // Run initializes and starts the Gordon application. | 143 | // Run initializes and starts the Gordon application. | ||
| @@ -306,6 +310,18 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 306 | maxProxyBodySize = parsedSize | 310 | maxProxyBodySize = parsedSize | ||
| 307 | } | 311 | } | ||
| 308 | 312 | ||||
| 313 | + | // Parse max_blob_chunk_size config (default: 95MB) | |||
| 314 | + | maxBlobChunkSize := int64(registry.DefaultMaxBlobChunkSize) | |||
| 315 | + | if cfg.Server.MaxBlobChunkSize != "" { | |||
| 316 | + | parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobChunkSize) | |||
| 317 | + | if err != nil { | |||
| 318 | + | return nil, log.WrapErrWithFields(err, "invalid server.max_blob_chunk_size configuration", map[string]any{"value": cfg.Server.MaxBlobChunkSize}) | |||
| 319 | + | } | |||
| 320 | + | maxBlobChunkSize = parsedSize | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | svc.maxBlobChunkSize = maxBlobChunkSize | |||
| 324 | + | ||||
| 309 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | 325 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | ||
| 310 | RegistryDomain: cfg.Server.RegistryDomain, | 326 | RegistryDomain: cfg.Server.RegistryDomain, | ||
| 311 | RegistryPort: cfg.Server.RegistryPort, | 327 | RegistryPort: cfg.Server.RegistryPort, | ||
| @@ -853,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 853 | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | 869 | DNSSuffix: v.GetString("network_isolation.dns_suffix"), | ||
| 854 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | 870 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | ||
| 855 | Attachments: svc.configSvc.GetAttachments(), | 871 | Attachments: svc.configSvc.GetAttachments(), | ||
| 872 | + | ReadinessDelay: v.GetDuration("deploy.readiness_delay"), | |||
| 856 | } | 873 | } | ||
| 857 | 874 | ||||
| 858 | if cfg.Auth.Enabled && svc.authSvc != nil { | 875 | if cfg.Auth.Enabled && svc.authSvc != nil { | ||
| @@ -971,7 +988,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha | |||||
| 971 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | 988 | // This ensures consistent IP extraction across logging, rate limiting, and auth. | ||
| 972 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | 989 | trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies) | ||
| 973 | 990 | ||||
| 974 | - | registryHandler := registry.NewHandler(svc.registrySvc, log) | 991 | + | registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize) |
| 975 | 992 | ||||
| 976 | registryMiddlewares := []func(http.Handler) http.Handler{ | 993 | registryMiddlewares := []func(http.Handler) http.Handler{ | ||
| 977 | middleware.PanicRecovery(log), | 994 | middleware.PanicRecovery(log), | ||
| @@ -1186,24 +1203,9 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<- | |||||
| 1186 | 1203 | ||||
| 1187 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | 1204 | // SendReloadSignal sends SIGUSR1 to the running Gordon process. | ||
| 1188 | func SendReloadSignal() error { | 1205 | func SendReloadSignal() error { | ||
| 1189 | - | pidFile := findPidFile() | 1206 | + | process, _, err := findRunningProcess() |
| 1190 | - | if pidFile == "" { | |||
| 1191 | - | return fmt.Errorf("gordon PID file not found, is Gordon running?") | |||
| 1192 | - | } | |||
| 1193 | - | ||||
| 1194 | - | pidBytes, err := os.ReadFile(pidFile) | |||
| 1195 | if err != nil { | 1207 | if err != nil { | ||
| 1196 | - | return fmt.Errorf("failed to read PID file: %w", err) | 1208 | + | return err |
| 1197 | - | } | |||
| 1198 | - | ||||
| 1199 | - | var pid int | |||
| 1200 | - | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |||
| 1201 | - | return fmt.Errorf("failed to parse PID: %w", err) | |||
| 1202 | - | } | |||
| 1203 | - | ||||
| 1204 | - | process, err := os.FindProcess(pid) | |||
| 1205 | - | if err != nil { | |||
| 1206 | - | return fmt.Errorf("failed to find process: %w", err) | |||
| 1207 | } | 1209 | } | ||
| 1208 | 1210 | ||||
| 1209 | if err := process.Signal(syscall.SIGUSR1); err != nil { | 1211 | if err := process.Signal(syscall.SIGUSR1); err != nil { | ||
| @@ -1256,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) { | |||||
| 1256 | } | 1258 | } | ||
| 1257 | 1259 | ||||
| 1258 | // Find PID and send SIGUSR2 | 1260 | // Find PID and send SIGUSR2 | ||
| 1259 | - | pidFile := findPidFile() | 1261 | + | process, _, err := findRunningProcess() |
| 1260 | - | if pidFile == "" { | |||
| 1261 | - | _ = os.Remove(deployFile) | |||
| 1262 | - | return "", fmt.Errorf("gordon PID file not found, is Gordon running?") | |||
| 1263 | - | } | |||
| 1264 | - | ||||
| 1265 | - | pidBytes, err := os.ReadFile(pidFile) | |||
| 1266 | if err != nil { | 1262 | if err != nil { | ||
| 1267 | _ = os.Remove(deployFile) | 1263 | _ = os.Remove(deployFile) | ||
| 1268 | - | return "", fmt.Errorf("failed to read PID file: %w", err) | 1264 | + | return "", err |
| 1269 | - | } | |||
| 1270 | - | ||||
| 1271 | - | var pid int | |||
| 1272 | - | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil { | |||
| 1273 | - | _ = os.Remove(deployFile) | |||
| 1274 | - | return "", fmt.Errorf("failed to parse PID: %w", err) | |||
| 1275 | - | } | |||
| 1276 | - | ||||
| 1277 | - | process, err := os.FindProcess(pid) | |||
| 1278 | - | if err != nil { | |||
| 1279 | - | _ = os.Remove(deployFile) | |||
| 1280 | - | return "", fmt.Errorf("failed to find process: %w", err) | |||
| 1281 | } | 1265 | } | ||
| 1282 | 1266 | ||||
| 1283 | if err := process.Signal(syscall.SIGUSR2); err != nil { | 1267 | if err := process.Signal(syscall.SIGUSR2); err != nil { | ||
| @@ -1357,34 +1341,113 @@ func removePidFile(pidFile string, log zerowrap.Logger) { | |||||
| 1357 | } | 1341 | } | ||
| 1358 | } | 1342 | } | ||
| 1359 | 1343 | ||||
| 1360 | - | // findPidFile finds the Gordon PID file. | 1344 | + | func pidFileLocations() []string { |
| 1361 | - | // Checks secure locations first, then falls back to legacy /tmp location. | |||
| 1362 | - | func findPidFile() string { | |||
| 1363 | var locations []string | 1345 | var locations []string | ||
| 1346 | + | seen := make(map[string]struct{}) | |||
| 1347 | + | add := func(path string) { | |||
| 1348 | + | if path == "" { | |||
| 1349 | + | return | |||
| 1350 | + | } | |||
| 1351 | + | if _, ok := seen[path]; ok { | |||
| 1352 | + | return | |||
| 1353 | + | } | |||
| 1354 | + | seen[path] = struct{}{} | |||
| 1355 | + | locations = append(locations, path) | |||
| 1356 | + | } | |||
| 1364 | 1357 | ||||
| 1365 | // Check secure runtime directory first | 1358 | // Check secure runtime directory first | ||
| 1366 | if runtimeDir, err := getSecureRuntimeDir(); err == nil { | 1359 | if runtimeDir, err := getSecureRuntimeDir(); err == nil { | ||
| 1367 | - | locations = append(locations, filepath.Join(runtimeDir, "gordon.pid")) | 1360 | + | add(filepath.Join(runtimeDir, "gordon.pid")) |
| 1361 | + | } | |||
| 1362 | + | ||||
| 1363 | + | // Also check canonical /run/user/<uid> runtime path. This handles cases where | |||
| 1364 | + | // Gordon started under systemd user services with runtime dir available, but | |||
| 1365 | + | // CLI invocations (e.g. non-interactive SSH) don't have XDG_RUNTIME_DIR set. | |||
| 1366 | + | runtimeByUID := filepath.Join("/run/user", strconv.Itoa(os.Getuid()), "gordon", "gordon.pid") | |||
| 1367 | + | add(runtimeByUID) | |||
| 1368 | + | ||||
| 1369 | + | // Check explicit XDG_RUNTIME_DIR if present in this process env. | |||
| 1370 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" { | |||
| 1371 | + | add(filepath.Join(runtimeDir, "gordon.pid")) | |||
| 1368 | } | 1372 | } | ||
| 1369 | 1373 | ||||
| 1370 | // Check home directory | 1374 | // Check home directory | ||
| 1371 | if homeDir, err := os.UserHomeDir(); err == nil { | 1375 | if homeDir, err := os.UserHomeDir(); err == nil { | ||
| 1372 | - | locations = append(locations, filepath.Join(homeDir, ".gordon", "gordon.pid")) | 1376 | + | add(filepath.Join(homeDir, ".gordon", "gordon.pid")) |
| 1373 | // Legacy location for backward compatibility | 1377 | // Legacy location for backward compatibility | ||
| 1374 | - | locations = append(locations, filepath.Join(homeDir, ".gordon.pid")) | 1378 | + | add(filepath.Join(homeDir, ".gordon.pid")) |
| 1375 | } | 1379 | } | ||
| 1376 | 1380 | ||||
| 1377 | // Legacy /tmp locations for backward compatibility | 1381 | // Legacy /tmp locations for backward compatibility | ||
| 1378 | - | locations = append(locations, filepath.Join(os.TempDir(), "gordon.pid")) | 1382 | + | add(filepath.Join(os.TempDir(), "gordon.pid")) |
| 1379 | - | locations = append(locations, "/tmp/gordon.pid") | 1383 | + | add("/tmp/gordon.pid") |
| 1384 | + | ||||
| 1385 | + | return locations | |||
| 1386 | + | } | |||
| 1387 | + | ||||
| 1388 | + | // findRunningPidFile returns the first PID file whose PID belongs to a live process. | |||
| 1389 | + | // Stale/invalid PID files are ignored and removed when possible. | |||
| 1390 | + | func findRunningPidFile() (string, int, error) { | |||
| 1391 | + | return findRunningPidFileInLocations(pidFileLocations()) | |||
| 1392 | + | } | |||
| 1393 | + | ||||
| 1394 | + | func findRunningPidFileInLocations(locations []string) (string, int, error) { | |||
| 1395 | + | foundAny := false | |||
| 1380 | 1396 | ||||
| 1381 | for _, location := range locations { | 1397 | for _, location := range locations { | ||
| 1382 | - | if _, err := os.Stat(location); err == nil { | 1398 | + | pidBytes, err := os.ReadFile(location) |
| 1383 | - | return location | 1399 | + | if err != nil { |
| 1400 | + | continue | |||
| 1384 | } | 1401 | } | ||
| 1402 | + | ||||
| 1403 | + | foundAny = true | |||
| 1404 | + | ||||
| 1405 | + | var pid int | |||
| 1406 | + | if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil || pid <= 0 { | |||
| 1407 | + | _ = os.Remove(location) | |||
| 1408 | + | continue | |||
| 1409 | + | } | |||
| 1410 | + | ||||
| 1411 | + | if isProcessAlive(pid) { | |||
| 1412 | + | return location, pid, nil | |||
| 1413 | + | } | |||
| 1414 | + | ||||
| 1415 | + | _ = os.Remove(location) | |||
| 1385 | } | 1416 | } | ||
| 1386 | 1417 | ||||
| 1387 | - | return "" | 1418 | + | if foundAny { |
| 1419 | + | return "", 0, fmt.Errorf("found stale gordon PID file(s), is Gordon running?") | |||
| 1420 | + | } | |||
| 1421 | + | ||||
| 1422 | + | return "", 0, fmt.Errorf("gordon PID file not found, is Gordon running?") | |||
| 1423 | + | } | |||
| 1424 | + | ||||
| 1425 | + | func findRunningProcess() (*os.Process, int, error) { | |||
| 1426 | + | _, pid, err := findRunningPidFile() | |||
| 1427 | + | if err != nil { | |||
| 1428 | + | return nil, 0, err | |||
| 1429 | + | } | |||
| 1430 | + | ||||
| 1431 | + | process, err := os.FindProcess(pid) | |||
| 1432 | + | if err != nil { | |||
| 1433 | + | return nil, 0, fmt.Errorf("failed to find process: %w", err) | |||
| 1434 | + | } | |||
| 1435 | + | ||||
| 1436 | + | return process, pid, nil | |||
| 1437 | + | } | |||
| 1438 | + | ||||
| 1439 | + | func isProcessAlive(pid int) bool { | |||
| 1440 | + | process, err := os.FindProcess(pid) | |||
| 1441 | + | if err != nil { | |||
| 1442 | + | return false | |||
| 1443 | + | } | |||
| 1444 | + | ||||
| 1445 | + | err = process.Signal(syscall.Signal(0)) | |||
| 1446 | + | if err == nil { | |||
| 1447 | + | return true | |||
| 1448 | + | } | |||
| 1449 | + | ||||
| 1450 | + | return errors.Is(err, syscall.EPERM) | |||
| 1388 | } | 1451 | } | ||
| 1389 | 1452 | ||||
| 1390 | // loadConfig loads configuration from file and sets defaults. | 1453 | // loadConfig loads configuration from file and sets defaults. | ||
| @@ -1421,6 +1484,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1421 | v.SetDefault("volumes.prefix", "gordon") | 1484 | v.SetDefault("volumes.prefix", "gordon") | ||
| 1422 | v.SetDefault("volumes.preserve", true) | 1485 | v.SetDefault("volumes.preserve", true) | ||
| 1423 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | 1486 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | ||
| 1487 | + | v.SetDefault("deploy.readiness_delay", "5s") | |||
| 1424 | 1488 | ||||
| 1425 | ConfigureViper(v, configPath) | 1489 | ConfigureViper(v, configPath) | ||
| 1426 | 1490 | ||||
A internal/app/run_pid_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strconv" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestFindRunningPidFileInLocations_PrefersLiveProcessOverStale(t *testing.T) { | |
| 14 | + | tmpDir := t.TempDir() | |
| 15 | + | staleFile := filepath.Join(tmpDir, "stale.pid") | |
| 16 | + | liveFile := filepath.Join(tmpDir, "live.pid") | |
| 17 | + | ||
| 18 | + | require.NoError(t, os.WriteFile(staleFile, []byte("999999"), 0600)) | |
| 19 | + | myPid := os.Getpid() | |
| 20 | + | require.NoError(t, os.WriteFile(liveFile, []byte(strconv.Itoa(myPid)), 0600)) | |
| 21 | + | ||
| 22 | + | path, pid, err := findRunningPidFileInLocations([]string{staleFile, liveFile}) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | assert.Equal(t, liveFile, path) | |
| 25 | + | assert.Equal(t, myPid, pid) | |
| 26 | + | ||
| 27 | + | _, statErr := os.Stat(staleFile) | |
| 28 | + | assert.True(t, os.IsNotExist(statErr)) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestFindRunningPidFileInLocations_RemovesInvalidAndReturnsError(t *testing.T) { | |
| 32 | + | tmpDir := t.TempDir() | |
| 33 | + | invalidFile := filepath.Join(tmpDir, "invalid.pid") | |
| 34 | + | ||
| 35 | + | require.NoError(t, os.WriteFile(invalidFile, []byte("not-a-pid"), 0600)) | |
| 36 | + | ||
| 37 | + | _, _, err := findRunningPidFileInLocations([]string{invalidFile}) | |
| 38 | + | require.Error(t, err) | |
| 39 | + | assert.Contains(t, err.Error(), "stale gordon PID file") | |
| 40 | + | ||
| 41 | + | _, statErr := os.Stat(invalidFile) | |
| 42 | + | assert.True(t, os.IsNotExist(statErr)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestFindRunningPidFileInLocations_NoFiles(t *testing.T) { | |
| 46 | + | _, _, err := findRunningPidFileInLocations([]string{}) | |
| 47 | + | require.Error(t, err) | |
| 48 | + | assert.Contains(t, err.Error(), "gordon PID file not found") | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strconv" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestFindRunningPidFileInLocations_PrefersLiveProcessOverStale(t *testing.T) { | |||
| 14 | + | tmpDir := t.TempDir() | |||
| 15 | + | staleFile := filepath.Join(tmpDir, "stale.pid") | |||
| 16 | + | liveFile := filepath.Join(tmpDir, "live.pid") | |||
| 17 | + | ||||
| 18 | + | require.NoError(t, os.WriteFile(staleFile, []byte("999999"), 0600)) | |||
| 19 | + | myPid := os.Getpid() | |||
| 20 | + | require.NoError(t, os.WriteFile(liveFile, []byte(strconv.Itoa(myPid)), 0600)) | |||
| 21 | + | ||||
| 22 | + | path, pid, err := findRunningPidFileInLocations([]string{staleFile, liveFile}) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | assert.Equal(t, liveFile, path) | |||
| 25 | + | assert.Equal(t, myPid, pid) | |||
| 26 | + | ||||
| 27 | + | _, statErr := os.Stat(staleFile) | |||
| 28 | + | assert.True(t, os.IsNotExist(statErr)) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestFindRunningPidFileInLocations_RemovesInvalidAndReturnsError(t *testing.T) { | |||
| 32 | + | tmpDir := t.TempDir() | |||
| 33 | + | invalidFile := filepath.Join(tmpDir, "invalid.pid") | |||
| 34 | + | ||||
| 35 | + | require.NoError(t, os.WriteFile(invalidFile, []byte("not-a-pid"), 0600)) | |||
| 36 | + | ||||
| 37 | + | _, _, err := findRunningPidFileInLocations([]string{invalidFile}) | |||
| 38 | + | require.Error(t, err) | |||
| 39 | + | assert.Contains(t, err.Error(), "stale gordon PID file") | |||
| 40 | + | ||||
| 41 | + | _, statErr := os.Stat(invalidFile) | |||
| 42 | + | assert.True(t, os.IsNotExist(statErr)) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestFindRunningPidFileInLocations_NoFiles(t *testing.T) { | |||
| 46 | + | _, _, err := findRunningPidFileInLocations([]string{}) | |||
| 47 | + | require.Error(t, err) | |||
| 48 | + | assert.Contains(t, err.Error(), "gordon PID file not found") | |||
| 49 | + | } | |||
M internal/boundaries/in/mocks/mock_registry_service.go +15 -15
| @@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter { | |||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | // AppendBlobChunk provides a mock function for the type MockRegistryService | |
| 43 | - | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, chunk []byte) (int64, error) { | |
| 44 | - | ret := _mock.Called(ctx, name, uuid, chunk) | |
| 43 | + | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) { | |
| 44 | + | ret := _mock.Called(ctx, name, uuid, data) | |
| 45 | 45 | ||
| 46 | 46 | if len(ret) == 0 { | |
| 47 | 47 | panic("no return value specified for AppendBlobChunk") | |
| @@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri | |||
| 49 | 49 | ||
| 50 | 50 | var r0 int64 | |
| 51 | 51 | var r1 error | |
| 52 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) (int64, error)); ok { | |
| 53 | - | return returnFunc(ctx, name, uuid, chunk) | |
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok { | |
| 53 | + | return returnFunc(ctx, name, uuid, data) | |
| 54 | 54 | } | |
| 55 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) int64); ok { | |
| 56 | - | r0 = returnFunc(ctx, name, uuid, chunk) | |
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok { | |
| 56 | + | r0 = returnFunc(ctx, name, uuid, data) | |
| 57 | 57 | } else { | |
| 58 | 58 | r0 = ret.Get(0).(int64) | |
| 59 | 59 | } | |
| 60 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte) error); ok { | |
| 61 | - | r1 = returnFunc(ctx, name, uuid, chunk) | |
| 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok { | |
| 61 | + | r1 = returnFunc(ctx, name, uuid, data) | |
| 62 | 62 | } else { | |
| 63 | 63 | r1 = ret.Error(1) | |
| 64 | 64 | } | |
| @@ -74,12 +74,12 @@ type MockRegistryService_AppendBlobChunk_Call struct { | |||
| 74 | 74 | // - ctx context.Context | |
| 75 | 75 | // - name string | |
| 76 | 76 | // - uuid string | |
| 77 | - | // - chunk []byte | |
| 78 | - | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, chunk interface{}) *MockRegistryService_AppendBlobChunk_Call { | |
| 79 | - | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, chunk)} | |
| 77 | + | // - data io.Reader | |
| 78 | + | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call { | |
| 79 | + | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)} | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, chunk []byte)) *MockRegistryService_AppendBlobChunk_Call { | |
| 82 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call { | |
| 83 | 83 | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | 84 | var arg0 context.Context | |
| 85 | 85 | if args[0] != nil { | |
| @@ -93,9 +93,9 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con | |||
| 93 | 93 | if args[2] != nil { | |
| 94 | 94 | arg2 = args[2].(string) | |
| 95 | 95 | } | |
| 96 | - | var arg3 []byte | |
| 96 | + | var arg3 io.Reader | |
| 97 | 97 | if args[3] != nil { | |
| 98 | - | arg3 = args[3].([]byte) | |
| 98 | + | arg3 = args[3].(io.Reader) | |
| 99 | 99 | } | |
| 100 | 100 | run( | |
| 101 | 101 | arg0, | |
| @@ -112,7 +112,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) * | |||
| 112 | 112 | return _c | |
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, chunk []byte) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { | |
| 115 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { | |
| 116 | 116 | _c.Call.Return(run) | |
| 117 | 117 | return _c | |
| 118 | 118 | } | |
| @@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter { | |||||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | // AppendBlobChunk provides a mock function for the type MockRegistryService | 42 | // AppendBlobChunk provides a mock function for the type MockRegistryService | ||
| 43 | - | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, chunk []byte) (int64, error) { | 43 | + | func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) { |
| 44 | - | ret := _mock.Called(ctx, name, uuid, chunk) | 44 | + | ret := _mock.Called(ctx, name, uuid, data) |
| 45 | 45 | ||||
| 46 | if len(ret) == 0 { | 46 | if len(ret) == 0 { | ||
| 47 | panic("no return value specified for AppendBlobChunk") | 47 | panic("no return value specified for AppendBlobChunk") | ||
| @@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri | |||||
| 49 | 49 | ||||
| 50 | var r0 int64 | 50 | var r0 int64 | ||
| 51 | var r1 error | 51 | var r1 error | ||
| 52 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) (int64, error)); ok { | 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok { |
| 53 | - | return returnFunc(ctx, name, uuid, chunk) | 53 | + | return returnFunc(ctx, name, uuid, data) |
| 54 | } | 54 | } | ||
| 55 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte) int64); ok { | 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok { |
| 56 | - | r0 = returnFunc(ctx, name, uuid, chunk) | 56 | + | r0 = returnFunc(ctx, name, uuid, data) |
| 57 | } else { | 57 | } else { | ||
| 58 | r0 = ret.Get(0).(int64) | 58 | r0 = ret.Get(0).(int64) | ||
| 59 | } | 59 | } | ||
| 60 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte) error); ok { | 60 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok { |
| 61 | - | r1 = returnFunc(ctx, name, uuid, chunk) | 61 | + | r1 = returnFunc(ctx, name, uuid, data) |
| 62 | } else { | 62 | } else { | ||
| 63 | r1 = ret.Error(1) | 63 | r1 = ret.Error(1) | ||
| 64 | } | 64 | } | ||
| @@ -74,12 +74,12 @@ type MockRegistryService_AppendBlobChunk_Call struct { | |||||
| 74 | // - ctx context.Context | 74 | // - ctx context.Context | ||
| 75 | // - name string | 75 | // - name string | ||
| 76 | // - uuid string | 76 | // - uuid string | ||
| 77 | - | // - chunk []byte | 77 | + | // - data io.Reader |
| 78 | - | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, chunk interface{}) *MockRegistryService_AppendBlobChunk_Call { | 78 | + | func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call { |
| 79 | - | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, chunk)} | 79 | + | return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)} |
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, chunk []byte)) *MockRegistryService_AppendBlobChunk_Call { | 82 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call { |
| 83 | _c.Call.Run(func(args mock.Arguments) { | 83 | _c.Call.Run(func(args mock.Arguments) { | ||
| 84 | var arg0 context.Context | 84 | var arg0 context.Context | ||
| 85 | if args[0] != nil { | 85 | if args[0] != nil { | ||
| @@ -93,9 +93,9 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con | |||||
| 93 | if args[2] != nil { | 93 | if args[2] != nil { | ||
| 94 | arg2 = args[2].(string) | 94 | arg2 = args[2].(string) | ||
| 95 | } | 95 | } | ||
| 96 | - | var arg3 []byte | 96 | + | var arg3 io.Reader |
| 97 | if args[3] != nil { | 97 | if args[3] != nil { | ||
| 98 | - | arg3 = args[3].([]byte) | 98 | + | arg3 = args[3].(io.Reader) |
| 99 | } | 99 | } | ||
| 100 | run( | 100 | run( | ||
| 101 | arg0, | 101 | arg0, | ||
| @@ -112,7 +112,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) * | |||||
| 112 | return _c | 112 | return _c | ||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | - | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, chunk []byte) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { | 115 | + | func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call { |
| 116 | _c.Call.Return(run) | 116 | _c.Call.Return(run) | ||
| 117 | return _c | 117 | return _c | ||
| 118 | } | 118 | } | ||
M internal/boundaries/in/registry.go +1 -1
| @@ -22,7 +22,7 @@ type RegistryService interface { | |||
| 22 | 22 | ||
| 23 | 23 | // Upload operations | |
| 24 | 24 | StartUpload(ctx context.Context, name string) (string, error) | |
| 25 | - | AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) | |
| 25 | + | AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) | |
| 26 | 26 | FinishUpload(ctx context.Context, uuid, digest string) error | |
| 27 | 27 | CancelUpload(ctx context.Context, uuid string) error | |
| 28 | 28 | ||
| @@ -22,7 +22,7 @@ type RegistryService interface { | |||||
| 22 | 22 | ||||
| 23 | // Upload operations | 23 | // Upload operations | ||
| 24 | StartUpload(ctx context.Context, name string) (string, error) | 24 | StartUpload(ctx context.Context, name string) (string, error) | ||
| 25 | - | AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) | 25 | + | AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) |
| 26 | FinishUpload(ctx context.Context, uuid, digest string) error | 26 | FinishUpload(ctx context.Context, uuid, digest string) error | ||
| 27 | CancelUpload(ctx context.Context, uuid string) error | 27 | CancelUpload(ctx context.Context, uuid string) error | ||
| 28 | 28 | ||||
M internal/boundaries/out/mocks/mock_blob_storage.go +15 -15
| @@ -38,8 +38,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter { | |||
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | 40 | // AppendBlobChunk provides a mock function for the type MockBlobStorage | |
| 41 | - | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk []byte) (int64, error) { | |
| 42 | - | ret := _mock.Called(name, uuid, chunk) | |
| 41 | + | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) { | |
| 42 | + | ret := _mock.Called(name, uuid, data) | |
| 43 | 43 | ||
| 44 | 44 | if len(ret) == 0 { | |
| 45 | 45 | panic("no return value specified for AppendBlobChunk") | |
| @@ -47,16 +47,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk [] | |||
| 47 | 47 | ||
| 48 | 48 | var r0 int64 | |
| 49 | 49 | var r1 error | |
| 50 | - | if returnFunc, ok := ret.Get(0).(func(string, string, []byte) (int64, error)); ok { | |
| 51 | - | return returnFunc(name, uuid, chunk) | |
| 50 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok { | |
| 51 | + | return returnFunc(name, uuid, data) | |
| 52 | 52 | } | |
| 53 | - | if returnFunc, ok := ret.Get(0).(func(string, string, []byte) int64); ok { | |
| 54 | - | r0 = returnFunc(name, uuid, chunk) | |
| 53 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok { | |
| 54 | + | r0 = returnFunc(name, uuid, data) | |
| 55 | 55 | } else { | |
| 56 | 56 | r0 = ret.Get(0).(int64) | |
| 57 | 57 | } | |
| 58 | - | if returnFunc, ok := ret.Get(1).(func(string, string, []byte) error); ok { | |
| 59 | - | r1 = returnFunc(name, uuid, chunk) | |
| 58 | + | if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok { | |
| 59 | + | r1 = returnFunc(name, uuid, data) | |
| 60 | 60 | } else { | |
| 61 | 61 | r1 = ret.Error(1) | |
| 62 | 62 | } | |
| @@ -71,12 +71,12 @@ type MockBlobStorage_AppendBlobChunk_Call struct { | |||
| 71 | 71 | // AppendBlobChunk is a helper method to define mock.On call | |
| 72 | 72 | // - name string | |
| 73 | 73 | // - uuid string | |
| 74 | - | // - chunk []byte | |
| 75 | - | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, chunk interface{}) *MockBlobStorage_AppendBlobChunk_Call { | |
| 76 | - | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, chunk)} | |
| 74 | + | // - data io.Reader | |
| 75 | + | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call { | |
| 76 | + | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)} | |
| 77 | 77 | } | |
| 78 | 78 | ||
| 79 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, chunk []byte)) *MockBlobStorage_AppendBlobChunk_Call { | |
| 79 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call { | |
| 80 | 80 | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | 81 | var arg0 string | |
| 82 | 82 | if args[0] != nil { | |
| @@ -86,9 +86,9 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s | |||
| 86 | 86 | if args[1] != nil { | |
| 87 | 87 | arg1 = args[1].(string) | |
| 88 | 88 | } | |
| 89 | - | var arg2 []byte | |
| 89 | + | var arg2 io.Reader | |
| 90 | 90 | if args[2] != nil { | |
| 91 | - | arg2 = args[2].([]byte) | |
| 91 | + | arg2 = args[2].(io.Reader) | |
| 92 | 92 | } | |
| 93 | 93 | run( | |
| 94 | 94 | arg0, | |
| @@ -104,7 +104,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock | |||
| 104 | 104 | return _c | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, chunk []byte) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { | |
| 107 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { | |
| 108 | 108 | _c.Call.Return(run) | |
| 109 | 109 | return _c | |
| 110 | 110 | } | |
| @@ -38,8 +38,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter { | |||||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | // AppendBlobChunk provides a mock function for the type MockBlobStorage | 40 | // AppendBlobChunk provides a mock function for the type MockBlobStorage | ||
| 41 | - | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk []byte) (int64, error) { | 41 | + | func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) { |
| 42 | - | ret := _mock.Called(name, uuid, chunk) | 42 | + | ret := _mock.Called(name, uuid, data) |
| 43 | 43 | ||||
| 44 | if len(ret) == 0 { | 44 | if len(ret) == 0 { | ||
| 45 | panic("no return value specified for AppendBlobChunk") | 45 | panic("no return value specified for AppendBlobChunk") | ||
| @@ -47,16 +47,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk [] | |||||
| 47 | 47 | ||||
| 48 | var r0 int64 | 48 | var r0 int64 | ||
| 49 | var r1 error | 49 | var r1 error | ||
| 50 | - | if returnFunc, ok := ret.Get(0).(func(string, string, []byte) (int64, error)); ok { | 50 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok { |
| 51 | - | return returnFunc(name, uuid, chunk) | 51 | + | return returnFunc(name, uuid, data) |
| 52 | } | 52 | } | ||
| 53 | - | if returnFunc, ok := ret.Get(0).(func(string, string, []byte) int64); ok { | 53 | + | if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok { |
| 54 | - | r0 = returnFunc(name, uuid, chunk) | 54 | + | r0 = returnFunc(name, uuid, data) |
| 55 | } else { | 55 | } else { | ||
| 56 | r0 = ret.Get(0).(int64) | 56 | r0 = ret.Get(0).(int64) | ||
| 57 | } | 57 | } | ||
| 58 | - | if returnFunc, ok := ret.Get(1).(func(string, string, []byte) error); ok { | 58 | + | if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok { |
| 59 | - | r1 = returnFunc(name, uuid, chunk) | 59 | + | r1 = returnFunc(name, uuid, data) |
| 60 | } else { | 60 | } else { | ||
| 61 | r1 = ret.Error(1) | 61 | r1 = ret.Error(1) | ||
| 62 | } | 62 | } | ||
| @@ -71,12 +71,12 @@ type MockBlobStorage_AppendBlobChunk_Call struct { | |||||
| 71 | // AppendBlobChunk is a helper method to define mock.On call | 71 | // AppendBlobChunk is a helper method to define mock.On call | ||
| 72 | // - name string | 72 | // - name string | ||
| 73 | // - uuid string | 73 | // - uuid string | ||
| 74 | - | // - chunk []byte | 74 | + | // - data io.Reader |
| 75 | - | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, chunk interface{}) *MockBlobStorage_AppendBlobChunk_Call { | 75 | + | func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call { |
| 76 | - | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, chunk)} | 76 | + | return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)} |
| 77 | } | 77 | } | ||
| 78 | 78 | ||||
| 79 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, chunk []byte)) *MockBlobStorage_AppendBlobChunk_Call { | 79 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call { |
| 80 | _c.Call.Run(func(args mock.Arguments) { | 80 | _c.Call.Run(func(args mock.Arguments) { | ||
| 81 | var arg0 string | 81 | var arg0 string | ||
| 82 | if args[0] != nil { | 82 | if args[0] != nil { | ||
| @@ -86,9 +86,9 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s | |||||
| 86 | if args[1] != nil { | 86 | if args[1] != nil { | ||
| 87 | arg1 = args[1].(string) | 87 | arg1 = args[1].(string) | ||
| 88 | } | 88 | } | ||
| 89 | - | var arg2 []byte | 89 | + | var arg2 io.Reader |
| 90 | if args[2] != nil { | 90 | if args[2] != nil { | ||
| 91 | - | arg2 = args[2].([]byte) | 91 | + | arg2 = args[2].(io.Reader) |
| 92 | } | 92 | } | ||
| 93 | run( | 93 | run( | ||
| 94 | arg0, | 94 | arg0, | ||
| @@ -104,7 +104,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock | |||||
| 104 | return _c | 104 | return _c | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | - | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, chunk []byte) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { | 107 | + | func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call { |
| 108 | _c.Call.Return(run) | 108 | _c.Call.Return(run) | ||
| 109 | return _c | 109 | return _c | ||
| 110 | } | 110 | } | ||
M internal/boundaries/out/storage.go +1 -1
| @@ -25,7 +25,7 @@ type BlobStorage interface { | |||
| 25 | 25 | StartBlobUpload(name string) (string, error) | |
| 26 | 26 | ||
| 27 | 27 | // AppendBlobChunk appends data to an in-progress upload. | |
| 28 | - | AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) | |
| 28 | + | AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) | |
| 29 | 29 | ||
| 30 | 30 | // GetBlobUpload returns a writer for the upload. | |
| 31 | 31 | GetBlobUpload(uuid string) (io.WriteCloser, error) | |
| @@ -25,7 +25,7 @@ type BlobStorage interface { | |||||
| 25 | StartBlobUpload(name string) (string, error) | 25 | StartBlobUpload(name string) (string, error) | ||
| 26 | 26 | ||||
| 27 | // AppendBlobChunk appends data to an in-progress upload. | 27 | // AppendBlobChunk appends data to an in-progress upload. | ||
| 28 | - | AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) | 28 | + | AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) |
| 29 | 29 | ||||
| 30 | // GetBlobUpload returns a writer for the upload. | 30 | // GetBlobUpload returns a writer for the upload. | ||
| 31 | GetBlobUpload(uuid string) (io.WriteCloser, error) | 31 | GetBlobUpload(uuid string) (io.WriteCloser, error) | ||
M internal/usecase/container/events.go +2 -2
| @@ -332,8 +332,8 @@ func (h *ManualDeployHandler) Handle(ctx context.Context, event domain.Event) er | |||
| 332 | 332 | return fmt.Errorf("route not found for domain: %s", payload.Domain) | |
| 333 | 333 | } | |
| 334 | 334 | ||
| 335 | - | // Deploy the container (this will replace any existing container) | |
| 336 | - | if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil { | |
| 335 | + | // Manual deploy is an internal trigger, so use internal deploy context. | |
| 336 | + | if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *targetRoute); err != nil { | |
| 337 | 337 | return log.WrapErr(err, "failed to deploy container") | |
| 338 | 338 | } | |
| 339 | 339 | ||
| @@ -332,8 +332,8 @@ func (h *ManualDeployHandler) Handle(ctx context.Context, event domain.Event) er | |||||
| 332 | return fmt.Errorf("route not found for domain: %s", payload.Domain) | 332 | return fmt.Errorf("route not found for domain: %s", payload.Domain) | ||
| 333 | } | 333 | } | ||
| 334 | 334 | ||||
| 335 | - | // Deploy the container (this will replace any existing container) | 335 | + | // Manual deploy is an internal trigger, so use internal deploy context. |
| 336 | - | if _, err := h.containerSvc.Deploy(ctx, *targetRoute); err != nil { | 336 | + | if _, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *targetRoute); err != nil { |
| 337 | return log.WrapErr(err, "failed to deploy container") | 337 | return log.WrapErr(err, "failed to deploy container") | ||
| 338 | } | 338 | } | ||
| 339 | 339 | ||||
M internal/usecase/container/service.go +168 -31
| @@ -3,6 +3,7 @@ package container | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "errors" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "maps" | |
| 8 | 9 | "slices" | |
| @@ -41,6 +42,12 @@ const ( | |||
| 41 | 42 | PullPolicyAlways = "always" | |
| 42 | 43 | PullPolicyIfNotPresent = "if-not-present" | |
| 43 | 44 | PullPolicyIfTagChanged = "if-tag-changed" | |
| 45 | + | ||
| 46 | + | // readinessRecoveryWindow is an additional grace window used when a container | |
| 47 | + | // is briefly not running at the end of readiness delay. This avoids false | |
| 48 | + | // negatives during short startup flaps. | |
| 49 | + | readinessRecoveryWindow = 30 * time.Second | |
| 50 | + | internalPullMaxAttempts = 3 | |
| 44 | 51 | ) | |
| 45 | 52 | ||
| 46 | 53 | // Service implements the ContainerService interface. | |
| @@ -138,12 +145,28 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f | |||
| 138 | 145 | } | |
| 139 | 146 | ||
| 140 | 147 | // buildContainerConfig constructs the container configuration for deployment. | |
| 141 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, hasExisting bool) *domain.ContainerConfig { | |
| 142 | - | // Determine container name (use temp suffix for zero-downtime if existing) | |
| 143 | - | containerName := fmt.Sprintf("gordon-%s", containerDomain) | |
| 144 | - | if hasExisting { | |
| 145 | - | containerName = fmt.Sprintf("gordon-%s-new", containerDomain) | |
| 148 | + | func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string { | |
| 149 | + | canonicalName := fmt.Sprintf("gordon-%s", containerDomain) | |
| 150 | + | if existing == nil { | |
| 151 | + | return canonicalName | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Alternate temporary names if the tracked container was left with a temp suffix. | |
| 155 | + | // This prevents name collisions after interrupted zero-downtime deploys. | |
| 156 | + | newName := canonicalName + "-new" | |
| 157 | + | nextName := canonicalName + "-next" | |
| 158 | + | switch existing.Name { | |
| 159 | + | case newName: | |
| 160 | + | return nextName | |
| 161 | + | case nextName: | |
| 162 | + | return newName | |
| 163 | + | default: | |
| 164 | + | return newName | |
| 146 | 165 | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |
| 169 | + | containerName := s.deploymentContainerName(containerDomain, existing) | |
| 147 | 170 | ||
| 148 | 171 | return &domain.ContainerConfig{ | |
| 149 | 172 | Image: actualImageRef, | |
| @@ -236,7 +259,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 236 | 259 | } | |
| 237 | 260 | ||
| 238 | 261 | // Build container configuration | |
| 239 | - | containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, hasExisting) | |
| 262 | + | containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing) | |
| 240 | 263 | ||
| 241 | 264 | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | |
| 242 | 265 | if err != nil { | |
| @@ -313,7 +336,33 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||
| 313 | 336 | ||
| 314 | 337 | // Restart the main container | |
| 315 | 338 | if err := s.runtime.RestartContainer(ctx, container.ID); err != nil { | |
| 316 | - | return log.WrapErr(err, "failed to restart container") | |
| 339 | + | if !isContainerNotFoundError(err) { | |
| 340 | + | return log.WrapErr(err, "failed to restart container") | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // In-memory container ID can become stale after external runtime changes. | |
| 344 | + | // Re-sync and retry once with the latest tracked container. | |
| 345 | + | log.Warn(). | |
| 346 | + | Err(err). | |
| 347 | + | Str(zerowrap.FieldEntityID, container.ID). | |
| 348 | + | Msg("tracked container missing during restart, attempting state reconciliation") | |
| 349 | + | ||
| 350 | + | if syncErr := s.SyncContainers(ctx); syncErr != nil { | |
| 351 | + | log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery") | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | s.mu.RLock() | |
| 355 | + | refreshed, refreshedExists := s.containers[domainName] | |
| 356 | + | attachmentIDs = append([]string{}, s.attachments[domainName]...) | |
| 357 | + | s.mu.RUnlock() | |
| 358 | + | ||
| 359 | + | if !refreshedExists || refreshed == nil { | |
| 360 | + | return domain.ErrContainerNotFound | |
| 361 | + | } | |
| 362 | + | if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil { | |
| 363 | + | return log.WrapErr(err, "failed to restart container after state reconciliation") | |
| 364 | + | } | |
| 365 | + | container = refreshed | |
| 317 | 366 | } | |
| 318 | 367 | log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted") | |
| 319 | 368 | ||
| @@ -1003,11 +1052,15 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 1003 | 1052 | if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" { | |
| 1004 | 1053 | return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | |
| 1005 | 1054 | } | |
| 1006 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword); err != nil { | |
| 1055 | + | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1056 | + | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | |
| 1057 | + | }, isInternal); err != nil { | |
| 1007 | 1058 | return log.WrapErr(err, "failed to pull image with internal auth") | |
| 1008 | 1059 | } | |
| 1009 | 1060 | case isInternal: | |
| 1010 | - | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 1061 | + | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1062 | + | return s.runtime.PullImage(ctx, pullRef) | |
| 1063 | + | }, isInternal); err != nil { | |
| 1011 | 1064 | return log.WrapErr(err, "failed to pull image") | |
| 1012 | 1065 | } | |
| 1013 | 1066 | case cfg.RegistryAuthEnabled: | |
| @@ -1026,6 +1079,34 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 1026 | 1079 | return nil | |
| 1027 | 1080 | } | |
| 1028 | 1081 | ||
| 1082 | + | func (s *Service) pullImageWithRetry(ctx context.Context, pullFn func(context.Context) error, isInternal bool) error { | |
| 1083 | + | log := zerowrap.FromCtx(ctx) | |
| 1084 | + | ||
| 1085 | + | for attempt := 1; attempt <= internalPullMaxAttempts; attempt++ { | |
| 1086 | + | err := pullFn(ctx) | |
| 1087 | + | if err == nil { | |
| 1088 | + | return nil | |
| 1089 | + | } | |
| 1090 | + | if !isInternal || !isConnectionRefusedError(err) || attempt == internalPullMaxAttempts { | |
| 1091 | + | return fmt.Errorf("internal image pull failed after %d attempts: %w", attempt, err) | |
| 1092 | + | } | |
| 1093 | + | ||
| 1094 | + | backoff := time.Duration(attempt) * time.Second | |
| 1095 | + | log.Warn(). | |
| 1096 | + | Err(err). | |
| 1097 | + | Int("attempt", attempt). | |
| 1098 | + | Dur("backoff", backoff). | |
| 1099 | + | Msg("internal image pull failed with connection refused, retrying") | |
| 1100 | + | ||
| 1101 | + | select { | |
| 1102 | + | case <-time.After(backoff): | |
| 1103 | + | case <-ctx.Done(): | |
| 1104 | + | return ctx.Err() | |
| 1105 | + | } | |
| 1106 | + | } | |
| 1107 | + | return nil | |
| 1108 | + | } | |
| 1109 | + | ||
| 1029 | 1110 | func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error { | |
| 1030 | 1111 | if sourceRef == targetRef { | |
| 1031 | 1112 | return nil | |
| @@ -1155,6 +1236,8 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||
| 1155 | 1236 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | |
| 1156 | 1237 | log := zerowrap.FromCtx(ctx) | |
| 1157 | 1238 | expectedName := fmt.Sprintf("gordon-%s", domainName) | |
| 1239 | + | expectedNewName := expectedName + "-new" | |
| 1240 | + | expectedNextName := expectedName + "-next" | |
| 1158 | 1241 | ||
| 1159 | 1242 | allContainers, err := s.runtime.ListContainers(ctx, true) | |
| 1160 | 1243 | if err != nil { | |
| @@ -1162,7 +1245,7 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||
| 1162 | 1245 | } | |
| 1163 | 1246 | ||
| 1164 | 1247 | for _, c := range allContainers { | |
| 1165 | - | if c.Name == expectedName && c.ID != skipContainerID { | |
| 1248 | + | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { | |
| 1166 | 1249 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | |
| 1167 | 1250 | ||
| 1168 | 1251 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |
| @@ -1644,6 +1727,26 @@ func sanitizeNameLegacy(d string) string { | |||
| 1644 | 1727 | return domain.SanitizeDomainForContainerLegacy(d) | |
| 1645 | 1728 | } | |
| 1646 | 1729 | ||
| 1730 | + | func isContainerNotFoundError(err error) bool { | |
| 1731 | + | if err == nil { | |
| 1732 | + | return false | |
| 1733 | + | } | |
| 1734 | + | if errors.Is(err, domain.ErrContainerNotFound) { | |
| 1735 | + | return true | |
| 1736 | + | } | |
| 1737 | + | msg := strings.ToLower(err.Error()) | |
| 1738 | + | return strings.Contains(msg, "no such container") || | |
| 1739 | + | strings.Contains(msg, "no container with name or id") || | |
| 1740 | + | strings.Contains(msg, "container not found") | |
| 1741 | + | } | |
| 1742 | + | ||
| 1743 | + | func isConnectionRefusedError(err error) bool { | |
| 1744 | + | if err == nil { | |
| 1745 | + | return false | |
| 1746 | + | } | |
| 1747 | + | return strings.Contains(strings.ToLower(err.Error()), "connection refused") | |
| 1748 | + | } | |
| 1749 | + | ||
| 1647 | 1750 | // findContainerByName finds a container by its name. | |
| 1648 | 1751 | func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container { | |
| 1649 | 1752 | containers, err := s.runtime.ListContainers(ctx, true) | |
| @@ -1664,30 +1767,14 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||
| 1664 | 1767 | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |
| 1665 | 1768 | log := zerowrap.FromCtx(ctx) | |
| 1666 | 1769 | ||
| 1667 | - | // Poll for container to be running (max 30 seconds) | |
| 1668 | - | for i := 0; i < 30; i++ { | |
| 1669 | - | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |
| 1670 | - | if err != nil { | |
| 1671 | - | return err | |
| 1672 | - | } | |
| 1673 | - | if running { | |
| 1674 | - | break | |
| 1675 | - | } | |
| 1676 | - | if i == 29 { | |
| 1677 | - | return fmt.Errorf("container did not start within 30 seconds") | |
| 1678 | - | } | |
| 1679 | - | select { | |
| 1680 | - | case <-time.After(time.Second): | |
| 1681 | - | case <-ctx.Done(): | |
| 1682 | - | return ctx.Err() | |
| 1683 | - | } | |
| 1770 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { | |
| 1771 | + | return err | |
| 1684 | 1772 | } | |
| 1685 | 1773 | ||
| 1686 | 1774 | s.mu.RLock() | |
| 1687 | 1775 | cfg := s.config | |
| 1688 | 1776 | s.mu.RUnlock() | |
| 1689 | 1777 | ||
| 1690 | - | // Additional readiness delay (configurable, default 5 seconds) | |
| 1691 | 1778 | delay := cfg.ReadinessDelay | |
| 1692 | 1779 | if delay == 0 { | |
| 1693 | 1780 | delay = 5 * time.Second | |
| @@ -1705,11 +1792,61 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||
| 1705 | 1792 | if err != nil { | |
| 1706 | 1793 | return err | |
| 1707 | 1794 | } | |
| 1708 | - | if !running { | |
| 1709 | - | return fmt.Errorf("container stopped during readiness delay") | |
| 1795 | + | if running { | |
| 1796 | + | return nil | |
| 1710 | 1797 | } | |
| 1711 | 1798 | ||
| 1712 | - | return nil | |
| 1799 | + | return s.waitForRecovery(ctx, containerID) | |
| 1800 | + | } | |
| 1801 | + | ||
| 1802 | + | // pollContainerRunning polls until the container is running (max 30 seconds). | |
| 1803 | + | func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error { | |
| 1804 | + | for i := 0; i < 30; i++ { | |
| 1805 | + | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |
| 1806 | + | if err != nil { | |
| 1807 | + | return err | |
| 1808 | + | } | |
| 1809 | + | if running { | |
| 1810 | + | return nil | |
| 1811 | + | } | |
| 1812 | + | if i == 29 { | |
| 1813 | + | return fmt.Errorf("container did not start within 30 seconds") | |
| 1814 | + | } | |
| 1815 | + | select { | |
| 1816 | + | case <-time.After(time.Second): | |
| 1817 | + | case <-ctx.Done(): | |
| 1818 | + | return ctx.Err() | |
| 1819 | + | } | |
| 1820 | + | } | |
| 1821 | + | return fmt.Errorf("container did not start within 30 seconds") | |
| 1822 | + | } | |
| 1823 | + | ||
| 1824 | + | // waitForRecovery waits for a container to recover within the recovery window. | |
| 1825 | + | func (s *Service) waitForRecovery(ctx context.Context, containerID string) error { | |
| 1826 | + | log := zerowrap.FromCtx(ctx) | |
| 1827 | + | log.Warn(). | |
| 1828 | + | Dur("recovery_window", readinessRecoveryWindow). | |
| 1829 | + | Msg("container not running after readiness delay, waiting for recovery") | |
| 1830 | + | ||
| 1831 | + | deadline := time.Now().Add(readinessRecoveryWindow) | |
| 1832 | + | for time.Now().Before(deadline) { | |
| 1833 | + | select { | |
| 1834 | + | case <-time.After(time.Second): | |
| 1835 | + | case <-ctx.Done(): | |
| 1836 | + | return ctx.Err() | |
| 1837 | + | } | |
| 1838 | + | ||
| 1839 | + | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |
| 1840 | + | if err != nil { | |
| 1841 | + | return err | |
| 1842 | + | } | |
| 1843 | + | if running { | |
| 1844 | + | log.Info().Msg("container recovered during readiness recovery window") | |
| 1845 | + | return nil | |
| 1846 | + | } | |
| 1847 | + | } | |
| 1848 | + | ||
| 1849 | + | return fmt.Errorf("container not running after readiness delay and recovery window") | |
| 1713 | 1850 | } | |
| 1714 | 1851 | ||
| 1715 | 1852 | // publishContainerDeployed publishes a container.deployed event. | |
| @@ -3,6 +3,7 @@ package container | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "errors" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "maps" | 8 | "maps" | ||
| 8 | "slices" | 9 | "slices" | ||
| @@ -41,6 +42,12 @@ const ( | |||||
| 41 | PullPolicyAlways = "always" | 42 | PullPolicyAlways = "always" | ||
| 42 | PullPolicyIfNotPresent = "if-not-present" | 43 | PullPolicyIfNotPresent = "if-not-present" | ||
| 43 | PullPolicyIfTagChanged = "if-tag-changed" | 44 | PullPolicyIfTagChanged = "if-tag-changed" | ||
| 45 | + | ||||
| 46 | + | // readinessRecoveryWindow is an additional grace window used when a container | |||
| 47 | + | // is briefly not running at the end of readiness delay. This avoids false | |||
| 48 | + | // negatives during short startup flaps. | |||
| 49 | + | readinessRecoveryWindow = 30 * time.Second | |||
| 50 | + | internalPullMaxAttempts = 3 | |||
| 44 | ) | 51 | ) | ||
| 45 | 52 | ||||
| 46 | // Service implements the ContainerService interface. | 53 | // Service implements the ContainerService interface. | ||
| @@ -138,12 +145,28 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f | |||||
| 138 | } | 145 | } | ||
| 139 | 146 | ||||
| 140 | // buildContainerConfig constructs the container configuration for deployment. | 147 | // buildContainerConfig constructs the container configuration for deployment. | ||
| 141 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, hasExisting bool) *domain.ContainerConfig { | 148 | + | func (s *Service) deploymentContainerName(containerDomain string, existing *domain.Container) string { |
| 142 | - | // Determine container name (use temp suffix for zero-downtime if existing) | 149 | + | canonicalName := fmt.Sprintf("gordon-%s", containerDomain) |
| 143 | - | containerName := fmt.Sprintf("gordon-%s", containerDomain) | 150 | + | if existing == nil { |
| 144 | - | if hasExisting { | 151 | + | return canonicalName |
| 145 | - | containerName = fmt.Sprintf("gordon-%s-new", containerDomain) | 152 | + | } |
| 153 | + | ||||
| 154 | + | // Alternate temporary names if the tracked container was left with a temp suffix. | |||
| 155 | + | // This prevents name collisions after interrupted zero-downtime deploys. | |||
| 156 | + | newName := canonicalName + "-new" | |||
| 157 | + | nextName := canonicalName + "-next" | |||
| 158 | + | switch existing.Name { | |||
| 159 | + | case newName: | |||
| 160 | + | return nextName | |||
| 161 | + | case nextName: | |||
| 162 | + | return newName | |||
| 163 | + | default: | |||
| 164 | + | return newName | |||
| 146 | } | 165 | } | ||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |||
| 169 | + | containerName := s.deploymentContainerName(containerDomain, existing) | |||
| 147 | 170 | ||||
| 148 | return &domain.ContainerConfig{ | 171 | return &domain.ContainerConfig{ | ||
| 149 | Image: actualImageRef, | 172 | Image: actualImageRef, | ||
| @@ -236,7 +259,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 236 | } | 259 | } | ||
| 237 | 260 | ||||
| 238 | // Build container configuration | 261 | // Build container configuration | ||
| 239 | - | containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, hasExisting) | 262 | + | containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing) |
| 240 | 263 | ||||
| 241 | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | 264 | newContainer, err := s.runtime.CreateContainer(ctx, containerConfig) | ||
| 242 | if err != nil { | 265 | if err != nil { | ||
| @@ -313,7 +336,33 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||||
| 313 | 336 | ||||
| 314 | // Restart the main container | 337 | // Restart the main container | ||
| 315 | if err := s.runtime.RestartContainer(ctx, container.ID); err != nil { | 338 | if err := s.runtime.RestartContainer(ctx, container.ID); err != nil { | ||
| 316 | - | return log.WrapErr(err, "failed to restart container") | 339 | + | if !isContainerNotFoundError(err) { |
| 340 | + | return log.WrapErr(err, "failed to restart container") | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // In-memory container ID can become stale after external runtime changes. | |||
| 344 | + | // Re-sync and retry once with the latest tracked container. | |||
| 345 | + | log.Warn(). | |||
| 346 | + | Err(err). | |||
| 347 | + | Str(zerowrap.FieldEntityID, container.ID). | |||
| 348 | + | Msg("tracked container missing during restart, attempting state reconciliation") | |||
| 349 | + | ||||
| 350 | + | if syncErr := s.SyncContainers(ctx); syncErr != nil { | |||
| 351 | + | log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery") | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | s.mu.RLock() | |||
| 355 | + | refreshed, refreshedExists := s.containers[domainName] | |||
| 356 | + | attachmentIDs = append([]string{}, s.attachments[domainName]...) | |||
| 357 | + | s.mu.RUnlock() | |||
| 358 | + | ||||
| 359 | + | if !refreshedExists || refreshed == nil { | |||
| 360 | + | return domain.ErrContainerNotFound | |||
| 361 | + | } | |||
| 362 | + | if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil { | |||
| 363 | + | return log.WrapErr(err, "failed to restart container after state reconciliation") | |||
| 364 | + | } | |||
| 365 | + | container = refreshed | |||
| 317 | } | 366 | } | ||
| 318 | log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted") | 367 | log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted") | ||
| 319 | 368 | ||||
| @@ -1003,11 +1052,15 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||||
| 1003 | if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" { | 1052 | if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" { | ||
| 1004 | return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | 1053 | return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | ||
| 1005 | } | 1054 | } | ||
| 1006 | - | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword); err != nil { | 1055 | + | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { |
| 1056 | + | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | |||
| 1057 | + | }, isInternal); err != nil { | |||
| 1007 | return log.WrapErr(err, "failed to pull image with internal auth") | 1058 | return log.WrapErr(err, "failed to pull image with internal auth") | ||
| 1008 | } | 1059 | } | ||
| 1009 | case isInternal: | 1060 | case isInternal: | ||
| 1010 | - | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | 1061 | + | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { |
| 1062 | + | return s.runtime.PullImage(ctx, pullRef) | |||
| 1063 | + | }, isInternal); err != nil { | |||
| 1011 | return log.WrapErr(err, "failed to pull image") | 1064 | return log.WrapErr(err, "failed to pull image") | ||
| 1012 | } | 1065 | } | ||
| 1013 | case cfg.RegistryAuthEnabled: | 1066 | case cfg.RegistryAuthEnabled: | ||
| @@ -1026,6 +1079,34 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||||
| 1026 | return nil | 1079 | return nil | ||
| 1027 | } | 1080 | } | ||
| 1028 | 1081 | ||||
| 1082 | + | func (s *Service) pullImageWithRetry(ctx context.Context, pullFn func(context.Context) error, isInternal bool) error { | |||
| 1083 | + | log := zerowrap.FromCtx(ctx) | |||
| 1084 | + | ||||
| 1085 | + | for attempt := 1; attempt <= internalPullMaxAttempts; attempt++ { | |||
| 1086 | + | err := pullFn(ctx) | |||
| 1087 | + | if err == nil { | |||
| 1088 | + | return nil | |||
| 1089 | + | } | |||
| 1090 | + | if !isInternal || !isConnectionRefusedError(err) || attempt == internalPullMaxAttempts { | |||
| 1091 | + | return fmt.Errorf("internal image pull failed after %d attempts: %w", attempt, err) | |||
| 1092 | + | } | |||
| 1093 | + | ||||
| 1094 | + | backoff := time.Duration(attempt) * time.Second | |||
| 1095 | + | log.Warn(). | |||
| 1096 | + | Err(err). | |||
| 1097 | + | Int("attempt", attempt). | |||
| 1098 | + | Dur("backoff", backoff). | |||
| 1099 | + | Msg("internal image pull failed with connection refused, retrying") | |||
| 1100 | + | ||||
| 1101 | + | select { | |||
| 1102 | + | case <-time.After(backoff): | |||
| 1103 | + | case <-ctx.Done(): | |||
| 1104 | + | return ctx.Err() | |||
| 1105 | + | } | |||
| 1106 | + | } | |||
| 1107 | + | return nil | |||
| 1108 | + | } | |||
| 1109 | + | ||||
| 1029 | func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error { | 1110 | func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error { | ||
| 1030 | if sourceRef == targetRef { | 1111 | if sourceRef == targetRef { | ||
| 1031 | return nil | 1112 | return nil | ||
| @@ -1155,6 +1236,8 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) | |||||
| 1155 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | 1236 | func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error { | ||
| 1156 | log := zerowrap.FromCtx(ctx) | 1237 | log := zerowrap.FromCtx(ctx) | ||
| 1157 | expectedName := fmt.Sprintf("gordon-%s", domainName) | 1238 | expectedName := fmt.Sprintf("gordon-%s", domainName) | ||
| 1239 | + | expectedNewName := expectedName + "-new" | |||
| 1240 | + | expectedNextName := expectedName + "-next" | |||
| 1158 | 1241 | ||||
| 1159 | allContainers, err := s.runtime.ListContainers(ctx, true) | 1242 | allContainers, err := s.runtime.ListContainers(ctx, true) | ||
| 1160 | if err != nil { | 1243 | if err != nil { | ||
| @@ -1162,7 +1245,7 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri | |||||
| 1162 | } | 1245 | } | ||
| 1163 | 1246 | ||||
| 1164 | for _, c := range allContainers { | 1247 | for _, c := range allContainers { | ||
| 1165 | - | if c.Name == expectedName && c.ID != skipContainerID { | 1248 | + | if (c.Name == expectedName || c.Name == expectedNewName || c.Name == expectedNextName) && c.ID != skipContainerID { |
| 1166 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | 1249 | log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing") | ||
| 1167 | 1250 | ||||
| 1168 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | 1251 | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | ||
| @@ -1644,6 +1727,26 @@ func sanitizeNameLegacy(d string) string { | |||||
| 1644 | return domain.SanitizeDomainForContainerLegacy(d) | 1727 | return domain.SanitizeDomainForContainerLegacy(d) | ||
| 1645 | } | 1728 | } | ||
| 1646 | 1729 | ||||
| 1730 | + | func isContainerNotFoundError(err error) bool { | |||
| 1731 | + | if err == nil { | |||
| 1732 | + | return false | |||
| 1733 | + | } | |||
| 1734 | + | if errors.Is(err, domain.ErrContainerNotFound) { | |||
| 1735 | + | return true | |||
| 1736 | + | } | |||
| 1737 | + | msg := strings.ToLower(err.Error()) | |||
| 1738 | + | return strings.Contains(msg, "no such container") || | |||
| 1739 | + | strings.Contains(msg, "no container with name or id") || | |||
| 1740 | + | strings.Contains(msg, "container not found") | |||
| 1741 | + | } | |||
| 1742 | + | ||||
| 1743 | + | func isConnectionRefusedError(err error) bool { | |||
| 1744 | + | if err == nil { | |||
| 1745 | + | return false | |||
| 1746 | + | } | |||
| 1747 | + | return strings.Contains(strings.ToLower(err.Error()), "connection refused") | |||
| 1748 | + | } | |||
| 1749 | + | ||||
| 1647 | // findContainerByName finds a container by its name. | 1750 | // findContainerByName finds a container by its name. | ||
| 1648 | func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container { | 1751 | func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container { | ||
| 1649 | containers, err := s.runtime.ListContainers(ctx, true) | 1752 | containers, err := s.runtime.ListContainers(ctx, true) | ||
| @@ -1664,30 +1767,14 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain. | |||||
| 1664 | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | 1767 | func (s *Service) waitForReady(ctx context.Context, containerID string) error { | ||
| 1665 | log := zerowrap.FromCtx(ctx) | 1768 | log := zerowrap.FromCtx(ctx) | ||
| 1666 | 1769 | ||||
| 1667 | - | // Poll for container to be running (max 30 seconds) | 1770 | + | if err := s.pollContainerRunning(ctx, containerID); err != nil { |
| 1668 | - | for i := 0; i < 30; i++ { | 1771 | + | return err |
| 1669 | - | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |||
| 1670 | - | if err != nil { | |||
| 1671 | - | return err | |||
| 1672 | - | } | |||
| 1673 | - | if running { | |||
| 1674 | - | break | |||
| 1675 | - | } | |||
| 1676 | - | if i == 29 { | |||
| 1677 | - | return fmt.Errorf("container did not start within 30 seconds") | |||
| 1678 | - | } | |||
| 1679 | - | select { | |||
| 1680 | - | case <-time.After(time.Second): | |||
| 1681 | - | case <-ctx.Done(): | |||
| 1682 | - | return ctx.Err() | |||
| 1683 | - | } | |||
| 1684 | } | 1772 | } | ||
| 1685 | 1773 | ||||
| 1686 | s.mu.RLock() | 1774 | s.mu.RLock() | ||
| 1687 | cfg := s.config | 1775 | cfg := s.config | ||
| 1688 | s.mu.RUnlock() | 1776 | s.mu.RUnlock() | ||
| 1689 | 1777 | ||||
| 1690 | - | // Additional readiness delay (configurable, default 5 seconds) | |||
| 1691 | delay := cfg.ReadinessDelay | 1778 | delay := cfg.ReadinessDelay | ||
| 1692 | if delay == 0 { | 1779 | if delay == 0 { | ||
| 1693 | delay = 5 * time.Second | 1780 | delay = 5 * time.Second | ||
| @@ -1705,11 +1792,61 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error { | |||||
| 1705 | if err != nil { | 1792 | if err != nil { | ||
| 1706 | return err | 1793 | return err | ||
| 1707 | } | 1794 | } | ||
| 1708 | - | if !running { | 1795 | + | if running { |
| 1709 | - | return fmt.Errorf("container stopped during readiness delay") | 1796 | + | return nil |
| 1710 | } | 1797 | } | ||
| 1711 | 1798 | ||||
| 1712 | - | return nil | 1799 | + | return s.waitForRecovery(ctx, containerID) |
| 1800 | + | } | |||
| 1801 | + | ||||
| 1802 | + | // pollContainerRunning polls until the container is running (max 30 seconds). | |||
| 1803 | + | func (s *Service) pollContainerRunning(ctx context.Context, containerID string) error { | |||
| 1804 | + | for i := 0; i < 30; i++ { | |||
| 1805 | + | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |||
| 1806 | + | if err != nil { | |||
| 1807 | + | return err | |||
| 1808 | + | } | |||
| 1809 | + | if running { | |||
| 1810 | + | return nil | |||
| 1811 | + | } | |||
| 1812 | + | if i == 29 { | |||
| 1813 | + | return fmt.Errorf("container did not start within 30 seconds") | |||
| 1814 | + | } | |||
| 1815 | + | select { | |||
| 1816 | + | case <-time.After(time.Second): | |||
| 1817 | + | case <-ctx.Done(): | |||
| 1818 | + | return ctx.Err() | |||
| 1819 | + | } | |||
| 1820 | + | } | |||
| 1821 | + | return fmt.Errorf("container did not start within 30 seconds") | |||
| 1822 | + | } | |||
| 1823 | + | ||||
| 1824 | + | // waitForRecovery waits for a container to recover within the recovery window. | |||
| 1825 | + | func (s *Service) waitForRecovery(ctx context.Context, containerID string) error { | |||
| 1826 | + | log := zerowrap.FromCtx(ctx) | |||
| 1827 | + | log.Warn(). | |||
| 1828 | + | Dur("recovery_window", readinessRecoveryWindow). | |||
| 1829 | + | Msg("container not running after readiness delay, waiting for recovery") | |||
| 1830 | + | ||||
| 1831 | + | deadline := time.Now().Add(readinessRecoveryWindow) | |||
| 1832 | + | for time.Now().Before(deadline) { | |||
| 1833 | + | select { | |||
| 1834 | + | case <-time.After(time.Second): | |||
| 1835 | + | case <-ctx.Done(): | |||
| 1836 | + | return ctx.Err() | |||
| 1837 | + | } | |||
| 1838 | + | ||||
| 1839 | + | running, err := s.runtime.IsContainerRunning(ctx, containerID) | |||
| 1840 | + | if err != nil { | |||
| 1841 | + | return err | |||
| 1842 | + | } | |||
| 1843 | + | if running { | |||
| 1844 | + | log.Info().Msg("container recovered during readiness recovery window") | |||
| 1845 | + | return nil | |||
| 1846 | + | } | |||
| 1847 | + | } | |||
| 1848 | + | ||||
| 1849 | + | return fmt.Errorf("container not running after readiness delay and recovery window") | |||
| 1713 | } | 1850 | } | ||
| 1714 | 1851 | ||||
| 1715 | // publishContainerDeployed publishes a container.deployed event. | 1852 | // publishContainerDeployed publishes a container.deployed event. | ||
M internal/usecase/container/service_test.go +267 -0
| @@ -96,6 +96,65 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 96 | 96 | assert.Equal(t, "container-123", tracked.ID) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) { | |
| 100 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 101 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 102 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 103 | + | ||
| 104 | + | config := Config{ | |
| 105 | + | NetworkIsolation: false, | |
| 106 | + | VolumeAutoCreate: false, | |
| 107 | + | ReadinessDelay: time.Millisecond, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 111 | + | ctx := testContext() | |
| 112 | + | ||
| 113 | + | route := domain.Route{ | |
| 114 | + | Domain: "test.example.com", | |
| 115 | + | Image: "myapp:latest", | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 119 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 120 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil) | |
| 121 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 122 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 123 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 124 | + | ||
| 125 | + | createdContainer := &domain.Container{ | |
| 126 | + | ID: "container-123", | |
| 127 | + | Name: "gordon-test.example.com", | |
| 128 | + | Image: "myapp:latest", | |
| 129 | + | Status: "created", | |
| 130 | + | } | |
| 131 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |
| 132 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | |
| 133 | + | ||
| 134 | + | // Readiness check sequence: | |
| 135 | + | // 1) Initial "started" check => running | |
| 136 | + | // 2) Post-delay verification => transient not-running | |
| 137 | + | // 3) Recovery-window poll => running again | |
| 138 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once() | |
| 139 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once() | |
| 140 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once() | |
| 141 | + | ||
| 142 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{ | |
| 143 | + | ID: "container-123", | |
| 144 | + | Name: "gordon-test.example.com", | |
| 145 | + | Image: "myapp:latest", | |
| 146 | + | Status: "running", | |
| 147 | + | Ports: []int{8080}, | |
| 148 | + | }, nil) | |
| 149 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 150 | + | ||
| 151 | + | result, err := svc.Deploy(ctx, route) | |
| 152 | + | ||
| 153 | + | assert.NoError(t, err) | |
| 154 | + | assert.NotNil(t, result) | |
| 155 | + | assert.Equal(t, "container-123", result.ID) | |
| 156 | + | } | |
| 157 | + | ||
| 99 | 158 | func TestService_Deploy_ImagePullFailure(t *testing.T) { | |
| 100 | 159 | runtime := mocks.NewMockContainerRuntime(t) | |
| 101 | 160 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -156,6 +215,30 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |||
| 156 | 215 | assert.Contains(t, err.Error(), "registry service token not configured") | |
| 157 | 216 | } | |
| 158 | 217 | ||
| 218 | + | func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | |
| 219 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 220 | + | ||
| 221 | + | config := Config{ | |
| 222 | + | RegistryAuthEnabled: true, | |
| 223 | + | InternalRegistryUsername: "internal", | |
| 224 | + | InternalRegistryPassword: "secret", | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | svc := NewService(runtime, nil, nil, nil, config) | |
| 228 | + | ctx := testContext() | |
| 229 | + | ||
| 230 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret"). | |
| 231 | + | Return(errors.New("connection refused")). | |
| 232 | + | Once() | |
| 233 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret"). | |
| 234 | + | Return(nil). | |
| 235 | + | Once() | |
| 236 | + | ||
| 237 | + | err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true) | |
| 238 | + | ||
| 239 | + | assert.NoError(t, err) | |
| 240 | + | } | |
| 241 | + | ||
| 159 | 242 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |
| 160 | 243 | runtime := mocks.NewMockContainerRuntime(t) | |
| 161 | 244 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -625,6 +708,65 @@ func TestService_Restart_Error(t *testing.T) { | |||
| 625 | 708 | assert.Contains(t, err.Error(), "failed to restart container") | |
| 626 | 709 | } | |
| 627 | 710 | ||
| 711 | + | func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) { | |
| 712 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 713 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 714 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 715 | + | ||
| 716 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 717 | + | ctx := testContext() | |
| 718 | + | ||
| 719 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 720 | + | ID: "stale-container-id", | |
| 721 | + | Name: "gordon-test.example.com", | |
| 722 | + | Status: "running", | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |
| 726 | + | Return(errors.New("no container with name or ID \"stale-container-id\" found")). | |
| 727 | + | Once() | |
| 728 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 729 | + | { | |
| 730 | + | ID: "fresh-container-id", | |
| 731 | + | Name: "gordon-test.example.com", | |
| 732 | + | Labels: map[string]string{ | |
| 733 | + | "gordon.domain": "test.example.com", | |
| 734 | + | "gordon.managed": "true", | |
| 735 | + | }, | |
| 736 | + | }, | |
| 737 | + | }, nil).Once() | |
| 738 | + | runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once() | |
| 739 | + | ||
| 740 | + | err := svc.Restart(ctx, "test.example.com", false) | |
| 741 | + | ||
| 742 | + | assert.NoError(t, err) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) { | |
| 746 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 747 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 748 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 749 | + | ||
| 750 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |
| 751 | + | ctx := testContext() | |
| 752 | + | ||
| 753 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 754 | + | ID: "stale-container-id", | |
| 755 | + | Name: "gordon-test.example.com", | |
| 756 | + | Status: "running", | |
| 757 | + | } | |
| 758 | + | ||
| 759 | + | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |
| 760 | + | Return(errors.New("no such container")). | |
| 761 | + | Once() | |
| 762 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |
| 763 | + | ||
| 764 | + | err := svc.Restart(ctx, "test.example.com", false) | |
| 765 | + | ||
| 766 | + | assert.Error(t, err) | |
| 767 | + | assert.True(t, errors.Is(err, domain.ErrContainerNotFound)) | |
| 768 | + | } | |
| 769 | + | ||
| 628 | 770 | func TestService_Restart_WithAttachments(t *testing.T) { | |
| 629 | 771 | runtime := mocks.NewMockContainerRuntime(t) | |
| 630 | 772 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -1339,6 +1481,131 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 1339 | 1481 | assert.Equal(t, "new-container", tracked.ID) | |
| 1340 | 1482 | } | |
| 1341 | 1483 | ||
| 1484 | + | func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |
| 1485 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1486 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 1487 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1488 | + | ||
| 1489 | + | config := Config{ | |
| 1490 | + | ReadinessDelay: time.Millisecond, | |
| 1491 | + | } | |
| 1492 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1493 | + | ctx := testContext() | |
| 1494 | + | ||
| 1495 | + | // Tracked canonical container exists, but stale -new container should be removed. | |
| 1496 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 1497 | + | ID: "tracked-container-123", | |
| 1498 | + | Name: "gordon-test.example.com", | |
| 1499 | + | Status: "running", | |
| 1500 | + | } | |
| 1501 | + | ||
| 1502 | + | route := domain.Route{ | |
| 1503 | + | Domain: "test.example.com", | |
| 1504 | + | Image: "myapp:v2", | |
| 1505 | + | } | |
| 1506 | + | ||
| 1507 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 1508 | + | { | |
| 1509 | + | ID: "tracked-container-123", | |
| 1510 | + | Name: "gordon-test.example.com", | |
| 1511 | + | Status: "running", | |
| 1512 | + | }, | |
| 1513 | + | { | |
| 1514 | + | ID: "stale-new-container", | |
| 1515 | + | Name: "gordon-test.example.com-new", | |
| 1516 | + | Status: "exited", | |
| 1517 | + | }, | |
| 1518 | + | }, nil) | |
| 1519 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once() | |
| 1520 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once() | |
| 1521 | + | ||
| 1522 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 1523 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 1524 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 1525 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 1526 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 1527 | + | ||
| 1528 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 1529 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 1530 | + | return cfg.Name == "gordon-test.example.com-new" | |
| 1531 | + | })).Return(newContainer, nil) | |
| 1532 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1533 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1534 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 1535 | + | ID: "new-container", | |
| 1536 | + | Status: "running", | |
| 1537 | + | }, nil) | |
| 1538 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1539 | + | ||
| 1540 | + | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | |
| 1541 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | |
| 1542 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 1543 | + | ||
| 1544 | + | result, err := svc.Deploy(ctx, route) | |
| 1545 | + | assert.NoError(t, err) | |
| 1546 | + | assert.NotNil(t, result) | |
| 1547 | + | assert.Equal(t, "new-container", result.ID) | |
| 1548 | + | } | |
| 1549 | + | ||
| 1550 | + | func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) { | |
| 1551 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 1552 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 1553 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1554 | + | ||
| 1555 | + | config := Config{ | |
| 1556 | + | ReadinessDelay: time.Millisecond, | |
| 1557 | + | } | |
| 1558 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 1559 | + | ctx := testContext() | |
| 1560 | + | ||
| 1561 | + | // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name. | |
| 1562 | + | svc.containers["test.example.com"] = &domain.Container{ | |
| 1563 | + | ID: "tracked-temp-container", | |
| 1564 | + | Name: "gordon-test.example.com-new", | |
| 1565 | + | Status: "running", | |
| 1566 | + | } | |
| 1567 | + | ||
| 1568 | + | route := domain.Route{ | |
| 1569 | + | Domain: "test.example.com", | |
| 1570 | + | Image: "myapp:v2", | |
| 1571 | + | } | |
| 1572 | + | ||
| 1573 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 1574 | + | { | |
| 1575 | + | ID: "tracked-temp-container", | |
| 1576 | + | Name: "gordon-test.example.com-new", | |
| 1577 | + | Status: "running", | |
| 1578 | + | }, | |
| 1579 | + | }, nil) | |
| 1580 | + | ||
| 1581 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |
| 1582 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |
| 1583 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |
| 1584 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 1585 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |
| 1586 | + | ||
| 1587 | + | created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"} | |
| 1588 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 1589 | + | return cfg.Name == "gordon-test.example.com-next" | |
| 1590 | + | })).Return(created, nil) | |
| 1591 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 1592 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |
| 1593 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 1594 | + | ID: "new-container", | |
| 1595 | + | Status: "running", | |
| 1596 | + | }, nil) | |
| 1597 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 1598 | + | ||
| 1599 | + | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | |
| 1600 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | |
| 1601 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 1602 | + | ||
| 1603 | + | result, err := svc.Deploy(ctx, route) | |
| 1604 | + | assert.NoError(t, err) | |
| 1605 | + | assert.NotNil(t, result) | |
| 1606 | + | assert.Equal(t, "new-container", result.ID) | |
| 1607 | + | } | |
| 1608 | + | ||
| 1342 | 1609 | func TestService_StripRegistryPrefix(t *testing.T) { | |
| 1343 | 1610 | tests := []struct { | |
| 1344 | 1611 | name string | |
| @@ -96,6 +96,65 @@ func TestService_Deploy_Success(t *testing.T) { | |||||
| 96 | assert.Equal(t, "container-123", tracked.ID) | 96 | assert.Equal(t, "container-123", tracked.ID) | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) { | |||
| 100 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 101 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 102 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 103 | + | ||||
| 104 | + | config := Config{ | |||
| 105 | + | NetworkIsolation: false, | |||
| 106 | + | VolumeAutoCreate: false, | |||
| 107 | + | ReadinessDelay: time.Millisecond, | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 111 | + | ctx := testContext() | |||
| 112 | + | ||||
| 113 | + | route := domain.Route{ | |||
| 114 | + | Domain: "test.example.com", | |||
| 115 | + | Image: "myapp:latest", | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 119 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 120 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:latest").Return(nil) | |||
| 121 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 122 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 123 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 124 | + | ||||
| 125 | + | createdContainer := &domain.Container{ | |||
| 126 | + | ID: "container-123", | |||
| 127 | + | Name: "gordon-test.example.com", | |||
| 128 | + | Image: "myapp:latest", | |||
| 129 | + | Status: "created", | |||
| 130 | + | } | |||
| 131 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(createdContainer, nil) | |||
| 132 | + | runtime.EXPECT().StartContainer(mock.Anything, "container-123").Return(nil) | |||
| 133 | + | ||||
| 134 | + | // Readiness check sequence: | |||
| 135 | + | // 1) Initial "started" check => running | |||
| 136 | + | // 2) Post-delay verification => transient not-running | |||
| 137 | + | // 3) Recovery-window poll => running again | |||
| 138 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once() | |||
| 139 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(false, nil).Once() | |||
| 140 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Once() | |||
| 141 | + | ||||
| 142 | + | runtime.EXPECT().InspectContainer(mock.Anything, "container-123").Return(&domain.Container{ | |||
| 143 | + | ID: "container-123", | |||
| 144 | + | Name: "gordon-test.example.com", | |||
| 145 | + | Image: "myapp:latest", | |||
| 146 | + | Status: "running", | |||
| 147 | + | Ports: []int{8080}, | |||
| 148 | + | }, nil) | |||
| 149 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 150 | + | ||||
| 151 | + | result, err := svc.Deploy(ctx, route) | |||
| 152 | + | ||||
| 153 | + | assert.NoError(t, err) | |||
| 154 | + | assert.NotNil(t, result) | |||
| 155 | + | assert.Equal(t, "container-123", result.ID) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 99 | func TestService_Deploy_ImagePullFailure(t *testing.T) { | 158 | func TestService_Deploy_ImagePullFailure(t *testing.T) { | ||
| 100 | runtime := mocks.NewMockContainerRuntime(t) | 159 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 101 | envLoader := mocks.NewMockEnvLoader(t) | 160 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -156,6 +215,30 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) { | |||||
| 156 | assert.Contains(t, err.Error(), "registry service token not configured") | 215 | assert.Contains(t, err.Error(), "registry service token not configured") | ||
| 157 | } | 216 | } | ||
| 158 | 217 | ||||
| 218 | + | func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | |||
| 219 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 220 | + | ||||
| 221 | + | config := Config{ | |||
| 222 | + | RegistryAuthEnabled: true, | |||
| 223 | + | InternalRegistryUsername: "internal", | |||
| 224 | + | InternalRegistryPassword: "secret", | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | svc := NewService(runtime, nil, nil, nil, config) | |||
| 228 | + | ctx := testContext() | |||
| 229 | + | ||||
| 230 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret"). | |||
| 231 | + | Return(errors.New("connection refused")). | |||
| 232 | + | Once() | |||
| 233 | + | runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret"). | |||
| 234 | + | Return(nil). | |||
| 235 | + | Once() | |||
| 236 | + | ||||
| 237 | + | err := svc.pullImage(ctx, "localhost:5000/myapp:latest", true) | |||
| 238 | + | ||||
| 239 | + | assert.NoError(t, err) | |||
| 240 | + | } | |||
| 241 | + | ||||
| 159 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | 242 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | ||
| 160 | runtime := mocks.NewMockContainerRuntime(t) | 243 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 161 | envLoader := mocks.NewMockEnvLoader(t) | 244 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -625,6 +708,65 @@ func TestService_Restart_Error(t *testing.T) { | |||||
| 625 | assert.Contains(t, err.Error(), "failed to restart container") | 708 | assert.Contains(t, err.Error(), "failed to restart container") | ||
| 626 | } | 709 | } | ||
| 627 | 710 | ||||
| 711 | + | func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) { | |||
| 712 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 713 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 714 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 715 | + | ||||
| 716 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |||
| 717 | + | ctx := testContext() | |||
| 718 | + | ||||
| 719 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 720 | + | ID: "stale-container-id", | |||
| 721 | + | Name: "gordon-test.example.com", | |||
| 722 | + | Status: "running", | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |||
| 726 | + | Return(errors.New("no container with name or ID \"stale-container-id\" found")). | |||
| 727 | + | Once() | |||
| 728 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 729 | + | { | |||
| 730 | + | ID: "fresh-container-id", | |||
| 731 | + | Name: "gordon-test.example.com", | |||
| 732 | + | Labels: map[string]string{ | |||
| 733 | + | "gordon.domain": "test.example.com", | |||
| 734 | + | "gordon.managed": "true", | |||
| 735 | + | }, | |||
| 736 | + | }, | |||
| 737 | + | }, nil).Once() | |||
| 738 | + | runtime.EXPECT().RestartContainer(mock.Anything, "fresh-container-id").Return(nil).Once() | |||
| 739 | + | ||||
| 740 | + | err := svc.Restart(ctx, "test.example.com", false) | |||
| 741 | + | ||||
| 742 | + | assert.NoError(t, err) | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing.T) { | |||
| 746 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 747 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 748 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 749 | + | ||||
| 750 | + | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |||
| 751 | + | ctx := testContext() | |||
| 752 | + | ||||
| 753 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 754 | + | ID: "stale-container-id", | |||
| 755 | + | Name: "gordon-test.example.com", | |||
| 756 | + | Status: "running", | |||
| 757 | + | } | |||
| 758 | + | ||||
| 759 | + | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |||
| 760 | + | Return(errors.New("no such container")). | |||
| 761 | + | Once() | |||
| 762 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once() | |||
| 763 | + | ||||
| 764 | + | err := svc.Restart(ctx, "test.example.com", false) | |||
| 765 | + | ||||
| 766 | + | assert.Error(t, err) | |||
| 767 | + | assert.True(t, errors.Is(err, domain.ErrContainerNotFound)) | |||
| 768 | + | } | |||
| 769 | + | ||||
| 628 | func TestService_Restart_WithAttachments(t *testing.T) { | 770 | func TestService_Restart_WithAttachments(t *testing.T) { | ||
| 629 | runtime := mocks.NewMockContainerRuntime(t) | 771 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 630 | envLoader := mocks.NewMockEnvLoader(t) | 772 | envLoader := mocks.NewMockEnvLoader(t) | ||
| @@ -1339,6 +1481,131 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||||
| 1339 | assert.Equal(t, "new-container", tracked.ID) | 1481 | assert.Equal(t, "new-container", tracked.ID) | ||
| 1340 | } | 1482 | } | ||
| 1341 | 1483 | ||||
| 1484 | + | func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 1485 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1486 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 1487 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1488 | + | ||||
| 1489 | + | config := Config{ | |||
| 1490 | + | ReadinessDelay: time.Millisecond, | |||
| 1491 | + | } | |||
| 1492 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 1493 | + | ctx := testContext() | |||
| 1494 | + | ||||
| 1495 | + | // Tracked canonical container exists, but stale -new container should be removed. | |||
| 1496 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 1497 | + | ID: "tracked-container-123", | |||
| 1498 | + | Name: "gordon-test.example.com", | |||
| 1499 | + | Status: "running", | |||
| 1500 | + | } | |||
| 1501 | + | ||||
| 1502 | + | route := domain.Route{ | |||
| 1503 | + | Domain: "test.example.com", | |||
| 1504 | + | Image: "myapp:v2", | |||
| 1505 | + | } | |||
| 1506 | + | ||||
| 1507 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |||
| 1508 | + | { | |||
| 1509 | + | ID: "tracked-container-123", | |||
| 1510 | + | Name: "gordon-test.example.com", | |||
| 1511 | + | Status: "running", | |||
| 1512 | + | }, | |||
| 1513 | + | { | |||
| 1514 | + | ID: "stale-new-container", | |||
| 1515 | + | Name: "gordon-test.example.com-new", | |||
| 1516 | + | Status: "exited", | |||
| 1517 | + | }, | |||
| 1518 | + | }, nil) | |||
| 1519 | + | runtime.EXPECT().StopContainer(mock.Anything, "stale-new-container").Return(nil).Once() | |||
| 1520 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "stale-new-container", true).Return(nil).Once() | |||
| 1521 | + | ||||
| 1522 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 1523 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 1524 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 1525 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 1526 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 1527 | + | ||||
| 1528 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 1529 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 1530 | + | return cfg.Name == "gordon-test.example.com-new" | |||
| 1531 | + | })).Return(newContainer, nil) | |||
| 1532 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 1533 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 1534 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 1535 | + | ID: "new-container", | |||
| 1536 | + | Status: "running", | |||
| 1537 | + | }, nil) | |||
| 1538 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 1539 | + | ||||
| 1540 | + | runtime.EXPECT().StopContainer(mock.Anything, "tracked-container-123").Return(nil) | |||
| 1541 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-container-123", true).Return(nil) | |||
| 1542 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 1543 | + | ||||
| 1544 | + | result, err := svc.Deploy(ctx, route) | |||
| 1545 | + | assert.NoError(t, err) | |||
| 1546 | + | assert.NotNil(t, result) | |||
| 1547 | + | assert.Equal(t, "new-container", result.ID) | |||
| 1548 | + | } | |||
| 1549 | + | ||||
| 1550 | + | func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) { | |||
| 1551 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 1552 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 1553 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1554 | + | ||||
| 1555 | + | config := Config{ | |||
| 1556 | + | ReadinessDelay: time.Millisecond, | |||
| 1557 | + | } | |||
| 1558 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 1559 | + | ctx := testContext() | |||
| 1560 | + | ||||
| 1561 | + | // Simulate an interrupted zero-downtime deploy that left "-new" as the tracked container name. | |||
| 1562 | + | svc.containers["test.example.com"] = &domain.Container{ | |||
| 1563 | + | ID: "tracked-temp-container", | |||
| 1564 | + | Name: "gordon-test.example.com-new", | |||
| 1565 | + | Status: "running", | |||
| 1566 | + | } | |||
| 1567 | + | ||||
| 1568 | + | route := domain.Route{ | |||
| 1569 | + | Domain: "test.example.com", | |||
| 1570 | + | Image: "myapp:v2", | |||
| 1571 | + | } | |||
| 1572 | + | ||||
| 1573 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |||
| 1574 | + | { | |||
| 1575 | + | ID: "tracked-temp-container", | |||
| 1576 | + | Name: "gordon-test.example.com-new", | |||
| 1577 | + | Status: "running", | |||
| 1578 | + | }, | |||
| 1579 | + | }, nil) | |||
| 1580 | + | ||||
| 1581 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{}, nil) | |||
| 1582 | + | runtime.EXPECT().PullImage(mock.Anything, "myapp:v2").Return(nil) | |||
| 1583 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:v2").Return([]int{8080}, nil) | |||
| 1584 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 1585 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:v2").Return([]string{}, nil) | |||
| 1586 | + | ||||
| 1587 | + | created := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-next", Status: "created"} | |||
| 1588 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 1589 | + | return cfg.Name == "gordon-test.example.com-next" | |||
| 1590 | + | })).Return(created, nil) | |||
| 1591 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 1592 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(2) | |||
| 1593 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 1594 | + | ID: "new-container", | |||
| 1595 | + | Status: "running", | |||
| 1596 | + | }, nil) | |||
| 1597 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 1598 | + | ||||
| 1599 | + | runtime.EXPECT().StopContainer(mock.Anything, "tracked-temp-container").Return(nil) | |||
| 1600 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "tracked-temp-container", true).Return(nil) | |||
| 1601 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 1602 | + | ||||
| 1603 | + | result, err := svc.Deploy(ctx, route) | |||
| 1604 | + | assert.NoError(t, err) | |||
| 1605 | + | assert.NotNil(t, result) | |||
| 1606 | + | assert.Equal(t, "new-container", result.ID) | |||
| 1607 | + | } | |||
| 1608 | + | ||||
| 1342 | func TestService_StripRegistryPrefix(t *testing.T) { | 1609 | func TestService_StripRegistryPrefix(t *testing.T) { | ||
| 1343 | tests := []struct { | 1610 | tests := []struct { | ||
| 1344 | name string | 1611 | name string | ||
M internal/usecase/registry/service.go +2 -3
| @@ -184,17 +184,16 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error) | |||
| 184 | 184 | } | |
| 185 | 185 | ||
| 186 | 186 | // AppendBlobChunk appends data to an in-progress blob upload. | |
| 187 | - | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) { | |
| 187 | + | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) { | |
| 188 | 188 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 189 | 189 | zerowrap.FieldLayer: "usecase", | |
| 190 | 190 | zerowrap.FieldUseCase: "AppendBlobChunk", | |
| 191 | 191 | "name": name, | |
| 192 | 192 | "uuid": uuid, | |
| 193 | - | zerowrap.FieldSize: len(chunk), | |
| 194 | 193 | }) | |
| 195 | 194 | log := zerowrap.FromCtx(ctx) | |
| 196 | 195 | ||
| 197 | - | length, err := s.blobStorage.AppendBlobChunk(name, uuid, chunk) | |
| 196 | + | length, err := s.blobStorage.AppendBlobChunk(name, uuid, data) | |
| 198 | 197 | if err != nil { | |
| 199 | 198 | return 0, log.WrapErr(err, "failed to append blob chunk") | |
| 200 | 199 | } | |
| @@ -184,17 +184,16 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error) | |||||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | // AppendBlobChunk appends data to an in-progress blob upload. | 186 | // AppendBlobChunk appends data to an in-progress blob upload. | ||
| 187 | - | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, chunk []byte) (int64, error) { | 187 | + | func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) { |
| 188 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 188 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 189 | zerowrap.FieldLayer: "usecase", | 189 | zerowrap.FieldLayer: "usecase", | ||
| 190 | zerowrap.FieldUseCase: "AppendBlobChunk", | 190 | zerowrap.FieldUseCase: "AppendBlobChunk", | ||
| 191 | "name": name, | 191 | "name": name, | ||
| 192 | "uuid": uuid, | 192 | "uuid": uuid, | ||
| 193 | - | zerowrap.FieldSize: len(chunk), | |||
| 194 | }) | 193 | }) | ||
| 195 | log := zerowrap.FromCtx(ctx) | 194 | log := zerowrap.FromCtx(ctx) | ||
| 196 | 195 | ||||
| 197 | - | length, err := s.blobStorage.AppendBlobChunk(name, uuid, chunk) | 196 | + | length, err := s.blobStorage.AppendBlobChunk(name, uuid, data) |
| 198 | if err != nil { | 197 | if err != nil { | ||
| 199 | return 0, log.WrapErr(err, "failed to append blob chunk") | 198 | return 0, log.WrapErr(err, "failed to append blob chunk") | ||
| 200 | } | 199 | } | ||