detached 4a08931

Merge pull request #60 from bnema/review/flatten-v2.11.0

Flatten post-v2.11.0 fixes for review
commit
4a089318c94e96ff448deacd511461db520c401b
author
bnema · · 2026-02-06 21:18 UTC
committer
GitHub · · 2026-02-06 21:18 UTC
merge of
dca6537 + 9b07d6a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +1721 -270
M docs/cli/index.md +13 -2
@@ -109,6 +109,7 @@ gordon remotes use prod
109109 | `-c, --config` | Path to configuration file |
110110 | `--remote` | Remote Gordon URL (e.g., `https://gordon.mydomain.com`) |
111111 | `--token` | Authentication token for remote |
112+| `--insecure` | Skip TLS certificate verification for remote HTTPS endpoints |
112113
113114 ### Remote Targeting
114115
@@ -118,13 +119,23 @@ when you want to bypass your saved configuration.
118119
119120 **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.
120121
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+
121128 ```bash
122129 # 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
124134
125135 # Using environment variables
126-export GORDON_REMOTE=https://gordon.mydomain.com
136+export GORDON_REMOTE=https://gordon.example.com
127137 export GORDON_TOKEN=$TOKEN
138+export GORDON_INSECURE=true
128139 gordon routes list
129140 ```
130141
@@ -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 Targeting114 ### 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 ```bash128 ```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 $TOKEN130+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 variables135 # Using environment variables
126-export GORDON_REMOTE=https://gordon.mydomain.com136+export GORDON_REMOTE=https://gordon.example.com
127 export GORDON_TOKEN=$TOKEN137 export GORDON_TOKEN=$TOKEN
138+export GORDON_INSECURE=true
128 gordon routes list139 gordon routes list
129 ```140 ```
130 141
M docs/cli/push.md +7 -2
@@ -21,6 +21,7 @@ gordon push <domain> [options]
2121 | Option | Description |
2222 |--------|-------------|
2323 | `--build` | Build the image first using `docker buildx` |
24+| `-f, --file` | Path to Dockerfile (default: `./Dockerfile`, used with `--build`) |
2425 | `--platform` | Target platform for buildx (default: `linux/amd64`) |
2526 | `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
2627 | `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) |
@@ -37,8 +38,9 @@ Gordon registry, pushes it, and optionally deploys it.
3738 - The registry and repository are derived from the route image on the server.
3839 - The version tag defaults to the latest git tag. If no tag is found, `latest`
3940 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.
4244 - The version tag and `latest` are both pushed (unless the version is `latest`).
4345
4446 ### Examples
@@ -55,6 +57,9 @@ gordon push myapp.example.com --no-confirm
5557
5658 # Build for ARM and pass build args
5759 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
5863 ```
5964
6065 ### 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` and41+- 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 ### Examples46 ### Examples
@@ -55,6 +57,9 @@ gordon push myapp.example.com --no-confirm
55 57
56 # Build for ARM and pass build args58 # Build for ARM and pass build args
57 gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=059 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 ### Notes65 ### Notes
M docs/cli/remotes.md +43 -0
@@ -222,6 +222,7 @@ active = "prod"
222222 [remotes.prod]
223223 url = "https://gordon.mydomain.com"
224224 token_env = "PROD_TOKEN"
225+insecure_tls = true
225226
226227 [remotes.staging]
227228 url = "https://staging.mydomain.com"
@@ -242,6 +243,12 @@ When multiple sources specify remote or token, the CLI uses this priority:
242243 2. `GORDON_TOKEN` environment variable
243244 3. Token from active remote in `remotes.toml`
244245
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+
245252 This allows overriding specific values while keeping defaults:
246253
247254 ```bash
@@ -297,6 +304,42 @@ gordon remotes use prod && gordon routes list
297304 gordon remotes use staging && gordon routes list
298305 ```
299306
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+
300343 ## Related
301344
302345 - [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 variable243 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 ```bash254 ```bash
@@ -297,6 +304,42 @@ gordon remotes use prod && gordon routes list
297 gordon remotes use staging && gordon routes list304 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 ## Related343 ## 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
4444
4545 ### Notes
4646
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`).
4850
4951 ## Related
5052
@@ -44,7 +44,9 @@ gordon restart myapp.example.com --with-attachments
44 44
45 ### Notes45 ### 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 ## Related51 ## Related
50 52
M docs/config/deploy.md +13 -0
@@ -7,6 +7,7 @@ Controls how Gordon pulls images during deployment.
77 ```toml
88 [deploy]
99 pull_policy = "if-tag-changed"
10+readiness_delay = "5s"
1011 ```
1112
1213 ## Settings
@@ -21,6 +22,18 @@ How to decide when to pull an image:
2122
2223 Default: `if-tag-changed`
2324
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+
2436 ## Notes
2537
2638 - 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 ```toml7 ```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 ## Settings13 ## 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 ## Notes36 ## 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
145145 | `api.rate_limit.burst` | `100` |
146146 | `api.rate_limit.trusted_proxies` | `[]` |
147147 | `deploy.pull_policy` | `"if-tag-changed"` |
148+| `deploy.readiness_delay` | `"5s"` |
148149 | `logging.level` | `"info"` |
149150 | `logging.format` | `"console"` |
150151 | `logging.file.enabled` | `false` |
@@ -173,6 +174,7 @@ The following settings require a restart to take effect:
173174 - `server.data_dir`
174175 - `auth` settings
175176 - `deploy.pull_policy`
177+- `deploy.readiness_delay`
176178
177179 ## Environment Variable Override
178180
@@ -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` settings175 - `auth` settings
175 - `deploy.pull_policy`176 - `deploy.pull_policy`
177+- `deploy.readiness_delay`
176 178
177 ## Environment Variable Override179 ## Environment Variable Override
178 180
M docs/config/reference.md +3 -0
@@ -13,6 +13,7 @@ port = 80 # HTTP proxy port
1313 registry_port = 5000 # Container registry port
1414 gordon_domain = "" # Required: Gordon domain (registry + API)
1515 data_dir = "~/.gordon" # Data directory (varies by install type)
16+max_blob_chunk_size = "512MB" # Max size per registry blob upload chunk
1617
1718 # =============================================================================
1819 # AUTHENTICATION (required - Gordon won't start without credentials configured)
@@ -125,6 +126,7 @@ preserve = true # Keep volumes when containers are
125126 | `server.registry_port` | `5000` | Container registry port |
126127 | `server.gordon_domain` | `""` | **Required** - Gordon domain |
127128 | `server.data_dir` | `~/.gordon` | Data directory |
129+| `server.max_blob_chunk_size` | `"512MB"` | Max size per registry blob upload chunk |
128130 | `auth.enabled` | `true` | Enable authentication |
129131 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
130132 | `auth.token_expiry` | `"720h"` | 30 days |
@@ -144,6 +146,7 @@ preserve = true # Keep volumes when containers are
144146 | `logging.container_logs.max_backups` | `3` | Keep 3 old files |
145147 | `logging.container_logs.max_age` | `28` | 28 days |
146148 | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
149+| `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
147150 | `auto_route.enabled` | `false` | Auto-route disabled |
148151 | `network_isolation.enabled` | `false` | Isolation disabled |
149152 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
@@ -13,6 +13,7 @@ port = 80 # HTTP proxy port
13 registry_port = 5000 # Container registry port13 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)
2222 | `registry_domain` | string | - | Deprecated alias for `gordon_domain` |
2323 | `data_dir` | string | `~/.gordon` | Directory for registry data, logs, and env files |
2424 | `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 |
2526
2627 ## Port Configuration
2728
@@ -140,7 +141,32 @@ data_dir = "~/.gordon" # Default for user installations
140141
141142 > **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.
142143
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
144170
145171 ### Development Configuration
146172
@@ -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 Configuration27 ## 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- ## Examples144+## 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 Configuration171 ### Development Configuration
146 172
M internal/adapters/in/cli/auth.go +6 -3
@@ -297,7 +297,8 @@ func runAuthLogin(remoteName, username, password string) error {
297297 }
298298
299299 // Create remote client
300- client := remote.NewClient(remoteConfig.URL)
300+ insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
301+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions("", insecureTLS)...)
301302
302303 // Authenticate
303304 ctx := context.Background()
@@ -332,7 +333,8 @@ func runAuthLoginWithToken(remoteName, token string) error {
332333 return err
333334 }
334335
335- client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
336+ insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
337+ client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
336338 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
337339 defer cancel()
338340 verified := true
@@ -758,7 +760,8 @@ func runAuthStatus(remoteNameArg string) error {
758760 }
759761
760762 // 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)...)
762765
763766 // Verify auth
764767 ctx := context.Background()
@@ -297,7 +297,8 @@ func runAuthLogin(remoteName, username, password string) error {
297 }297 }
298 298
299 // Create remote client299 // 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 // Authenticate303 // Authenticate
303 ctx := context.Background()304 ctx := context.Background()
@@ -332,7 +333,8 @@ func runAuthLoginWithToken(remoteName, token string) error {
332 return err333 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 := true340 verified := true
@@ -758,7 +760,8 @@ func runAuthStatus(remoteNameArg string) error {
758 }760 }
759 761
760 // Create client762 // 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 auth766 // Verify auth
764 ctx := context.Background()767 ctx := context.Background()
M internal/adapters/in/cli/deploy.go +9 -0
@@ -35,6 +35,15 @@ Examples:
3535 // Remote deployment via admin API
3636 result, err := client.Deploy(ctx, deployDomain)
3737 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+ }
3847 return fmt.Errorf("failed to deploy: %w", err)
3948 }
4049 containerID := result.ContainerID
@@ -35,6 +35,15 @@ Examples:
35 // Remote deployment via admin API35 // 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.ContainerID49 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 (
55 "context"
66 "fmt"
77 "path/filepath"
8+ "strings"
89
910 "github.com/bnema/zerowrap"
1011 "github.com/spf13/viper"
@@ -12,6 +13,8 @@ import (
1213 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
1314 "github.com/bnema/gordon/internal/app"
1415 "github.com/bnema/gordon/internal/boundaries/in"
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ "github.com/bnema/gordon/internal/domain"
1518 "github.com/bnema/gordon/internal/usecase/config"
1619 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
1720 )
@@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
8285 envDir = filepath.Join(dataDir, "env")
8386 }
8487
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)
8790 if err != nil {
8891 return nil, fmt.Errorf("failed to create domain secret store: %w", err)
8992 }
@@ -95,3 +98,36 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
9598 dataDir: dataDir,
9699 }, nil
97100 }
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 service88+ // 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 }, nil99 }, 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 (
1818
1919 func newPushCmd() *cobra.Command {
2020 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
2728 )
2829
2930 cmd := &cobra.Command{
@@ -41,7 +42,7 @@ Examples:
4142 gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,
4243 Args: cobra.ExactArgs(1),
4344 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)
4546 },
4647 }
4748
@@ -49,18 +50,40 @@ Examples:
4950 cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
5051 cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
5152 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)")
5254 cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
5355 cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
5456
5557 return cmd
5658 }
5759
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 {
5977 client, isRemote := GetRemoteClient()
6078 if !isRemote {
6179 return fmt.Errorf("push requires remote mode")
6280 }
6381
82+ dockerfile, err := resolveDockerfile(dockerfile, build)
83+ if err != nil {
84+ return err
85+ }
86+
6487 route, err := client.GetRoute(ctx, pushDomain)
6588 if err != nil {
6689 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
84107 }
85108 }
86109
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)
89111
90112 fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
91113 if version != "latest" {
@@ -93,7 +115,7 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s
93115 }
94116
95117 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 {
97119 return err
98120 }
99121 } else {
@@ -122,9 +144,9 @@ func determineVersion(ctx context.Context, tag string) string {
122144 return version
123145 }
124146
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)
128150 }
129151
130152 // Build and load into local daemon (NOT --push).
@@ -132,7 +154,7 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str
132154 // Cloudflare's 100MB per-request limit. Loading locally then
133155 // using docker push gives us chunked uploads (~5MB per request).
134156 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
136158 buildCmd.Stdout = os.Stdout
137159 buildCmd.Stderr = os.Stderr
138160 if err := buildCmd.Run(); err != nil {
@@ -155,10 +177,11 @@ func buildAndPush(ctx context.Context, version, platform string, buildArgs []str
155177 // buildImageArgs constructs the docker buildx build arguments.
156178 // Uses --load instead of --push so the image is loaded into the local
157179 // 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 {
159181 args := []string{
160182 "buildx", "build",
161183 "--platform", platform,
184+ "-f", dockerfile,
162185 "-t", latestRef,
163186 "--build-arg", "VERSION=" + version,
164187 }
@@ -18,12 +18,13 @@ import (
18 18
19 func newPushCmd() *cobra.Command {19 func newPushCmd() *cobra.Command {
20 var (20 var (
21- noDeploy bool21+ noDeploy bool
22- noConfirm bool22+ noConfirm bool
23- build bool23+ build bool
24- platform string24+ platform string
25- tag string25+ tag string
26- buildArgs []string26+ 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 cmd57 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 err119 return err
98 }120 }
99 } else {121 } else {
@@ -122,9 +144,9 @@ func determineVersion(ctx context.Context, tag string) string {
122 return version144 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 then154 // 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 G204157+ buildCmd := exec.CommandContext(ctx, "docker", buildImageArgs(version, platform, dockerfile, buildArgs, versionRef, latestRef)...) // #nosec G204
136 buildCmd.Stdout = os.Stdout158 buildCmd.Stdout = os.Stdout
137 buildCmd.Stderr = os.Stderr159 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 local178 // 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) {
6363
6464 func TestBuildAndPush_BuildArgs(t *testing.T) {
6565 // 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")
6767
6868 assert.Contains(t, args, "--load")
6969 assert.NotContains(t, args, "--push")
7070 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")
7180 }
@@ -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 --push65 // 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
44 import (
55 "bytes"
66 "context"
7+ "crypto/tls"
78 "encoding/json"
9+ "errors"
810 "fmt"
911 "io"
1012 "net/http"
1113 "net/url"
14+ "os"
1215 "strings"
1316 "time"
1417
@@ -18,11 +21,17 @@ import (
1821
1922 // Client is an HTTP client for the Gordon admin API.
2023 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
2428 }
2529
30+var (
31+ retryMaxAttempts = 4
32+ retryBaseDelay = 250 * time.Millisecond
33+)
34+
2635 // ClientOption configures the Client.
2736 type ClientOption func(*Client)
2837
@@ -42,6 +51,8 @@ func NewClient(baseURL string, opts ...ClientOption) *Client {
4251 opt(c)
4352 }
4453
54+ c.applyTLSConfig()
55+
4556 return c
4657 }
4758
@@ -66,6 +77,37 @@ func WithTimeout(timeout time.Duration) ClientOption {
6677 }
6778 }
6879
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+
69111 // request performs an HTTP request to the admin API.
70112 func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
71113 url := c.baseURL + "/admin" + path
@@ -100,13 +142,7 @@ func parseResponse(resp *http.Response, target any) error {
100142
101143 if resp.StatusCode >= 400 {
102144 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)
110146 }
111147
112148 if target != nil {
@@ -118,6 +154,74 @@ func parseResponse(resp *http.Response, target any) error {
118154 return nil
119155 }
120156
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+
121225 // Routes API
122226
123227 // Type aliases for API types using shared DTO package.
@@ -371,7 +475,7 @@ func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error)
371475
372476 // Reload triggers a configuration reload.
373477 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)
375479 if err != nil {
376480 return err
377481 }
@@ -431,7 +535,7 @@ type DeployResult struct {
431535
432536 // Deploy triggers a deployment for the specified domain.
433537 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)
435539 if err != nil {
436540 return nil, err
437541 }
@@ -461,7 +565,7 @@ func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachme
461565 if withAttachments {
462566 path += "?attachments=true"
463567 }
464- resp, err := c.request(ctx, http.MethodPost, path, nil)
568+ resp, err := c.requestWithRetry(ctx, http.MethodPost, path, nil)
465569 if err != nil {
466570 return nil, err
467571 }
@@ -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 string24+ baseURL string
22- token string25+ token string
23- httpClient *http.Client26+ 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 c56 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" + path113 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 nil154 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 API225 // 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 err480 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, err540 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, err570 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 (
44 "fmt"
55 "os"
66 "path/filepath"
7+ "strconv"
78
89 "github.com/pelletier/go-toml/v2"
910 )
@@ -17,17 +18,19 @@ type ClientConfig struct {
1718
1819 // ClientSettings represents the [client] section.
1920 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
2426 }
2527
2628 // RemoteEntry represents a saved remote in [remotes.*].
2729 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"`
3134 }
3235
3336 // DefaultClientConfigPath returns the default client config path.
@@ -134,43 +137,44 @@ func SaveRemotes(path string, config *ClientConfig) error {
134137
135138 // ResolveRemote resolves the remote URL and token from configuration.
136139 // 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) {
138141 config, _ := LoadClientConfig("")
139142 remotes, _ := LoadRemotes("")
140143
141- url, isRemote = resolveRemoteURL(flagRemote, config, remotes)
144+ url, remoteName, isRemote := resolveRemoteURL(flagRemote, config, remotes)
142145 if isRemote {
143146 token = resolveToken(flagToken, config, remotes)
147+ insecureTLS = resolveInsecureTLS(flagInsecure, config, remotes, remoteName)
144148 }
145149
146- return url, token, isRemote
150+ return url, token, insecureTLS, isRemote
147151 }
148152
149153 // 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) {
151155 // 1. Check flag
152156 if flagRemote != "" {
153- return flagRemote, true
157+ return flagRemote, "", true
154158 }
155159
156160 // 2. Check environment variable
157161 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
158- return envRemote, true
162+ return envRemote, "", true
159163 }
160164
161165 // 3. Check client config
162166 if config != nil && config.Client.Mode == "remote" && config.Client.Remote != "" {
163- return config.Client.Remote, true
167+ return config.Client.Remote, "", true
164168 }
165169
166170 // 4. Check active remote
167171 if remotes != nil && remotes.Active != "" {
168172 if remote, ok := remotes.Remotes[remotes.Active]; ok {
169- return remote.URL, true
173+ return remote.URL, remotes.Active, true
170174 }
171175 }
172176
173- return "", false
177+ return "", "", false
174178 }
175179
176180 // resolveToken resolves the authentication token from various sources.
@@ -210,6 +214,39 @@ func resolveToken(flagToken string, config *ClientConfig, remotes *ClientConfig)
210214 return ""
211215 }
212216
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+
213250 // AddRemote adds a new remote to the remotes configuration.
214251 func AddRemote(name, url, token string) error {
215252 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 URL22+ Remote string `toml:"remote"` // Remote Gordon URL
22- Token string `toml:"token"` // Auth token23+ Token string `toml:"token"` // Auth token
23- TokenEnv string `toml:"token_env"` // Env var name for token24+ 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 remote139 // 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, isRemote150+ 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 flag155 // 1. Check flag
152 if flagRemote != "" {156 if flagRemote != "" {
153- return flagRemote, true157+ return flagRemote, "", true
154 }158 }
155 159
156 // 2. Check environment variable160 // 2. Check environment variable
157 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {161 if envRemote := os.Getenv("GORDON_REMOTE"); envRemote != "" {
158- return envRemote, true162+ return envRemote, "", true
159 }163 }
160 164
161 // 3. Check client config165 // 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, true167+ return config.Client.Remote, "", true
164 }168 }
165 169
166 // 4. Check active remote170 // 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, true173+ return remote.URL, remotes.Active, true
170 }174 }
171 }175 }
172 176
173- return "", false177+ 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 (
66 "github.com/spf13/cobra"
77
88 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
9+ "github.com/bnema/gordon/internal/app"
910 )
1011
1112 func newRestartCmd() *cobra.Command {
@@ -29,11 +30,28 @@ Examples:
2930
3031 client, isRemote := GetRemoteClient()
3132 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
3342 }
3443
3544 result, err := client.Restart(ctx, restartDomain, withAttachments)
3645 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+ }
3755 return fmt.Errorf("failed to restart: %w", err)
3856 }
3957 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 (
2222 BuildDate = "unknown"
2323
2424 // Global flags for remote targeting
25- remoteFlag string
26- tokenFlag string
25+ remoteFlag string
26+ tokenFlag string
27+ insecureTLSFlag bool
2728 )
2829
2930 // Command group IDs
@@ -60,6 +61,7 @@ Commands are organized by where they run:
6061 // Add persistent flags for remote targeting
6162 rootCmd.PersistentFlags().StringVar(&remoteFlag, "remote", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
6263 rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote")
64+ rootCmd.PersistentFlags().BoolVar(&insecureTLSFlag, "insecure", false, "Skip TLS certificate verification for remote HTTPS endpoints")
6365
6466 // Server-only commands (must run on the Gordon host)
6567 serveCmd := newServeCmd()
@@ -134,18 +136,29 @@ Commands are organized by where they run:
134136 // GetRemoteClient returns a remote client if targeting a remote instance,
135137 // or nil if running locally.
136138 func GetRemoteClient() (*remote.Client, bool) {
137- url, token, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
139+ url, token, insecureTLS, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
138140 if !isRemote {
139141 return nil, false
140142 }
141143
142- client := remote.NewClient(url, remote.WithToken(token))
144+ client := remote.NewClient(url, remoteClientOptions(token, insecureTLS)...)
143145 return client, true
144146 }
145147
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+
146159 // IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
147160 func IsRemoteMode() bool {
148- _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag)
161+ _, _, _, isRemote := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag)
149162 return isRemote
150163 }
151164
@@ -190,6 +203,15 @@ func runReload() error {
190203 // runReloadRemote triggers a reload on a remote Gordon instance.
191204 func runReloadRemote(ctx context.Context, client *remote.Client) error {
192205 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+ }
193215 return fmt.Errorf("failed to reload: %w", err)
194216 }
195217 fmt.Println("Configuration reloaded successfully")
@@ -22,8 +22,9 @@ var (
22 BuildDate = "unknown"22 BuildDate = "unknown"
23 23
24 // Global flags for remote targeting24 // Global flags for remote targeting
25- remoteFlag string25+ remoteFlag string
26- tokenFlag string26+ tokenFlag string
27+ insecureTLSFlag bool
27 )28 )
28 29
29 // Command group IDs30 // Command group IDs
@@ -60,6 +61,7 @@ Commands are organized by where they run:
60 // Add persistent flags for remote targeting61 // 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, false141 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, true145 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 isRemote162 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
837837 return
838838 }
839839
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)
842842 if err != nil {
843843 log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container")
844844 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 return837 return
838 }838 }
839 839
840- // Deploy the container840+ // 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 (
2222 const (
2323 // MaxManifestSize limits manifest uploads to 10MB.
2424 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
2929 )
3030
3131 // Handler implements the HTTP handler for Docker Registry API v2.
3232 type Handler struct {
33- registrySvc in.RegistryService
34- log zerowrap.Logger
33+ registrySvc in.RegistryService
34+ log zerowrap.Logger
35+ maxBlobChunkSize int64
3536 }
3637
3738 // NewHandler creates a new registry HTTP handler.
3839 func NewHandler(
3940 registrySvc in.RegistryService,
4041 log zerowrap.Logger,
42+ maxBlobChunkSize int64,
4143 ) *Handler {
44+ if maxBlobChunkSize <= 0 {
45+ maxBlobChunkSize = DefaultMaxBlobChunkSize
46+ }
47+
4248 return &Handler{
43- registrySvc: registrySvc,
44- log: log,
49+ registrySvc: registrySvc,
50+ log: log,
51+ maxBlobChunkSize: maxBlobChunkSize,
4552 }
4653 }
4754
@@ -400,26 +407,20 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
400407 }
401408 }
402409
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)
405413
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)
408417 if err != nil {
409418 var maxBytesErr *http.MaxBytesError
410419 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")
412421 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
413422 return
414423 }
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 {
423424 log.Error().Err(err).Msg("failed to append blob chunk")
424425 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
425426 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 * 102424 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 behind26+ // 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 * 102428+ 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.RegistryService33+ registrySvc in.RegistryService
34- log zerowrap.Logger34+ 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 exhaustion410+ // 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 body414+ // 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.MaxBytesError418 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 return422 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 return426 return
M internal/adapters/in/http/registry/handler_test.go +54 -33
@@ -2,7 +2,9 @@ package registry
22
33 import (
44 "bytes"
5+ "context"
56 "encoding/json"
7+ "io"
68 "net/http"
79 "net/http/httptest"
810 "os"
@@ -23,7 +25,7 @@ func testLogger() zerowrap.Logger {
2325 func TestHandler_Base(t *testing.T) {
2426 registrySvc := inmocks.NewMockRegistryService(t)
2527
26- handler := NewHandler(registrySvc, testLogger())
28+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
2729
2830 req := httptest.NewRequest("GET", "/v2/", nil)
2931 rec := httptest.NewRecorder()
@@ -37,7 +39,7 @@ func TestHandler_Base(t *testing.T) {
3739 func TestHandler_NotFound(t *testing.T) {
3840 registrySvc := inmocks.NewMockRegistryService(t)
3941
40- handler := NewHandler(registrySvc, testLogger())
42+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
4143
4244 req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
4345 rec := httptest.NewRecorder()
@@ -50,7 +52,7 @@ func TestHandler_NotFound(t *testing.T) {
5052 func TestHandler_GetManifest_Success(t *testing.T) {
5153 registrySvc := inmocks.NewMockRegistryService(t)
5254
53- handler := NewHandler(registrySvc, testLogger())
55+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
5456
5557 manifestData := []byte(`{"schemaVersion": 2}`)
5658 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -73,7 +75,7 @@ func TestHandler_GetManifest_Success(t *testing.T) {
7375 func TestHandler_GetManifest_HEAD(t *testing.T) {
7476 registrySvc := inmocks.NewMockRegistryService(t)
7577
76- handler := NewHandler(registrySvc, testLogger())
78+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
7779
7880 manifestData := []byte(`{"schemaVersion": 2}`)
7981 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
@@ -97,7 +99,7 @@ func TestHandler_GetManifest_HEAD(t *testing.T) {
9799 func TestHandler_GetManifest_NotFound(t *testing.T) {
98100 registrySvc := inmocks.NewMockRegistryService(t)
99101
100- handler := NewHandler(registrySvc, testLogger())
102+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
101103
102104 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
103105
@@ -113,7 +115,7 @@ func TestHandler_GetManifest_NotFound(t *testing.T) {
113115 func TestHandler_GetManifest_NestedName(t *testing.T) {
114116 registrySvc := inmocks.NewMockRegistryService(t)
115117
116- handler := NewHandler(registrySvc, testLogger())
118+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
117119
118120 manifestData := []byte(`{"schemaVersion": 2}`)
119121 registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
@@ -134,7 +136,7 @@ func TestHandler_GetManifest_NestedName(t *testing.T) {
134136 func TestHandler_PutManifest_Success(t *testing.T) {
135137 registrySvc := inmocks.NewMockRegistryService(t)
136138
137- handler := NewHandler(registrySvc, testLogger())
139+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
138140
139141 manifestData := []byte(`{"schemaVersion": 2}`)
140142 registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
@@ -155,7 +157,7 @@ func TestHandler_PutManifest_Success(t *testing.T) {
155157 func TestHandler_PutManifest_MissingContentType(t *testing.T) {
156158 registrySvc := inmocks.NewMockRegistryService(t)
157159
158- handler := NewHandler(registrySvc, testLogger())
160+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
159161
160162 manifestData := []byte(`{"schemaVersion": 2}`)
161163
@@ -171,7 +173,7 @@ func TestHandler_PutManifest_MissingContentType(t *testing.T) {
171173 func TestHandler_PutManifest_StorageError(t *testing.T) {
172174 registrySvc := inmocks.NewMockRegistryService(t)
173175
174- handler := NewHandler(registrySvc, testLogger())
176+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
175177
176178 manifestData := []byte(`{"schemaVersion": 2}`)
177179 registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
@@ -188,7 +190,7 @@ func TestHandler_PutManifest_StorageError(t *testing.T) {
188190 func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
189191 registrySvc := inmocks.NewMockRegistryService(t)
190192
191- handler := NewHandler(registrySvc, testLogger())
193+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
192194
193195 req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
194196 rec := httptest.NewRecorder()
@@ -201,7 +203,7 @@ func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
201203 func TestHandler_GetBlob_Success(t *testing.T) {
202204 registrySvc := inmocks.NewMockRegistryService(t)
203205
204- handler := NewHandler(registrySvc, testLogger())
206+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
205207
206208 // Create a temp file for http.ServeFile
207209 tmpFile, err := os.CreateTemp("", "blob-*")
@@ -226,7 +228,7 @@ func TestHandler_GetBlob_Success(t *testing.T) {
226228 func TestHandler_GetBlob_NotFound(t *testing.T) {
227229 registrySvc := inmocks.NewMockRegistryService(t)
228230
229- handler := NewHandler(registrySvc, testLogger())
231+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
230232
231233 registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
232234
@@ -241,7 +243,7 @@ func TestHandler_GetBlob_NotFound(t *testing.T) {
241243 func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
242244 registrySvc := inmocks.NewMockRegistryService(t)
243245
244- handler := NewHandler(registrySvc, testLogger())
246+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
245247
246248 req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
247249 rec := httptest.NewRecorder()
@@ -255,7 +257,7 @@ func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
255257 func TestHandler_StartBlobUpload_Success(t *testing.T) {
256258 registrySvc := inmocks.NewMockRegistryService(t)
257259
258- handler := NewHandler(registrySvc, testLogger())
260+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
259261
260262 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
261263
@@ -272,7 +274,7 @@ func TestHandler_StartBlobUpload_Success(t *testing.T) {
272274 func TestHandler_StartBlobUpload_Error(t *testing.T) {
273275 registrySvc := inmocks.NewMockRegistryService(t)
274276
275- handler := NewHandler(registrySvc, testLogger())
277+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
276278
277279 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
278280
@@ -288,10 +290,10 @@ func TestHandler_StartBlobUpload_Error(t *testing.T) {
288290 func TestHandler_BlobUpload_PATCH(t *testing.T) {
289291 registrySvc := inmocks.NewMockRegistryService(t)
290292
291- handler := NewHandler(registrySvc, testLogger())
293+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
292294
293295 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)
295297
296298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
297299 rec := httptest.NewRecorder()
@@ -305,10 +307,10 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
305307 func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
306308 registrySvc := inmocks.NewMockRegistryService(t)
307309
308- handler := NewHandler(registrySvc, testLogger())
310+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
309311
310312 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)
312314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
313315
314316 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) {
324326 func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
325327 registrySvc := inmocks.NewMockRegistryService(t)
326328
327- handler := NewHandler(registrySvc, testLogger())
329+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
328330
329331 chunkData := []byte("final chunk")
330332 // Note: Invalid digest format is rejected by validation before reaching storage layer
@@ -342,7 +344,7 @@ func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
342344 func TestHandler_ListTags_Success(t *testing.T) {
343345 registrySvc := inmocks.NewMockRegistryService(t)
344346
345- handler := NewHandler(registrySvc, testLogger())
347+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
346348
347349 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
348350
@@ -366,7 +368,7 @@ func TestHandler_ListTags_Success(t *testing.T) {
366368 func TestHandler_ListTags_NotFound(t *testing.T) {
367369 registrySvc := inmocks.NewMockRegistryService(t)
368370
369- handler := NewHandler(registrySvc, testLogger())
371+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
370372
371373 registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
372374
@@ -382,7 +384,7 @@ func TestHandler_ListTags_NotFound(t *testing.T) {
382384 func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
383385 registrySvc := inmocks.NewMockRegistryService(t)
384386
385- handler := NewHandler(registrySvc, testLogger())
387+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
386388
387389 req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
388390 rec := httptest.NewRecorder()
@@ -396,7 +398,7 @@ func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
396398 func TestHandler_RegisterRoutes(t *testing.T) {
397399 registrySvc := inmocks.NewMockRegistryService(t)
398400
399- handler := NewHandler(registrySvc, testLogger())
401+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
400402
401403 mux := http.NewServeMux()
402404 handler.RegisterRoutes(mux)
@@ -412,7 +414,7 @@ func TestHandler_RegisterRoutes(t *testing.T) {
412414
413415 func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
414416 registrySvc := inmocks.NewMockRegistryService(t)
415- handler := NewHandler(registrySvc, testLogger())
417+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
416418
417419 registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
418420
@@ -427,10 +429,10 @@ func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
427429
428430 func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
429431 registrySvc := inmocks.NewMockRegistryService(t)
430- handler := NewHandler(registrySvc, testLogger())
432+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
431433
432434 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)
434436
435437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
436438 rec := httptest.NewRecorder()
@@ -441,18 +443,37 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
441443 assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))
442444 }
443445
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)
448469 }
449470
450471 func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
451472 registrySvc := inmocks.NewMockRegistryService(t)
452- handler := NewHandler(registrySvc, testLogger())
473+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
453474
454475 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)
456477
457478 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
458479 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.ServeFile208 // 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 layer332 // 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 limit447+ 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) {
6363 if err != nil {
6464 return nil, err
6565 }
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+ }
6875 }
76+ sort.Strings(keys)
6977
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")
7597 }
76- keys = append(keys, key)
7798 }
7899
79- return keys, nil
100+ return merged, nil
80101 }
81102
82103 // GetAll returns all secrets for a domain as a key-value map.
@@ -496,6 +517,35 @@ func (s *PassStore) manifestPath(domainName string) (string, error) {
496517 return path, nil
497518 }
498519
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+
499549 func (s *PassStore) attachmentPath(containerName string) (string, error) {
500550 if err := domain.ValidateContainerName(containerName); err != nil {
501551 return "", err
@@ -725,6 +775,34 @@ func passEntryMissing(output string) bool {
725775 strings.Contains(lower, "password store is empty")
726776 }
727777
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+
728806 func (s *PassStore) cleanupInsertedPaths(paths []string) {
729807 if len(paths) == 0 {
730808 return
@@ -63,20 +63,41 @@ func (s *PassStore) ListKeys(domainName string) ([]string, error) {
63 if err != nil {63 if err != nil {
64 return nil, err64 return nil, err
65 }65 }
66- if !exists {66+ keys := []string{}
67- return []string{}, nil67+ 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- continue82+ }
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, nil100+ 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, nil517 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 "", err551 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 return808 return
M internal/adapters/out/domainsecrets/pass_store_test.go +131 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "os/exec"
7+ "strings"
78 "testing"
89 "time"
910
@@ -19,6 +20,25 @@ func passCmd(args ...string) error {
1920 return exec.CommandContext(ctx, "pass", args...).Run()
2021 }
2122
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+
2242 func requirePass(t *testing.T) {
2343 if err := passCmd("version"); err != nil {
2444 t.Skip("pass not available")
@@ -157,6 +177,117 @@ func TestPassStore_DeleteAttachment(t *testing.T) {
157177 assert.False(t, exists)
158178 }
159179
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+
160291 func TestParsePassListOutput(t *testing.T) {
161292 tests := []struct {
162293 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 string293 name string
M internal/adapters/out/filesystem/blob.go +3 -3
@@ -197,7 +197,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
197197 }
198198
199199 // 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) {
201201 uploadPath, err := s.getUploadPath(uuid)
202202 if err != nil {
203203 return 0, fmt.Errorf("invalid upload path: %w", err)
@@ -212,7 +212,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e
212212 }
213213 defer file.Close()
214214
215- written, err := file.Write(chunk)
215+ written, err := io.Copy(file, data)
216216 if err != nil {
217217 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
218218 }
@@ -227,7 +227,7 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, e
227227 Str(zerowrap.FieldAdapter, "filesystem").
228228 Str("uuid", uuid).
229229 Str("name", name).
230- Int("chunk_size", written).
230+ Int64("chunk_size", written).
231231 Int64("total_size", fi.Size()).
232232 Msg("appended chunk to blob upload")
233233
@@ -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) {
242242
243243 // Append first chunk
244244 chunk1 := []byte("first chunk")
245- size1, err := storage.AppendBlobChunk("myapp", uuid, chunk1)
245+ size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1))
246246 require.NoError(t, err)
247247 assert.Equal(t, int64(len(chunk1)), size1)
248248
249249 // Append second chunk
250250 chunk2 := []byte(" second chunk")
251- size2, err := storage.AppendBlobChunk("myapp", uuid, chunk2)
251+ size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2))
252252 require.NoError(t, err)
253253 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
254254 }
@@ -260,7 +260,7 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
260260 storage, err := NewBlobStorage(tmpDir, log)
261261 require.NoError(t, err)
262262
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")))
264264
265265 assert.Error(t, err)
266266 assert.Contains(t, err.Error(), "upload not found")
@@ -312,7 +312,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) {
312312
313313 // Append data - content that hashes to testDigest1
314314 blobData := []byte("complete blob content")
315- _, err = storage.AppendBlobChunk("myapp", uuid, blobData)
315+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
316316 require.NoError(t, err)
317317
318318 // Finish upload - use testDigest1 (validation will pass, but hash won't match)
@@ -439,7 +439,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
439439
440440 var totalSize int64
441441 for _, chunk := range chunks {
442- size, err := storage.AppendBlobChunk(repoName, uuid, chunk)
442+ size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk))
443443 require.NoError(t, err)
444444 totalSize = size
445445 }
@@ -242,13 +242,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) {
242 242
243 // Append first chunk243 // 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 chunk249 // 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 testDigest1313 // 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 int64440 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 = size444 totalSize = size
445 }445 }
M internal/app/run.go +130 -66
@@ -6,12 +6,14 @@ import (
66 "crypto/rand"
77 "encoding/hex"
88 "encoding/json"
9+ "errors"
910 "fmt"
1011 "net"
1112 "net/http"
1213 "os"
1314 "os/signal"
1415 "path/filepath"
16+ "strconv"
1517 "strings"
1618 "syscall"
1719 "time"
@@ -68,6 +70,7 @@ type Config struct {
6870 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
6971 DataDir string `mapstructure:"data_dir"`
7072 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
73+ MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
7174 } `mapstructure:"server"`
7275
7376 Logging struct {
@@ -117,23 +120,24 @@ type Config struct {
117120
118121 // services holds all the services used by the application.
119122 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
137141 }
138142
139143 // Run initializes and starts the Gordon application.
@@ -306,6 +310,18 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
306310 maxProxyBodySize = parsedSize
307311 }
308312
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+
309325 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
310326 RegistryDomain: cfg.Server.RegistryDomain,
311327 RegistryPort: cfg.Server.RegistryPort,
@@ -853,6 +869,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
853869 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
854870 NetworkGroups: svc.configSvc.GetNetworkGroups(),
855871 Attachments: svc.configSvc.GetAttachments(),
872+ ReadinessDelay: v.GetDuration("deploy.readiness_delay"),
856873 }
857874
858875 if cfg.Auth.Enabled && svc.authSvc != nil {
@@ -971,7 +988,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
971988 // This ensures consistent IP extraction across logging, rate limiting, and auth.
972989 trustedNets := middleware.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
973990
974- registryHandler := registry.NewHandler(svc.registrySvc, log)
991+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
975992
976993 registryMiddlewares := []func(http.Handler) http.Handler{
977994 middleware.PanicRecovery(log),
@@ -1186,24 +1203,9 @@ func startServer(addr string, handler http.Handler, name string, errChan chan<-
11861203
11871204 // SendReloadSignal sends SIGUSR1 to the running Gordon process.
11881205 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()
11951207 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
12071209 }
12081210
12091211 if err := process.Signal(syscall.SIGUSR1); err != nil {
@@ -1256,28 +1258,10 @@ func SendDeploySignal(domain string) (string, error) {
12561258 }
12571259
12581260 // 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()
12661262 if err != nil {
12671263 _ = 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
12811265 }
12821266
12831267 if err := process.Signal(syscall.SIGUSR2); err != nil {
@@ -1357,34 +1341,113 @@ func removePidFile(pidFile string, log zerowrap.Logger) {
13571341 }
13581342 }
13591343
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 {
13631345 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+ }
13641357
13651358 // Check secure runtime directory first
13661359 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"))
13681372 }
13691373
13701374 // Check home directory
13711375 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"))
13731377 // Legacy location for backward compatibility
1374- locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1378+ add(filepath.Join(homeDir, ".gordon.pid"))
13751379 }
13761380
13771381 // 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
13801396
13811397 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
13841401 }
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)
13851416 }
13861417
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)
13881451 }
13891452
13901453 // loadConfig loads configuration from file and sets defaults.
@@ -1421,6 +1484,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
14211484 v.SetDefault("volumes.prefix", "gordon")
14221485 v.SetDefault("volumes.preserve", true)
14231486 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1487+ v.SetDefault("deploy.readiness_delay", "5s")
14241488
14251489 ConfigureViper(v, configPath)
14261490
@@ -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_domain70 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.Runtime123+ runtime *docker.Runtime
121- eventBus *eventbus.InMemory124+ eventBus *eventbus.InMemory
122- blobStorage *filesystem.BlobStorage125+ blobStorage *filesystem.BlobStorage
123- manifestStorage *filesystem.ManifestStorage126+ manifestStorage *filesystem.ManifestStorage
124- envLoader out.EnvLoader127+ envLoader out.EnvLoader
125- logWriter *logwriter.LogWriter128+ logWriter *logwriter.LogWriter
126- tokenStore out.TokenStore129+ tokenStore out.TokenStore
127- configSvc *config.Service130+ configSvc *config.Service
128- containerSvc *container.Service131+ containerSvc *container.Service
129- registrySvc *registrySvc.Service132+ registrySvc *registrySvc.Service
130- proxySvc *proxy.Service133+ proxySvc *proxy.Service
131- authSvc *auth.Service134+ authSvc *auth.Service
132- authHandler *authhandler.Handler135+ authHandler *authhandler.Handler
133- adminHandler *admin.Handler136+ adminHandler *admin.Handler
134- internalRegUser string137+ internalRegUser string
135- internalRegPass string138+ internalRegPass string
136- envDir string139+ 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 = parsedSize310 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 SIGUSR21260 // 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 []string1345 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 first1358 // 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 directory1374 // 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 compatibility1377 // 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 compatibility1381 // 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 location1399+ 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 {
4040 }
4141
4242 // 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)
4545
4646 if len(ret) == 0 {
4747 panic("no return value specified for AppendBlobChunk")
@@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri
4949
5050 var r0 int64
5151 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)
5454 }
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)
5757 } else {
5858 r0 = ret.Get(0).(int64)
5959 }
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)
6262 } else {
6363 r1 = ret.Error(1)
6464 }
@@ -74,12 +74,12 @@ type MockRegistryService_AppendBlobChunk_Call struct {
7474 // - ctx context.Context
7575 // - name string
7676 // - 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)}
8080 }
8181
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 {
8383 _c.Call.Run(func(args mock.Arguments) {
8484 var arg0 context.Context
8585 if args[0] != nil {
@@ -93,9 +93,9 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con
9393 if args[2] != nil {
9494 arg2 = args[2].(string)
9595 }
96- var arg3 []byte
96+ var arg3 io.Reader
9797 if args[3] != nil {
98- arg3 = args[3].([]byte)
98+ arg3 = args[3].(io.Reader)
9999 }
100100 run(
101101 arg0,
@@ -112,7 +112,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *
112112 return _c
113113 }
114114
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 {
116116 _c.Call.Return(run)
117117 return _c
118118 }
@@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
40 }40 }
41 41
42 // AppendBlobChunk provides a mock function for the type MockRegistryService42 // 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 int6450 var r0 int64
51 var r1 error51 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.Context74 // - ctx context.Context
75 // - name string75 // - name string
76 // - uuid string76 // - uuid string
77-// - chunk []byte77+// - 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.Context84 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 []byte96+ 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 _c112 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 _c117 return _c
118 }118 }
M internal/boundaries/in/registry.go +1 -1
@@ -22,7 +22,7 @@ type RegistryService interface {
2222
2323 // Upload operations
2424 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)
2626 FinishUpload(ctx context.Context, uuid, digest string) error
2727 CancelUpload(ctx context.Context, uuid string) error
2828
@@ -22,7 +22,7 @@ type RegistryService interface {
22 22
23 // Upload operations23 // 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) error26 FinishUpload(ctx context.Context, uuid, digest string) error
27 CancelUpload(ctx context.Context, uuid string) error27 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 {
3838 }
3939
4040 // 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)
4343
4444 if len(ret) == 0 {
4545 panic("no return value specified for AppendBlobChunk")
@@ -47,16 +47,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, chunk []
4747
4848 var r0 int64
4949 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)
5252 }
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)
5555 } else {
5656 r0 = ret.Get(0).(int64)
5757 }
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)
6060 } else {
6161 r1 = ret.Error(1)
6262 }
@@ -71,12 +71,12 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
7171 // AppendBlobChunk is a helper method to define mock.On call
7272 // - name string
7373 // - 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)}
7777 }
7878
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 {
8080 _c.Call.Run(func(args mock.Arguments) {
8181 var arg0 string
8282 if args[0] != nil {
@@ -86,9 +86,9 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s
8686 if args[1] != nil {
8787 arg1 = args[1].(string)
8888 }
89- var arg2 []byte
89+ var arg2 io.Reader
9090 if args[2] != nil {
91- arg2 = args[2].([]byte)
91+ arg2 = args[2].(io.Reader)
9292 }
9393 run(
9494 arg0,
@@ -104,7 +104,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock
104104 return _c
105105 }
106106
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 {
108108 _c.Call.Return(run)
109109 return _c
110110 }
@@ -38,8 +38,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
38 }38 }
39 39
40 // AppendBlobChunk provides a mock function for the type MockBlobStorage40 // 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 int6448 var r0 int64
49 var r1 error49 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 call71 // AppendBlobChunk is a helper method to define mock.On call
72 // - name string72 // - name string
73 // - uuid string73 // - uuid string
74-// - chunk []byte74+// - 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 string81 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 []byte89+ 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 _c104 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 _c109 return _c
110 }110 }
M internal/boundaries/out/storage.go +1 -1
@@ -25,7 +25,7 @@ type BlobStorage interface {
2525 StartBlobUpload(name string) (string, error)
2626
2727 // 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)
2929
3030 // GetBlobUpload returns a writer for the upload.
3131 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
332332 return fmt.Errorf("route not found for domain: %s", payload.Domain)
333333 }
334334
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 {
337337 return log.WrapErr(err, "failed to deploy container")
338338 }
339339
@@ -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
33
44 import (
55 "context"
6+ "errors"
67 "fmt"
78 "maps"
89 "slices"
@@ -41,6 +42,12 @@ const (
4142 PullPolicyAlways = "always"
4243 PullPolicyIfNotPresent = "if-not-present"
4344 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
4451 )
4552
4653 // Service implements the ContainerService interface.
@@ -138,12 +145,28 @@ func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (f
138145 }
139146
140147 // 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
146165 }
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)
147170
148171 return &domain.ContainerConfig{
149172 Image: actualImageRef,
@@ -236,7 +259,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
236259 }
237260
238261 // 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)
240263
241264 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
242265 if err != nil {
@@ -313,7 +336,33 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
313336
314337 // Restart the main container
315338 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
317366 }
318367 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
319368
@@ -1003,11 +1052,15 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
10031052 if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
10041053 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
10051054 }
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 {
10071058 return log.WrapErr(err, "failed to pull image with internal auth")
10081059 }
10091060 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 {
10111064 return log.WrapErr(err, "failed to pull image")
10121065 }
10131066 case cfg.RegistryAuthEnabled:
@@ -1026,6 +1079,34 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
10261079 return nil
10271080 }
10281081
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+
10291110 func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
10301111 if sourceRef == targetRef {
10311112 return nil
@@ -1155,6 +1236,8 @@ func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string)
11551236 func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
11561237 log := zerowrap.FromCtx(ctx)
11571238 expectedName := fmt.Sprintf("gordon-%s", domainName)
1239+ expectedNewName := expectedName + "-new"
1240+ expectedNextName := expectedName + "-next"
11581241
11591242 allContainers, err := s.runtime.ListContainers(ctx, true)
11601243 if err != nil {
@@ -1162,7 +1245,7 @@ func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName stri
11621245 }
11631246
11641247 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 {
11661249 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
11671250
11681251 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
@@ -1644,6 +1727,26 @@ func sanitizeNameLegacy(d string) string {
16441727 return domain.SanitizeDomainForContainerLegacy(d)
16451728 }
16461729
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+
16471750 // findContainerByName finds a container by its name.
16481751 func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
16491752 containers, err := s.runtime.ListContainers(ctx, true)
@@ -1664,30 +1767,14 @@ func (s *Service) findContainerByName(ctx context.Context, name string) *domain.
16641767 func (s *Service) waitForReady(ctx context.Context, containerID string) error {
16651768 log := zerowrap.FromCtx(ctx)
16661769
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
16841772 }
16851773
16861774 s.mu.RLock()
16871775 cfg := s.config
16881776 s.mu.RUnlock()
16891777
1690- // Additional readiness delay (configurable, default 5 seconds)
16911778 delay := cfg.ReadinessDelay
16921779 if delay == 0 {
16931780 delay = 5 * time.Second
@@ -1705,11 +1792,61 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
17051792 if err != nil {
17061793 return err
17071794 }
1708- if !running {
1709- return fmt.Errorf("container stopped during readiness delay")
1795+ if running {
1796+ return nil
17101797 }
17111798
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")
17131850 }
17141851
17151852 // 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 configuration261 // 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 container337 // 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 nil1079 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 nil1112 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.config1775 cfg := s.config
1688 s.mu.RUnlock()1776 s.mu.RUnlock()
1689 1777
1690- // Additional readiness delay (configurable, default 5 seconds)
1691 delay := cfg.ReadinessDelay1778 delay := cfg.ReadinessDelay
1692 if delay == 0 {1779 if delay == 0 {
1693 delay = 5 * time.Second1780 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 err1793 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 nil1799+ 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) {
9696 assert.Equal(t, "container-123", tracked.ID)
9797 }
9898
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+
99158 func TestService_Deploy_ImagePullFailure(t *testing.T) {
100159 runtime := mocks.NewMockContainerRuntime(t)
101160 envLoader := mocks.NewMockEnvLoader(t)
@@ -156,6 +215,30 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
156215 assert.Contains(t, err.Error(), "registry service token not configured")
157216 }
158217
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+
159242 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
160243 runtime := mocks.NewMockContainerRuntime(t)
161244 envLoader := mocks.NewMockEnvLoader(t)
@@ -625,6 +708,65 @@ func TestService_Restart_Error(t *testing.T) {
625708 assert.Contains(t, err.Error(), "failed to restart container")
626709 }
627710
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+
628770 func TestService_Restart_WithAttachments(t *testing.T) {
629771 runtime := mocks.NewMockContainerRuntime(t)
630772 envLoader := mocks.NewMockEnvLoader(t)
@@ -1339,6 +1481,131 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
13391481 assert.Equal(t, "new-container", tracked.ID)
13401482 }
13411483
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+
13421609 func TestService_StripRegistryPrefix(t *testing.T) {
13431610 tests := []struct {
13441611 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 string1611 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)
184184 }
185185
186186 // 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) {
188188 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
189189 zerowrap.FieldLayer: "usecase",
190190 zerowrap.FieldUseCase: "AppendBlobChunk",
191191 "name": name,
192192 "uuid": uuid,
193- zerowrap.FieldSize: len(chunk),
194193 })
195194 log := zerowrap.FromCtx(ctx)
196195
197- length, err := s.blobStorage.AppendBlobChunk(name, uuid, chunk)
196+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, data)
198197 if err != nil {
199198 return 0, log.WrapErr(err, "failed to append blob chunk")
200199 }
@@ -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 }

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close