detached c016c64

Merge pull request #99 from bnema/issue-98-bootstrap-json

feat: CLI bootstrap command, idempotent routes, and --json list output
commit
c016c6460402ef65f05b58823b06dcd80b68b21c
author
bnema · · 2026-03-14 03:06 UTC
committer
GitHub · · 2026-03-14 03:06 UTC
merge of
6ccaf03 + dbf81b6 · diff against the first parent
signature
unsigned
37 files · +1892 -219
M docs/cli/attachments.md +28 -0
@@ -40,6 +40,7 @@ List all configured attachments.
4040 ```bash
4141 # List all attachments
4242 gordon attachments list
43+gordon attachments list --json
4344
4445 # List attachments for a specific domain or group
4546 gordon attachments list app.example.com
@@ -59,6 +60,33 @@ api.example.com postgres:18
5960 backend (group) rabbitmq:3-management
6061 ```
6162
63+### JSON Output
64+
65+```bash
66+gordon attachments list --json
67+```
68+
69+```json
70+[
71+ {
72+ "target": "app.example.com",
73+ "attachments": ["postgres:18", "redis:7-alpine"]
74+ },
75+ {
76+ "target": "backend",
77+ "attachments": ["rabbitmq:3-management"]
78+ }
79+]
80+```
81+
82+### Options
83+
84+| Option | Description |
85+|--------|-------------|
86+| `--json` | Output attachments as JSON |
87+| `--remote` | Remote Gordon URL |
88+| `--token` | Authentication token for remote |
89+
6290 ---
6391
6492 ## gordon attachments add
@@ -40,6 +40,7 @@ List all configured attachments.
40 ```bash40 ```bash
41 # List all attachments41 # List all attachments
42 gordon attachments list42 gordon attachments list
43+gordon attachments list --json
43 44
44 # List attachments for a specific domain or group45 # List attachments for a specific domain or group
45 gordon attachments list app.example.com46 gordon attachments list app.example.com
@@ -59,6 +60,33 @@ api.example.com postgres:18
59 backend (group) rabbitmq:3-management60 backend (group) rabbitmq:3-management
60 ```61 ```
61 62
63+### JSON Output
64+
65+```bash
66+gordon attachments list --json
67+```
68+
69+```json
70+[
71+ {
72+ "target": "app.example.com",
73+ "attachments": ["postgres:18", "redis:7-alpine"]
74+ },
75+ {
76+ "target": "backend",
77+ "attachments": ["rabbitmq:3-management"]
78+ }
79+]
80+```
81+
82+### Options
83+
84+| Option | Description |
85+|--------|-------------|
86+| `--json` | Output attachments as JSON |
87+| `--remote` | Remote Gordon URL |
88+| `--token` | Authentication token for remote |
89+
62 ---90 ---
63 91
64 ## gordon attachments add92 ## gordon attachments add
M docs/cli/backup.md +21 -0
@@ -29,6 +29,7 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co
2929 ```bash
3030 # List all backups
3131 gordon backups list
32+gordon backups list --json
3233
3334 # List backups for one domain
3435 gordon backups list app.example.com
@@ -46,6 +47,26 @@ gordon backups detect app.example.com
4647 gordon backups status
4748 ```
4849
50+## JSON Output
51+
52+`gordon backups list` supports `--json` for machine-readable output.
53+
54+```bash
55+gordon backups list --json
56+```
57+
58+```json
59+[
60+ {
61+ "domain": "app.example.com",
62+ "db": "postgres",
63+ "status": "completed",
64+ "started_at": "2026-03-13T09:45:00Z",
65+ "backup_id": "backup_20260313_094500"
66+ }
67+]
68+```
69+
4970 ## Output Columns
5071
5172 ### list
@@ -29,6 +29,7 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co
29 ```bash29 ```bash
30 # List all backups30 # List all backups
31 gordon backups list31 gordon backups list
32+gordon backups list --json
32 33
33 # List backups for one domain34 # List backups for one domain
34 gordon backups list app.example.com35 gordon backups list app.example.com
@@ -46,6 +47,26 @@ gordon backups detect app.example.com
46 gordon backups status47 gordon backups status
47 ```48 ```
48 49
50+## JSON Output
51+
52+`gordon backups list` supports `--json` for machine-readable output.
53+
54+```bash
55+gordon backups list --json
56+```
57+
58+```json
59+[
60+ {
61+ "domain": "app.example.com",
62+ "db": "postgres",
63+ "status": "completed",
64+ "started_at": "2026-03-13T09:45:00Z",
65+ "backup_id": "backup_20260313_094500"
66+ }
67+]
68+```
69+
49 ## Output Columns70 ## Output Columns
50 71
51 ### list72 ### list
A docs/cli/bootstrap.md +72 -0
@@ -0,0 +1,72 @@
1+# Bootstrap Command
2+
3+Create or update route configuration, attachments, and secrets in one command.
4+
5+## gordon bootstrap
6+
7+### Synopsis
8+
9+```bash
10+gordon bootstrap <domain> <image> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to create or update |
18+| `<image>` | The image to assign to the route |
19+
20+### Options
21+
22+| Option | Description |
23+|--------|-------------|
24+| `--attachment` | Add an attachment to the route (repeatable) |
25+| `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) |
26+| `--remote` | Remote Gordon URL |
27+| `--token` | Authentication token for remote |
28+
29+### Description
30+
31+`gordon bootstrap` is the recommended first-step setup workflow.
32+
33+- Creates the route when it does not exist.
34+- Updates the route when it already exists.
35+- Can attach services and set environment variables as part of the same command.
36+- Does not push or deploy the image.
37+
38+Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image.
39+
40+### Examples
41+
42+```bash
43+# First-time route setup
44+gordon bootstrap app.example.com myapp:latest
45+
46+# Then push and deploy the image
47+gordon push app.example.com --build --no-confirm
48+
49+# First-time route setup with a database attachment and environment variable
50+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
51+
52+# Multiple attachments and environment variables
53+gordon bootstrap app.example.com myapp:latest \
54+ --attachment postgres:18 \
55+ --attachment redis:7-alpine \
56+ --env APP_ENV=production \
57+ --env LOG_LEVEL=info
58+
59+# Remote target
60+gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
61+```
62+
63+### Notes
64+
65+- `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing.
66+- Run `gordon push` after bootstrap to upload and deploy the image.
67+
68+## Related
69+
70+- [CLI Overview](./index.md)
71+- [Routes Commands](./routes.md)
72+- [Push Command](./push.md)
@@ -0,0 +1,72 @@
1+# Bootstrap Command
2+
3+Create or update route configuration, attachments, and secrets in one command.
4+
5+## gordon bootstrap
6+
7+### Synopsis
8+
9+```bash
10+gordon bootstrap <domain> <image> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to create or update |
18+| `<image>` | The image to assign to the route |
19+
20+### Options
21+
22+| Option | Description |
23+|--------|-------------|
24+| `--attachment` | Add an attachment to the route (repeatable) |
25+| `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) |
26+| `--remote` | Remote Gordon URL |
27+| `--token` | Authentication token for remote |
28+
29+### Description
30+
31+`gordon bootstrap` is the recommended first-step setup workflow.
32+
33+- Creates the route when it does not exist.
34+- Updates the route when it already exists.
35+- Can attach services and set environment variables as part of the same command.
36+- Does not push or deploy the image.
37+
38+Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image.
39+
40+### Examples
41+
42+```bash
43+# First-time route setup
44+gordon bootstrap app.example.com myapp:latest
45+
46+# Then push and deploy the image
47+gordon push app.example.com --build --no-confirm
48+
49+# First-time route setup with a database attachment and environment variable
50+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
51+
52+# Multiple attachments and environment variables
53+gordon bootstrap app.example.com myapp:latest \
54+ --attachment postgres:18 \
55+ --attachment redis:7-alpine \
56+ --env APP_ENV=production \
57+ --env LOG_LEVEL=info
58+
59+# Remote target
60+gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
61+```
62+
63+### Notes
64+
65+- `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing.
66+- Run `gordon push` after bootstrap to upload and deploy the image.
67+
68+## Related
69+
70+- [CLI Overview](./index.md)
71+- [Routes Commands](./routes.md)
72+- [Push Command](./push.md)
M docs/cli/images.md +26 -0
@@ -19,11 +19,37 @@ Subcommands:
1919
2020 ```bash
2121 gordon images list
22+gordon images list --json
2223 ```
2324
2425 Shows image rows with repository, tag, size, creation time, image id, and dangling status.
2526 Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`.
2627
28+Flags:
29+
30+| Flag | Description |
31+|------|-------------|
32+| `--json` | Output images as JSON |
33+
34+### JSON Output
35+
36+```bash
37+gordon images list --json
38+```
39+
40+```json
41+[
42+ {
43+ "repository": "myapp",
44+ "tag": "latest",
45+ "size": "148MB",
46+ "created_at": "2026-03-13T10:15:00Z",
47+ "image_id": "sha256:abc123def456",
48+ "dangling": false
49+ }
50+]
51+```
52+
2753 ## gordon images prune
2854
2955 ```bash
@@ -19,11 +19,37 @@ Subcommands:
19 19
20 ```bash20 ```bash
21 gordon images list21 gordon images list
22+gordon images list --json
22 ```23 ```
23 24
24 Shows image rows with repository, tag, size, creation time, image id, and dangling status.25 Shows image rows with repository, tag, size, creation time, image id, and dangling status.
25 Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`.26 Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`.
26 27
28+Flags:
29+
30+| Flag | Description |
31+|------|-------------|
32+| `--json` | Output images as JSON |
33+
34+### JSON Output
35+
36+```bash
37+gordon images list --json
38+```
39+
40+```json
41+[
42+ {
43+ "repository": "myapp",
44+ "tag": "latest",
45+ "size": "148MB",
46+ "created_at": "2026-03-13T10:15:00Z",
47+ "image_id": "sha256:abc123def456",
48+ "dangling": false
49+ }
50+]
51+```
52+
27 ## gordon images prune53 ## gordon images prune
28 54
29 ```bash55 ```bash
M docs/cli/index.md +9 -0
@@ -2,6 +2,8 @@
22
33 Gordon provides a command-line interface for server management, deployment, and authentication.
44
5+Most `list` commands also support `--json` for machine-readable output.
6+
57 Commands are organized by where they run:
68
79 - **Server Commands** - Must run on the machine hosting Gordon
@@ -26,6 +28,7 @@ Management commands run locally through in-process services by default. Add `--r
2628 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
2729 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
2830 | `gordon restart` | Restart a running container | [restart](./restart.md) |
31+| `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |
2932 | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
3033 | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
3134 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
@@ -58,6 +61,12 @@ gordon deploy myapp.example.com
5861 # Restart a running container
5962 gordon restart myapp.example.com
6063
64+# First-time route setup
65+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
66+
67+# Then push and deploy
68+gordon push app.example.com --build --no-confirm
69+
6170 # Push an image and deploy
6271 gordon push myapp.example.com --build
6372
@@ -2,6 +2,8 @@
2 2
3 Gordon provides a command-line interface for server management, deployment, and authentication.3 Gordon provides a command-line interface for server management, deployment, and authentication.
4 4
5+Most `list` commands also support `--json` for machine-readable output.
6+
5 Commands are organized by where they run:7 Commands are organized by where they run:
6 8
7 - **Server Commands** - Must run on the machine hosting Gordon9 - **Server Commands** - Must run on the machine hosting Gordon
@@ -26,6 +28,7 @@ Management commands run locally through in-process services by default. Add `--r
26 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |28 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
27 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |29 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
28 | `gordon restart` | Restart a running container | [restart](./restart.md) |30 | `gordon restart` | Restart a running container | [restart](./restart.md) |
31+| `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) |
29 | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |32 | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
30 | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |33 | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
31 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |34 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
@@ -58,6 +61,12 @@ gordon deploy myapp.example.com
58 # Restart a running container61 # Restart a running container
59 gordon restart myapp.example.com62 gordon restart myapp.example.com
60 63
64+# First-time route setup
65+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
66+
67+# Then push and deploy
68+gordon push app.example.com --build --no-confirm
69+
61 # Push an image and deploy70 # Push an image and deploy
62 gordon push myapp.example.com --build71 gordon push myapp.example.com --build
63 72
M docs/cli/push.md +2 -0
@@ -35,6 +35,7 @@ gordon push <domain> [options]
3535 `gordon push` resolves the route image for `<domain>`, tags the image for the
3636 Gordon registry, pushes it, and optionally deploys it.
3737
38+- For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.
3839 - The registry and repository are derived from the route image on the server.
3940 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
4041 then falls back to `git describe --tags --dirty` (for example
@@ -66,6 +67,7 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
6667 ### Notes
6768
6869 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
70+- `gordon push` still requires the route to already exist so it can resolve the target image.
6971 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
7072 install the `docker-buildx-plugin` package.
7173
@@ -35,6 +35,7 @@ gordon push <domain> [options]
35 `gordon push` resolves the route image for `<domain>`, tags the image for the35 `gordon push` resolves the route image for `<domain>`, tags the image for the
36 Gordon registry, pushes it, and optionally deploys it.36 Gordon registry, pushes it, and optionally deploys it.
37 37
38+- For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image.
38 - The registry and repository are derived from the route image on the server.39 - The registry and repository are derived from the route image on the server.
39 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,40 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
40 then falls back to `git describe --tags --dirty` (for example41 then falls back to `git describe --tags --dirty` (for example
@@ -66,6 +67,7 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile
66 ### Notes67 ### Notes
67 68
68 - Remote mode required. See [CLI Overview](./index.md) for targeting options.69 - Remote mode required. See [CLI Overview](./index.md) for targeting options.
70+- `gordon push` still requires the route to already exist so it can resolve the target image.
69 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,71 - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
70 install the `docker-buildx-plugin` package.72 install the `docker-buildx-plugin` package.
71 73
M docs/cli/routes.md +43 -2
@@ -12,7 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1212 | Subcommand | Description |
1313 |------------|-------------|
1414 | `list` | List all routes |
15-| `add` | Add a new route |
15+| `add` | Create or update a route |
1616 | `remove` | Remove a route |
1717 | `deploy` | Deploy a specific route |
1818
@@ -24,6 +24,7 @@ List all configured routes.
2424
2525 ```bash
2626 gordon routes list
27+gordon routes list --json
2728 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
2829 ```
2930
@@ -37,17 +38,49 @@ api.example.com myapi:v2.1.0 running
3738 admin.example.com admin-panel:latest stopped
3839 ```
3940
41+### JSON Output
42+
43+```bash
44+gordon routes list --json
45+```
46+
47+```json
48+[
49+ {
50+ "domain": "app.example.com",
51+ "image": "myapp:latest",
52+ "status": "running"
53+ },
54+ {
55+ "domain": "api.example.com",
56+ "image": "myapi:v2.1.0",
57+ "status": "running"
58+ }
59+]
60+```
61+
62+### Options
63+
64+| Option | Description |
65+|--------|-------------|
66+| `--json` | Output routes as JSON |
67+| `--remote` | Remote Gordon URL |
68+| `--token` | Authentication token for remote |
69+
4070 ---
4171
4272 ## gordon routes add
4373
44-Add a new route.
74+Create a new route or update an existing route.
4575
4676 ```bash
4777 gordon routes add <domain> <image>
4878 gordon routes add myapp.example.com myapp:latest
4979 ```
5080
81+If the route already exists, Gordon updates it to the new image instead of failing.
82+The image does not need to be pushed to the Gordon registry before you add the route.
83+
5184 ### Arguments
5285
5386 | Argument | Description |
@@ -69,10 +102,18 @@ gordon routes add myapp.example.com myapp:latest
69102 gordon routes add myapp.example.com myapp:latest
70103 gordon routes add api.example.com myapi:v2.1.0
71104
105+# Update an existing route
106+gordon routes add myapp.example.com myapp:v2
107+
72108 # Remote (override)
73109 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
74110 ```
75111
112+### Notes
113+
114+- `gordon routes add` is idempotent: it creates the route when missing and updates it when present.
115+- You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image.
116+
76117 ---
77118
78119 ## gordon routes remove
@@ -12,7 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
12 | Subcommand | Description |12 | Subcommand | Description |
13 |------------|-------------|13 |------------|-------------|
14 | `list` | List all routes |14 | `list` | List all routes |
15-| `add` | Add a new route |15+| `add` | Create or update a route |
16 | `remove` | Remove a route |16 | `remove` | Remove a route |
17 | `deploy` | Deploy a specific route |17 | `deploy` | Deploy a specific route |
18 18
@@ -24,6 +24,7 @@ List all configured routes.
24 24
25 ```bash25 ```bash
26 gordon routes list26 gordon routes list
27+gordon routes list --json
27 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN28 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
28 ```29 ```
29 30
@@ -37,17 +38,49 @@ api.example.com myapi:v2.1.0 running
37 admin.example.com admin-panel:latest stopped38 admin.example.com admin-panel:latest stopped
38 ```39 ```
39 40
41+### JSON Output
42+
43+```bash
44+gordon routes list --json
45+```
46+
47+```json
48+[
49+ {
50+ "domain": "app.example.com",
51+ "image": "myapp:latest",
52+ "status": "running"
53+ },
54+ {
55+ "domain": "api.example.com",
56+ "image": "myapi:v2.1.0",
57+ "status": "running"
58+ }
59+]
60+```
61+
62+### Options
63+
64+| Option | Description |
65+|--------|-------------|
66+| `--json` | Output routes as JSON |
67+| `--remote` | Remote Gordon URL |
68+| `--token` | Authentication token for remote |
69+
40 ---70 ---
41 71
42 ## gordon routes add72 ## gordon routes add
43 73
44-Add a new route.74+Create a new route or update an existing route.
45 75
46 ```bash76 ```bash
47 gordon routes add <domain> <image>77 gordon routes add <domain> <image>
48 gordon routes add myapp.example.com myapp:latest78 gordon routes add myapp.example.com myapp:latest
49 ```79 ```
50 80
81+If the route already exists, Gordon updates it to the new image instead of failing.
82+The image does not need to be pushed to the Gordon registry before you add the route.
83+
51 ### Arguments84 ### Arguments
52 85
53 | Argument | Description |86 | Argument | Description |
@@ -69,10 +102,18 @@ gordon routes add myapp.example.com myapp:latest
69 gordon routes add myapp.example.com myapp:latest102 gordon routes add myapp.example.com myapp:latest
70 gordon routes add api.example.com myapi:v2.1.0103 gordon routes add api.example.com myapi:v2.1.0
71 104
105+# Update an existing route
106+gordon routes add myapp.example.com myapp:v2
107+
72 # Remote (override)108 # Remote (override)
73 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN109 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
74 ```110 ```
75 111
112+### Notes
113+
114+- `gordon routes add` is idempotent: it creates the route when missing and updates it when present.
115+- You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image.
116+
76 ---117 ---
77 118
78 ## gordon routes remove119 ## gordon routes remove
M docs/cli/secrets.md +26 -0
@@ -39,6 +39,7 @@ gordon secrets list <domain>
3939
4040 | Option | Description |
4141 |--------|-------------|
42+| `--json` | Output secrets as JSON |
4243 | `--remote` | Remote Gordon URL |
4344 | `--token` | Authentication token for remote |
4445
@@ -67,6 +68,31 @@ API_KEY ****
6768 └─ REDIS_PASSWORD ****
6869 ```
6970
71+### JSON Output
72+
73+```bash
74+gordon secrets list app.mydomain.com --json
75+```
76+
77+```json
78+{
79+ "domain": "app.mydomain.com",
80+ "secrets": {
81+ "DATABASE_URL": "****",
82+ "API_KEY": "****"
83+ },
84+ "attachments": {
85+ "postgres": {
86+ "POSTGRES_USER": "****",
87+ "POSTGRES_PASSWORD": "****"
88+ },
89+ "redis": {
90+ "REDIS_PASSWORD": "****"
91+ }
92+ }
93+}
94+```
95+
7096 ---
7197
7298 ## gordon secrets set
@@ -39,6 +39,7 @@ gordon secrets list <domain>
39 39
40 | Option | Description |40 | Option | Description |
41 |--------|-------------|41 |--------|-------------|
42+| `--json` | Output secrets as JSON |
42 | `--remote` | Remote Gordon URL |43 | `--remote` | Remote Gordon URL |
43 | `--token` | Authentication token for remote |44 | `--token` | Authentication token for remote |
44 45
@@ -67,6 +68,31 @@ API_KEY ****
67 └─ REDIS_PASSWORD ****68 └─ REDIS_PASSWORD ****
68 ```69 ```
69 70
71+### JSON Output
72+
73+```bash
74+gordon secrets list app.mydomain.com --json
75+```
76+
77+```json
78+{
79+ "domain": "app.mydomain.com",
80+ "secrets": {
81+ "DATABASE_URL": "****",
82+ "API_KEY": "****"
83+ },
84+ "attachments": {
85+ "postgres": {
86+ "POSTGRES_USER": "****",
87+ "POSTGRES_PASSWORD": "****"
88+ },
89+ "redis": {
90+ "REDIS_PASSWORD": "****"
91+ }
92+ }
93+}
94+```
95+
70 ---96 ---
71 97
72 ## gordon secrets set98 ## gordon secrets set
M docs/getting-started.md +19 -9
@@ -143,19 +143,29 @@ On your local machine:
143143 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
144144 gordon remotes use prod
145145
146-# Build, push, and deploy through Gordon
147-gordon push myapp --build --no-confirm
146+# Recommended first-time setup
147+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
148+
149+# Then build, push, and deploy
150+gordon push app.example.com --build --no-confirm
148151 ```
149152
150153 What this command does:
151154
152-- `gordon push myapp` resolves the route for `myapp`, chooses a version tag,
153- pushes image tags to Gordon's registry, and triggers deploy.
154-- `--build` runs `docker buildx build` first, injecting `VERSION` from the tag
155- into the build and then pushing both version and `latest` tags. This requires
156- Docker with Buildx; a Podman-only setup is not supported for `--build`.
157-- `--no-confirm` skips the interactive "Deploy now?" prompt so the deploy runs
158- immediately.
155+- `gordon bootstrap` creates or updates the route, applies requested attachments,
156+ and stores environment variables.
157+- This is the recommended path for first deploys because it does not require the
158+ route to exist ahead of time.
159+- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.
160+
161+If the route already exists and you only need to push a new image version, use:
162+
163+```bash
164+gordon push app.example.com --build --no-confirm
165+```
166+
167+`gordon push` still requires the route to already exist so it can resolve the
168+target image.
159169
160170 Your app is now live at `https://app.mydomain.com`!
161171
@@ -143,19 +143,29 @@ On your local machine:
143 gordon remotes add prod https://gordon.mydomain.com --token <your-token>143 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
144 gordon remotes use prod144 gordon remotes use prod
145 145
146-# Build, push, and deploy through Gordon146+# Recommended first-time setup
147-gordon push myapp --build --no-confirm147+gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
148+
149+# Then build, push, and deploy
150+gordon push app.example.com --build --no-confirm
148 ```151 ```
149 152
150 What this command does:153 What this command does:
151 154
152-- `gordon push myapp` resolves the route for `myapp`, chooses a version tag,155+- `gordon bootstrap` creates or updates the route, applies requested attachments,
153- pushes image tags to Gordon's registry, and triggers deploy.156+ and stores environment variables.
154-- `--build` runs `docker buildx build` first, injecting `VERSION` from the tag157+- This is the recommended path for first deploys because it does not require the
155- into the build and then pushing both version and `latest` tags. This requires158+ route to exist ahead of time.
156- Docker with Buildx; a Podman-only setup is not supported for `--build`.159+- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.
157-- `--no-confirm` skips the interactive "Deploy now?" prompt so the deploy runs160+
158- immediately.161+If the route already exists and you only need to push a new image version, use:
162+
163+```bash
164+gordon push app.example.com --build --no-confirm
165+```
166+
167+`gordon push` still requires the route to already exist so it can resolve the
168+target image.
159 169
160 Your app is now live at `https://app.mydomain.com`!170 Your app is now live at `https://app.mydomain.com`!
161 171
A internal/adapters/dto/admin_bootstrap.go +25 -0
@@ -0,0 +1,25 @@
1+package dto
2+
3+// BootstrapRequest represents a bootstrap operation request.
4+type BootstrapRequest struct {
5+ Domain string `json:"domain"`
6+ Image string `json:"image"`
7+ Attachments []string `json:"attachments,omitempty"`
8+ Env map[string]string `json:"env,omitempty"`
9+ AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"`
10+}
11+
12+// BootstrapStep represents the result of one bootstrap step.
13+type BootstrapStep struct {
14+ Name string `json:"name"`
15+ Status string `json:"status"` // "created", "configured", "updated", "noop", "failed"
16+}
17+
18+// BootstrapResponse represents the result of a bootstrap operation.
19+type BootstrapResponse struct {
20+ Domain string `json:"domain"`
21+ Image string `json:"image"`
22+ Steps []BootstrapStep `json:"steps"`
23+ Warnings []string `json:"warnings,omitempty"`
24+ Next string `json:"next"`
25+}
@@ -0,0 +1,25 @@
1+package dto
2+
3+// BootstrapRequest represents a bootstrap operation request.
4+type BootstrapRequest struct {
5+ Domain string `json:"domain"`
6+ Image string `json:"image"`
7+ Attachments []string `json:"attachments,omitempty"`
8+ Env map[string]string `json:"env,omitempty"`
9+ AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"`
10+}
11+
12+// BootstrapStep represents the result of one bootstrap step.
13+type BootstrapStep struct {
14+ Name string `json:"name"`
15+ Status string `json:"status"` // "created", "configured", "updated", "noop", "failed"
16+}
17+
18+// BootstrapResponse represents the result of a bootstrap operation.
19+type BootstrapResponse struct {
20+ Domain string `json:"domain"`
21+ Image string `json:"image"`
22+ Steps []BootstrapStep `json:"steps"`
23+ Warnings []string `json:"warnings,omitempty"`
24+ Next string `json:"next"`
25+}
M internal/adapters/in/cli/attachments.go +28 -5
@@ -3,6 +3,7 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "sort"
78
89 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -37,7 +38,9 @@ the local Gordon configuration.`,
3738
3839 // newAttachmentsListCmd creates the attachments list command.
3940 func newAttachmentsListCmd() *cobra.Command {
40- return &cobra.Command{
41+ var jsonOut bool
42+
43+ cmd := &cobra.Command{
4144 Use: "list [domain-or-group]",
4245 Short: "List configured attachments",
4346 Long: `List all configured attachments, or attachments for a specific domain/group.
@@ -56,15 +59,19 @@ Examples:
5659
5760 client, isRemote := GetRemoteClient()
5861 if isRemote {
59- return runAttachmentsListRemote(ctx, client, target)
62+ return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
6063 }
61- return runAttachmentsListLocal(ctx, configPath, target)
64+ return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
6265 },
6366 }
67+
68+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
69+
70+ return cmd
6471 }
6572
6673 // runAttachmentsListRemote lists attachments from a remote Gordon instance.
67-func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {
74+func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
6875 if target != "" {
6976 // List for specific target
7077 images, err := client.GetAttachmentsConfig(ctx, target)
@@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
7784 return nil
7885 }
7986
87+ if jsonOut {
88+ return writeJSON(out, map[string]any{"target": target, "images": images})
89+ }
90+
8091 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
8192 fmt.Println()
8293 for _, img := range images {
@@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
96107 return nil
97108 }
98109
110+ if jsonOut {
111+ return writeJSON(out, map[string]any{"attachments": attachments})
112+ }
113+
99114 // Sort targets for consistent output
100115 targets := make([]string, 0, len(attachments))
101116 for t := range attachments {
@@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
118133 }
119134
120135 // runAttachmentsListLocal lists attachments from local configuration.
121-func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {
136+func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
122137 local, err := GetLocalServices(cfgPath)
123138 if err != nil {
124139 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
136151 return nil
137152 }
138153
154+ if jsonOut {
155+ return writeJSON(out, map[string]any{"target": target, "images": images})
156+ }
157+
139158 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140159 fmt.Println()
141160 for _, img := range images {
@@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
152171 return nil
153172 }
154173
174+ if jsonOut {
175+ return writeJSON(out, map[string]any{"attachments": attachments})
176+ }
177+
155178 // Sort targets for consistent output
156179 targets := make([]string, 0, len(attachments))
157180 for t := range attachments {
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 "sort"7 "sort"
7 8
8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -37,7 +38,9 @@ the local Gordon configuration.`,
37 38
38 // newAttachmentsListCmd creates the attachments list command.39 // newAttachmentsListCmd creates the attachments list command.
39 func newAttachmentsListCmd() *cobra.Command {40 func newAttachmentsListCmd() *cobra.Command {
40- return &cobra.Command{41+ var jsonOut bool
42+
43+ cmd := &cobra.Command{
41 Use: "list [domain-or-group]",44 Use: "list [domain-or-group]",
42 Short: "List configured attachments",45 Short: "List configured attachments",
43 Long: `List all configured attachments, or attachments for a specific domain/group.46 Long: `List all configured attachments, or attachments for a specific domain/group.
@@ -56,15 +59,19 @@ Examples:
56 59
57 client, isRemote := GetRemoteClient()60 client, isRemote := GetRemoteClient()
58 if isRemote {61 if isRemote {
59- return runAttachmentsListRemote(ctx, client, target)62+ return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout())
60 }63 }
61- return runAttachmentsListLocal(ctx, configPath, target)64+ return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout())
62 },65 },
63 }66 }
67+
68+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
69+
70+ return cmd
64 }71 }
65 72
66 // runAttachmentsListRemote lists attachments from a remote Gordon instance.73 // runAttachmentsListRemote lists attachments from a remote Gordon instance.
67-func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error {74+func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error {
68 if target != "" {75 if target != "" {
69 // List for specific target76 // List for specific target
70 images, err := client.GetAttachmentsConfig(ctx, target)77 images, err := client.GetAttachmentsConfig(ctx, target)
@@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
77 return nil84 return nil
78 }85 }
79 86
87+ if jsonOut {
88+ return writeJSON(out, map[string]any{"target": target, "images": images})
89+ }
90+
80 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))91 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target)))
81 fmt.Println()92 fmt.Println()
82 for _, img := range images {93 for _, img := range images {
@@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
96 return nil107 return nil
97 }108 }
98 109
110+ if jsonOut {
111+ return writeJSON(out, map[string]any{"attachments": attachments})
112+ }
113+
99 // Sort targets for consistent output114 // Sort targets for consistent output
100 targets := make([]string, 0, len(attachments))115 targets := make([]string, 0, len(attachments))
101 for t := range attachments {116 for t := range attachments {
@@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target
118 }133 }
119 134
120 // runAttachmentsListLocal lists attachments from local configuration.135 // runAttachmentsListLocal lists attachments from local configuration.
121-func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error {136+func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error {
122 local, err := GetLocalServices(cfgPath)137 local, err := GetLocalServices(cfgPath)
123 if err != nil {138 if err != nil {
124 return fmt.Errorf("failed to initialize local services: %w", err)139 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
136 return nil151 return nil
137 }152 }
138 153
154+ if jsonOut {
155+ return writeJSON(out, map[string]any{"target": target, "images": images})
156+ }
157+
139 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))158 fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target)))
140 fmt.Println()159 fmt.Println()
141 for _, img := range images {160 for _, img := range images {
@@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string)
152 return nil171 return nil
153 }172 }
154 173
174+ if jsonOut {
175+ return writeJSON(out, map[string]any{"attachments": attachments})
176+ }
177+
155 // Sort targets for consistent output178 // Sort targets for consistent output
156 targets := make([]string, 0, len(attachments))179 targets := make([]string, 0, len(attachments))
157 for t := range attachments {180 for t := range attachments {
M internal/adapters/in/cli/auth.go +30 -3
@@ -4,6 +4,7 @@ import (
44 "bufio"
55 "context"
66 "fmt"
7+ "io"
78 "os"
89 "strings"
910 "time"
@@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
130131
131132 // newTokenListCmd creates the token list command.
132133 func newTokenListCmd() *cobra.Command {
133- var configPath string
134+ var (
135+ configPath string
136+ jsonOut bool
137+ )
134138
135139 cmd := &cobra.Command{
136140 Use: "list",
137141 Short: "List all authentication tokens",
138142 Long: `List all stored authentication tokens with their subjects and expiry information.`,
139143 RunE: func(cmd *cobra.Command, args []string) error {
140- return runTokenList(configPath)
144+ return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
141145 },
142146 }
143147
144148 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
149+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
145150
146151 return cmd
147152 }
@@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
514519 }
515520
516521 // runTokenList lists all stored tokens.
517-func runTokenList(configPath string) error {
522+func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
518523 cfg, err := loadAuthConfig(configPath)
519524 if err != nil {
520525 return err
@@ -533,10 +538,32 @@ func runTokenList(configPath string) error {
533538 }
534539
535540 if len(tokens) == 0 {
541+ if jsonOut {
542+ return writeJSON(out, []map[string]any{})
543+ }
536544 fmt.Println("No tokens found.")
537545 return nil
538546 }
539547
548+ if jsonOut {
549+ payload := make([]map[string]any, 0, len(tokens))
550+ for _, t := range tokens {
551+ var expiresAt *time.Time
552+ if !t.ExpiresAt.IsZero() {
553+ expiresAt = &t.ExpiresAt
554+ }
555+ payload = append(payload, map[string]any{
556+ "id": t.ID,
557+ "description": t.Subject,
558+ "scopes": t.Scopes,
559+ "created_at": t.IssuedAt,
560+ "expires_at": expiresAt,
561+ "revoked": t.Revoked,
562+ })
563+ }
564+ return writeJSON(out, payload)
565+ }
566+
540567 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
541568 fmt.Println(strings.Repeat("-", 90))
542569
@@ -4,6 +4,7 @@ import (
4 "bufio"4 "bufio"
5 "context"5 "context"
6 "fmt"6 "fmt"
7+ "io"
7 "os"8 "os"
8 "strings"9 "strings"
9 "time"10 "time"
@@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`,
130 131
131 // newTokenListCmd creates the token list command.132 // newTokenListCmd creates the token list command.
132 func newTokenListCmd() *cobra.Command {133 func newTokenListCmd() *cobra.Command {
133- var configPath string134+ var (
135+ configPath string
136+ jsonOut bool
137+ )
134 138
135 cmd := &cobra.Command{139 cmd := &cobra.Command{
136 Use: "list",140 Use: "list",
137 Short: "List all authentication tokens",141 Short: "List all authentication tokens",
138 Long: `List all stored authentication tokens with their subjects and expiry information.`,142 Long: `List all stored authentication tokens with their subjects and expiry information.`,
139 RunE: func(cmd *cobra.Command, args []string) error {143 RunE: func(cmd *cobra.Command, args []string) error {
140- return runTokenList(configPath)144+ return runTokenList(configPath, cmd.OutOrStdout(), jsonOut)
141 },145 },
142 }146 }
143 147
144 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")148 cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
149+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
145 150
146 return cmd151 return cmd
147 }152 }
@@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error {
514 }519 }
515 520
516 // runTokenList lists all stored tokens.521 // runTokenList lists all stored tokens.
517-func runTokenList(configPath string) error {522+func runTokenList(configPath string, out io.Writer, jsonOut bool) error {
518 cfg, err := loadAuthConfig(configPath)523 cfg, err := loadAuthConfig(configPath)
519 if err != nil {524 if err != nil {
520 return err525 return err
@@ -533,10 +538,32 @@ func runTokenList(configPath string) error {
533 }538 }
534 539
535 if len(tokens) == 0 {540 if len(tokens) == 0 {
541+ if jsonOut {
542+ return writeJSON(out, []map[string]any{})
543+ }
536 fmt.Println("No tokens found.")544 fmt.Println("No tokens found.")
537 return nil545 return nil
538 }546 }
539 547
548+ if jsonOut {
549+ payload := make([]map[string]any, 0, len(tokens))
550+ for _, t := range tokens {
551+ var expiresAt *time.Time
552+ if !t.ExpiresAt.IsZero() {
553+ expiresAt = &t.ExpiresAt
554+ }
555+ payload = append(payload, map[string]any{
556+ "id": t.ID,
557+ "description": t.Subject,
558+ "scopes": t.Scopes,
559+ "created_at": t.IssuedAt,
560+ "expires_at": expiresAt,
561+ "revoked": t.Revoked,
562+ })
563+ }
564+ return writeJSON(out, payload)
565+ }
566+
540 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")567 fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked")
541 fmt.Println(strings.Repeat("-", 90))568 fmt.Println(strings.Repeat("-", 90))
542 569
M internal/adapters/in/cli/backup.go +38 -24
@@ -2,10 +2,12 @@ package cli
22
33 import (
44 "fmt"
5+ "io"
56 "os"
67 "text/tabwriter"
78 "time"
89
10+ "github.com/bnema/gordon/internal/adapters/dto"
911 "github.com/spf13/cobra"
1012 )
1113
@@ -30,9 +32,12 @@ instance when --remote targeting is configured.`,
3032 }
3133
3234 func newBackupListCmd() *cobra.Command {
33- return &cobra.Command{
35+ var jsonOut bool
36+
37+ cmd := &cobra.Command{
3438 Use: "list [domain]",
3539 Short: "List backups",
40+ Long: cliRenderMuted("List backups for all domains or a specific domain."),
3641 Args: cobra.MaximumNArgs(1),
3742 RunE: func(cmd *cobra.Command, args []string) error {
3843 handle, err := resolveControlPlane(configPath)
@@ -51,32 +56,41 @@ func newBackupListCmd() *cobra.Command {
5156 return fmt.Errorf("failed to list backups: %w", err)
5257 }
5358
54- if len(jobs) == 0 {
55- if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {
56- return err
57- }
58- return nil
59- }
60- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61- return err
62- }
63- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
64- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
65- return err
66- }
59+ return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
60+ },
61+ }
6762
68- for _, job := range jobs {
69- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
70- return err
71- }
72- }
73- if err := w.Flush(); err != nil {
74- return err
75- }
63+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
7664
77- return nil
78- },
65+ return cmd
66+}
67+
68+func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
69+ if len(jobs) == 0 {
70+ if jsonOut {
71+ return writeJSON(out, []dto.BackupJob{})
72+ }
73+ return cliWriteLine(out, cliRenderMuted("No backups found"))
74+ }
75+
76+ if jsonOut {
77+ return writeJSON(out, jobs)
78+ }
79+
80+ if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil {
81+ return err
82+ }
83+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
84+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
85+ return err
86+ }
87+
88+ for _, job := range jobs {
89+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
90+ return err
91+ }
7992 }
93+ return w.Flush()
8094 }
8195
8296 func newBackupRunCmd() *cobra.Command {
@@ -2,10 +2,12 @@ package cli
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "io"
5 "os"6 "os"
6 "text/tabwriter"7 "text/tabwriter"
7 "time"8 "time"
8 9
10+ "github.com/bnema/gordon/internal/adapters/dto"
9 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
10 )12 )
11 13
@@ -30,9 +32,12 @@ instance when --remote targeting is configured.`,
30 }32 }
31 33
32 func newBackupListCmd() *cobra.Command {34 func newBackupListCmd() *cobra.Command {
33- return &cobra.Command{35+ var jsonOut bool
36+
37+ cmd := &cobra.Command{
34 Use: "list [domain]",38 Use: "list [domain]",
35 Short: "List backups",39 Short: "List backups",
40+ Long: cliRenderMuted("List backups for all domains or a specific domain."),
36 Args: cobra.MaximumNArgs(1),41 Args: cobra.MaximumNArgs(1),
37 RunE: func(cmd *cobra.Command, args []string) error {42 RunE: func(cmd *cobra.Command, args []string) error {
38 handle, err := resolveControlPlane(configPath)43 handle, err := resolveControlPlane(configPath)
@@ -51,32 +56,41 @@ func newBackupListCmd() *cobra.Command {
51 return fmt.Errorf("failed to list backups: %w", err)56 return fmt.Errorf("failed to list backups: %w", err)
52 }57 }
53 58
54- if len(jobs) == 0 {59+ return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
55- if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil {60+ },
56- return err61+ }
57- }
58- return nil
59- }
60- if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil {
61- return err
62- }
63- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
64- if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
65- return err
66- }
67 62
68- for _, job := range jobs {63+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
69- if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
70- return err
71- }
72- }
73- if err := w.Flush(); err != nil {
74- return err
75- }
76 64
77- return nil65+ return cmd
78- },66+}
67+
68+func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
69+ if len(jobs) == 0 {
70+ if jsonOut {
71+ return writeJSON(out, []dto.BackupJob{})
72+ }
73+ return cliWriteLine(out, cliRenderMuted("No backups found"))
74+ }
75+
76+ if jsonOut {
77+ return writeJSON(out, jobs)
78+ }
79+
80+ if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil {
81+ return err
82+ }
83+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
84+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
85+ return err
86+ }
87+
88+ for _, job := range jobs {
89+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
90+ return err
91+ }
79 }92 }
93+ return w.Flush()
80 }94 }
81 95
82 func newBackupRunCmd() *cobra.Command {96 func newBackupRunCmd() *cobra.Command {
A internal/adapters/in/cli/bootstrap.go +121 -0
@@ -0,0 +1,121 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/gordon/internal/adapters/dto"
11+)
12+
13+func newBootstrapCmd() *cobra.Command {
14+ var attachments []string
15+ var envPairs []string
16+ var attachmentEnvPairs []string
17+ var configPath string
18+
19+ cmd := &cobra.Command{
20+ Use: "bootstrap <domain> <image>",
21+ Short: "Create a route, attachments, and secrets together",
22+ Args: cobra.ExactArgs(2),
23+ RunE: func(cmd *cobra.Command, args []string) error {
24+ ctx := cmd.Context()
25+ req := dto.BootstrapRequest{
26+ Domain: args[0],
27+ Image: args[1],
28+ Attachments: append([]string(nil), attachments...),
29+ Env: map[string]string{},
30+ AttachmentEnv: map[string]map[string]string{},
31+ }
32+
33+ for _, pair := range envPairs {
34+ parts := strings.SplitN(pair, "=", 2)
35+ if len(parts) != 2 {
36+ return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
37+ }
38+ req.Env[parts[0]] = parts[1]
39+ }
40+
41+ for _, pair := range attachmentEnvPairs {
42+ serviceAndKV := strings.SplitN(pair, ":", 2)
43+ if len(serviceAndKV) != 2 {
44+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
45+ }
46+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
47+ if len(keyValue) != 2 {
48+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
49+ }
50+ service := serviceAndKV[0]
51+ if req.AttachmentEnv[service] == nil {
52+ req.AttachmentEnv[service] = map[string]string{}
53+ }
54+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
55+ }
56+
57+ if len(req.Env) == 0 {
58+ req.Env = nil
59+ }
60+ if len(req.AttachmentEnv) == 0 {
61+ req.AttachmentEnv = nil
62+ }
63+
64+ handle, err := resolveControlPlane(configPath)
65+ if err != nil {
66+ return err
67+ }
68+ defer handle.close()
69+
70+ resp, err := handle.plane.Bootstrap(ctx, req)
71+ if resp != nil {
72+ if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
73+ return writeErr
74+ }
75+ }
76+ if err != nil {
77+ return fmt.Errorf("failed to bootstrap: %w", err)
78+ }
79+ out := cmd.OutOrStdout()
80+ if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
81+ return err
82+ }
83+ return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image))
84+ },
85+ }
86+
87+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
88+ cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
89+ cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
90+ cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
91+
92+ return cmd
93+}
94+
95+func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {
96+ out := cmd.OutOrStdout()
97+ if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
98+ return err
99+ }
100+ if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
101+ return err
102+ }
103+ if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
104+ return err
105+ }
106+ for _, step := range resp.Steps {
107+ if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
108+ return err
109+ }
110+ }
111+ if len(resp.Warnings) > 0 {
112+ sorted := append([]string(nil), resp.Warnings...)
113+ sort.Strings(sorted)
114+ for _, warning := range sorted {
115+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
116+ return err
117+ }
118+ }
119+ }
120+ return nil
121+}
@@ -0,0 +1,121 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/gordon/internal/adapters/dto"
11+)
12+
13+func newBootstrapCmd() *cobra.Command {
14+ var attachments []string
15+ var envPairs []string
16+ var attachmentEnvPairs []string
17+ var configPath string
18+
19+ cmd := &cobra.Command{
20+ Use: "bootstrap <domain> <image>",
21+ Short: "Create a route, attachments, and secrets together",
22+ Args: cobra.ExactArgs(2),
23+ RunE: func(cmd *cobra.Command, args []string) error {
24+ ctx := cmd.Context()
25+ req := dto.BootstrapRequest{
26+ Domain: args[0],
27+ Image: args[1],
28+ Attachments: append([]string(nil), attachments...),
29+ Env: map[string]string{},
30+ AttachmentEnv: map[string]map[string]string{},
31+ }
32+
33+ for _, pair := range envPairs {
34+ parts := strings.SplitN(pair, "=", 2)
35+ if len(parts) != 2 {
36+ return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
37+ }
38+ req.Env[parts[0]] = parts[1]
39+ }
40+
41+ for _, pair := range attachmentEnvPairs {
42+ serviceAndKV := strings.SplitN(pair, ":", 2)
43+ if len(serviceAndKV) != 2 {
44+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
45+ }
46+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
47+ if len(keyValue) != 2 {
48+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
49+ }
50+ service := serviceAndKV[0]
51+ if req.AttachmentEnv[service] == nil {
52+ req.AttachmentEnv[service] = map[string]string{}
53+ }
54+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
55+ }
56+
57+ if len(req.Env) == 0 {
58+ req.Env = nil
59+ }
60+ if len(req.AttachmentEnv) == 0 {
61+ req.AttachmentEnv = nil
62+ }
63+
64+ handle, err := resolveControlPlane(configPath)
65+ if err != nil {
66+ return err
67+ }
68+ defer handle.close()
69+
70+ resp, err := handle.plane.Bootstrap(ctx, req)
71+ if resp != nil {
72+ if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
73+ return writeErr
74+ }
75+ }
76+ if err != nil {
77+ return fmt.Errorf("failed to bootstrap: %w", err)
78+ }
79+ out := cmd.OutOrStdout()
80+ if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil {
81+ return err
82+ }
83+ return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image))
84+ },
85+ }
86+
87+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
88+ cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
89+ cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
90+ cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
91+
92+ return cmd
93+}
94+
95+func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {
96+ out := cmd.OutOrStdout()
97+ if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
98+ return err
99+ }
100+ if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
101+ return err
102+ }
103+ if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
104+ return err
105+ }
106+ for _, step := range resp.Steps {
107+ if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
108+ return err
109+ }
110+ }
111+ if len(resp.Warnings) > 0 {
112+ sorted := append([]string(nil), resp.Warnings...)
113+ sort.Strings(sorted)
114+ for _, warning := range sorted {
115+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
116+ return err
117+ }
118+ }
119+ }
120+ return nil
121+}
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -20,6 +20,7 @@ type ControlPlane interface {
2020 AddRoute(ctx context.Context, route domain.Route) error
2121 UpdateRoute(ctx context.Context, route domain.Route) error
2222 RemoveRoute(ctx context.Context, routeDomain string) error
23+ Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)
2324
2425 ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
2526 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
@@ -20,6 +20,7 @@ type ControlPlane interface {
20 AddRoute(ctx context.Context, route domain.Route) error20 AddRoute(ctx context.Context, route domain.Route) error
21 UpdateRoute(ctx context.Context, route domain.Route) error21 UpdateRoute(ctx context.Context, route domain.Route) error
22 RemoveRoute(ctx context.Context, routeDomain string) error22 RemoveRoute(ctx context.Context, routeDomain string) error
23+ Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)
23 24
24 ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)25 ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
25 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error26 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
M internal/adapters/in/cli/controlplane_local.go +88 -0
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "time"
78
@@ -122,6 +123,93 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string)
122123 return l.configSvc.RemoveRoute(ctx, routeDomain)
123124 }
124125
126+func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
127+ resp := &dto.BootstrapResponse{
128+ Domain: req.Domain,
129+ Image: req.Image,
130+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
131+ }
132+
133+ if l.configSvc == nil {
134+ return resp, fmt.Errorf("local config service unavailable")
135+ }
136+ needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
137+ if needsSecrets && l.secretSvc == nil {
138+ return resp, fmt.Errorf("local secret service unavailable")
139+ }
140+
141+ addStep := func(name, status string) {
142+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
143+ }
144+
145+ err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
146+ switch err {
147+ case nil:
148+ addStep("route", "configured")
149+ default:
150+ addStep("route", "failed")
151+ return resp, err
152+ }
153+
154+ if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
155+ return resp, err
156+ }
157+
158+ if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
159+ return resp, err
160+ }
161+
162+ if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
163+ return resp, err
164+ }
165+
166+ return resp, nil
167+}
168+
169+func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
170+ for _, attachment := range req.Attachments {
171+ err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
172+ if err == nil {
173+ addStep("attachment:"+attachment, "created")
174+ continue
175+ }
176+ if errors.Is(err, domain.ErrAttachmentExists) {
177+ addStep("attachment:"+attachment, "noop")
178+ continue
179+ }
180+ addStep("attachment:"+attachment, "failed")
181+ return err
182+ }
183+
184+ return nil
185+}
186+
187+func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
188+ if len(req.Env) == 0 {
189+ return nil
190+ }
191+
192+ if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
193+ addStep("env", "failed")
194+ return err
195+ }
196+
197+ addStep("env", "updated")
198+ return nil
199+}
200+
201+func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
202+ for service, env := range req.AttachmentEnv {
203+ if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
204+ addStep("attachment_env:"+service, "failed")
205+ return err
206+ }
207+ addStep("attachment_env:"+service, "updated")
208+ }
209+
210+ return nil
211+}
212+
125213 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
126214 if l.secretSvc == nil {
127215 return nil, fmt.Errorf("local secret service unavailable")
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "time"7 "time"
7 8
@@ -122,6 +123,93 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string)
122 return l.configSvc.RemoveRoute(ctx, routeDomain)123 return l.configSvc.RemoveRoute(ctx, routeDomain)
123 }124 }
124 125
126+func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
127+ resp := &dto.BootstrapResponse{
128+ Domain: req.Domain,
129+ Image: req.Image,
130+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
131+ }
132+
133+ if l.configSvc == nil {
134+ return resp, fmt.Errorf("local config service unavailable")
135+ }
136+ needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
137+ if needsSecrets && l.secretSvc == nil {
138+ return resp, fmt.Errorf("local secret service unavailable")
139+ }
140+
141+ addStep := func(name, status string) {
142+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
143+ }
144+
145+ err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
146+ switch err {
147+ case nil:
148+ addStep("route", "configured")
149+ default:
150+ addStep("route", "failed")
151+ return resp, err
152+ }
153+
154+ if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
155+ return resp, err
156+ }
157+
158+ if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
159+ return resp, err
160+ }
161+
162+ if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
163+ return resp, err
164+ }
165+
166+ return resp, nil
167+}
168+
169+func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
170+ for _, attachment := range req.Attachments {
171+ err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
172+ if err == nil {
173+ addStep("attachment:"+attachment, "created")
174+ continue
175+ }
176+ if errors.Is(err, domain.ErrAttachmentExists) {
177+ addStep("attachment:"+attachment, "noop")
178+ continue
179+ }
180+ addStep("attachment:"+attachment, "failed")
181+ return err
182+ }
183+
184+ return nil
185+}
186+
187+func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
188+ if len(req.Env) == 0 {
189+ return nil
190+ }
191+
192+ if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
193+ addStep("env", "failed")
194+ return err
195+ }
196+
197+ addStep("env", "updated")
198+ return nil
199+}
200+
201+func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
202+ for service, env := range req.AttachmentEnv {
203+ if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
204+ addStep("attachment_env:"+service, "failed")
205+ return err
206+ }
207+ addStep("attachment_env:"+service, "updated")
208+ }
209+
210+ return nil
211+}
212+
125 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {213 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
126 if l.secretSvc == nil {214 if l.secretSvc == nil {
127 return nil, fmt.Errorf("local secret service unavailable")215 return nil, fmt.Errorf("local secret service unavailable")
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -44,6 +44,10 @@ func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string
4444 return r.client.RemoveRoute(ctx, routeDomain)
4545 }
4646
47+func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
48+ return r.client.Bootstrap(ctx, req)
49+}
50+
4751 func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
4852 return r.client.ListSecretsWithAttachments(ctx, secretDomain)
4953 }
@@ -44,6 +44,10 @@ func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string
44 return r.client.RemoveRoute(ctx, routeDomain)44 return r.client.RemoveRoute(ctx, routeDomain)
45 }45 }
46 46
47+func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
48+ return r.client.Bootstrap(ctx, req)
49+}
50+
47 func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {51 func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
48 return r.client.ListSecretsWithAttachments(ctx, secretDomain)52 return r.client.ListSecretsWithAttachments(ctx, secretDomain)
49 }53 }
M internal/adapters/in/cli/images.go +13 -3
@@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`,
8181 }
8282
8383 func newImagesListCmd() *cobra.Command {
84- return &cobra.Command{
84+ var jsonOut bool
85+
86+ cmd := &cobra.Command{
8587 Use: "list",
8688 Short: "List runtime images and registry tags",
8789 RunE: func(cmd *cobra.Command, _ []string) error {
@@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command {
9092 return fmt.Errorf("images commands require a configured remote target")
9193 }
9294
93- return runImagesList(cmd.Context(), client, cmd.OutOrStdout())
95+ return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
9496 },
9597 }
98+
99+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
100+
101+ return cmd
96102 }
97103
98104 func newImagesPruneCmd() *cobra.Command {
@@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru
191197 }
192198 }
193199
194-func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
200+func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error {
195201 images, err := client.ListImages(ctx)
196202 if err != nil {
197203 return fmt.Errorf("failed to list images: %w", err)
198204 }
199205
206+ if jsonOut {
207+ return writeJSON(out, images)
208+ }
209+
200210 if len(images) == 0 {
201211 if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
202212 return err
@@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`,
81 }81 }
82 82
83 func newImagesListCmd() *cobra.Command {83 func newImagesListCmd() *cobra.Command {
84- return &cobra.Command{84+ var jsonOut bool
85+
86+ cmd := &cobra.Command{
85 Use: "list",87 Use: "list",
86 Short: "List runtime images and registry tags",88 Short: "List runtime images and registry tags",
87 RunE: func(cmd *cobra.Command, _ []string) error {89 RunE: func(cmd *cobra.Command, _ []string) error {
@@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command {
90 return fmt.Errorf("images commands require a configured remote target")92 return fmt.Errorf("images commands require a configured remote target")
91 }93 }
92 94
93- return runImagesList(cmd.Context(), client, cmd.OutOrStdout())95+ return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
94 },96 },
95 }97 }
98+
99+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
100+
101+ return cmd
96 }102 }
97 103
98 func newImagesPruneCmd() *cobra.Command {104 func newImagesPruneCmd() *cobra.Command {
@@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru
191 }197 }
192 }198 }
193 199
194-func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {200+func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error {
195 images, err := client.ListImages(ctx)201 images, err := client.ListImages(ctx)
196 if err != nil {202 if err != nil {
197 return fmt.Errorf("failed to list images: %w", err)203 return fmt.Errorf("failed to list images: %w", err)
198 }204 }
199 205
206+ if jsonOut {
207+ return writeJSON(out, images)
208+ }
209+
200 if len(images) == 0 {210 if len(images) == 0 {
201 if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {211 if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
202 return err212 return err
M internal/adapters/in/cli/images_test.go +5 -5
@@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
5858 }
5959
6060 var out bytes.Buffer
61- err := runImagesList(context.Background(), client, &out)
61+ err := runImagesList(context.Background(), client, &out, false)
6262 require.NoError(t, err)
6363
6464 text := out.String()
@@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
7878 }
7979
8080 var out bytes.Buffer
81- err := runImagesList(context.Background(), client, &out)
81+ err := runImagesList(context.Background(), client, &out, false)
8282 require.NoError(t, err)
8383
8484 text := stripANSI(out.String())
@@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T)
103103 }
104104
105105 var out bytes.Buffer
106- err := runImagesList(context.Background(), client, &out)
106+ err := runImagesList(context.Background(), client, &out, false)
107107 require.NoError(t, err)
108108
109109 text := stripANSI(out.String())
@@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T)
145145 }
146146
147147 var out bytes.Buffer
148- err := runImagesList(context.Background(), client, &out)
148+ err := runImagesList(context.Background(), client, &out, false)
149149 require.NoError(t, err)
150150
151151 text := stripANSI(out.String())
@@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
177177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
178178
179179 var out bytes.Buffer
180- err := runImagesList(context.Background(), client, &out)
180+ err := runImagesList(context.Background(), client, &out, false)
181181 require.NoError(t, err)
182182
183183 text := out.String()
@@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
58 }58 }
59 59
60 var out bytes.Buffer60 var out bytes.Buffer
61- err := runImagesList(context.Background(), client, &out)61+ err := runImagesList(context.Background(), client, &out, false)
62 require.NoError(t, err)62 require.NoError(t, err)
63 63
64 text := out.String()64 text := out.String()
@@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) {
78 }78 }
79 79
80 var out bytes.Buffer80 var out bytes.Buffer
81- err := runImagesList(context.Background(), client, &out)81+ err := runImagesList(context.Background(), client, &out, false)
82 require.NoError(t, err)82 require.NoError(t, err)
83 83
84 text := stripANSI(out.String())84 text := stripANSI(out.String())
@@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T)
103 }103 }
104 104
105 var out bytes.Buffer105 var out bytes.Buffer
106- err := runImagesList(context.Background(), client, &out)106+ err := runImagesList(context.Background(), client, &out, false)
107 require.NoError(t, err)107 require.NoError(t, err)
108 108
109 text := stripANSI(out.String())109 text := stripANSI(out.String())
@@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T)
145 }145 }
146 146
147 var out bytes.Buffer147 var out bytes.Buffer
148- err := runImagesList(context.Background(), client, &out)148+ err := runImagesList(context.Background(), client, &out, false)
149 require.NoError(t, err)149 require.NoError(t, err)
150 150
151 text := stripANSI(out.String())151 text := stripANSI(out.String())
@@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}177 client := &fakeImagesClient{listImagesResp: []dto.Image{}}
178 178
179 var out bytes.Buffer179 var out bytes.Buffer
180- err := runImagesList(context.Background(), client, &out)180+ err := runImagesList(context.Background(), client, &out, false)
181 require.NoError(t, err)181 require.NoError(t, err)
182 182
183 text := out.String()183 text := out.String()
A internal/adapters/in/cli/json.go +13 -0
@@ -0,0 +1,13 @@
1+package cli
2+
3+import (
4+ "encoding/json"
5+ "io"
6+)
7+
8+// writeJSON encodes v as indented JSON to the given writer.
9+func writeJSON(out io.Writer, v any) error {
10+ enc := json.NewEncoder(out)
11+ enc.SetIndent("", " ")
12+ return enc.Encode(v)
13+}
@@ -0,0 +1,13 @@
1+package cli
2+
3+import (
4+ "encoding/json"
5+ "io"
6+)
7+
8+// writeJSON encodes v as indented JSON to the given writer.
9+func writeJSON(out io.Writer, v any) error {
10+ enc := json.NewEncoder(out)
11+ enc.SetIndent("", " ")
12+ return enc.Encode(v)
13+}
A internal/adapters/in/cli/json_parity_test.go +32 -0
@@ -0,0 +1,32 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/cobra"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
11+ commands := []struct {
12+ name string
13+ builder func() *cobra.Command
14+ }{
15+ {"routes list", newRoutesListCmd},
16+ {"attachments list", newAttachmentsListCmd},
17+ {"secrets list", newSecretsListCmd},
18+ {"images list", newImagesListCmd},
19+ {"backup list", newBackupListCmd},
20+ {"auth token list", newTokenListCmd},
21+ {"remotes list", newRemotesListCmd},
22+ {"rollback list", newRollbackListCmd},
23+ }
24+
25+ for _, tc := range commands {
26+ t.Run(tc.name, func(t *testing.T) {
27+ cmd := tc.builder()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f, "command %q should have --json flag", tc.name)
30+ })
31+ }
32+}
@@ -0,0 +1,32 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/cobra"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
11+ commands := []struct {
12+ name string
13+ builder func() *cobra.Command
14+ }{
15+ {"routes list", newRoutesListCmd},
16+ {"attachments list", newAttachmentsListCmd},
17+ {"secrets list", newSecretsListCmd},
18+ {"images list", newImagesListCmd},
19+ {"backup list", newBackupListCmd},
20+ {"auth token list", newTokenListCmd},
21+ {"remotes list", newRemotesListCmd},
22+ {"rollback list", newRollbackListCmd},
23+ }
24+
25+ for _, tc := range commands {
26+ t.Run(tc.name, func(t *testing.T) {
27+ cmd := tc.builder()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f, "command %q should have --json flag", tc.name)
30+ })
31+ }
32+}
A internal/adapters/in/cli/json_test.go +278 -0
@@ -0,0 +1,278 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestRoutesList_JSONFlag_Accepted(t *testing.T) {
18+ cmd := newRoutesListCmd()
19+ f := cmd.Flags().Lookup("json")
20+ assert.NotNil(t, f)
21+ if f != nil {
22+ assert.Equal(t, "false", f.DefValue)
23+ }
24+}
25+
26+func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) {
27+ cmd := newAttachmentsListCmd()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f)
30+ if f != nil {
31+ assert.Equal(t, "false", f.DefValue)
32+ }
33+}
34+
35+func TestSecretsList_JSONFlag_Accepted(t *testing.T) {
36+ cmd := newSecretsListCmd()
37+ f := cmd.Flags().Lookup("json")
38+ assert.NotNil(t, f)
39+ if f != nil {
40+ assert.Equal(t, "false", f.DefValue)
41+ }
42+}
43+
44+func TestImagesList_JSONFlag_Accepted(t *testing.T) {
45+ cmd := newImagesListCmd()
46+ f := cmd.Flags().Lookup("json")
47+ assert.NotNil(t, f)
48+ if f != nil {
49+ assert.Equal(t, "false", f.DefValue)
50+ }
51+}
52+
53+func TestBackupList_JSONFlag_Accepted(t *testing.T) {
54+ cmd := newBackupListCmd()
55+ f := cmd.Flags().Lookup("json")
56+ assert.NotNil(t, f)
57+ if f != nil {
58+ assert.Equal(t, "false", f.DefValue)
59+ }
60+}
61+
62+func TestTokenList_JSONFlag_Accepted(t *testing.T) {
63+ cmd := newTokenListCmd()
64+ f := cmd.Flags().Lookup("json")
65+ assert.NotNil(t, f)
66+ if f != nil {
67+ assert.Equal(t, "false", f.DefValue)
68+ }
69+}
70+
71+func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
72+ cmd := newRemotesListCmd()
73+ f := cmd.Flags().Lookup("json")
74+ assert.NotNil(t, f)
75+ if f != nil {
76+ assert.Equal(t, "false", f.DefValue)
77+ }
78+}
79+
80+func TestRollbackList_JSONFlag_Accepted(t *testing.T) {
81+ cmd := newRollbackListCmd()
82+ f := cmd.Flags().Lookup("json")
83+ assert.NotNil(t, f)
84+ if f != nil {
85+ assert.Equal(t, "false", f.DefValue)
86+ }
87+}
88+
89+func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
90+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
91+ images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}
92+
93+ payload, err := json.Marshal(images)
94+ require.NoError(t, err)
95+
96+ var got []dto.Image
97+ require.NoError(t, json.Unmarshal(payload, &got))
98+ require.Len(t, got, 1)
99+ assert.Equal(t, images[0].Repository, got[0].Repository)
100+ assert.Equal(t, images[0].Tag, got[0].Tag)
101+ assert.Equal(t, images[0].ID, got[0].ID)
102+}
103+
104+func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) {
105+ tags := []string{"v1.2.0", "v1.1.0"}
106+ payload, err := json.Marshal(tags)
107+ require.NoError(t, err)
108+
109+ var got []string
110+ require.NoError(t, json.Unmarshal(payload, &got))
111+ assert.Equal(t, tags, got)
112+}
113+
114+func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
115+ startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
116+ jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}}
117+
118+ payload, err := json.Marshal(jobs)
119+ require.NoError(t, err)
120+
121+ var got []domain.BackupJob
122+ require.NoError(t, json.Unmarshal(payload, &got))
123+ require.Len(t, got, 1)
124+ assert.Equal(t, "b1", got[0].ID)
125+ assert.Equal(t, "app.example.com", got[0].Domain)
126+}
127+
128+func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
129+ tokens := []domain.Token{{
130+ ID: "tok-1",
131+ Subject: "deploy",
132+ Scopes: []string{"admin:*:*"},
133+ IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC),
134+ ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
135+ }}
136+
137+ payload, err := json.Marshal(tokens)
138+ require.NoError(t, err)
139+
140+ var got []domain.Token
141+ require.NoError(t, json.Unmarshal(payload, &got))
142+ require.Len(t, got, 1)
143+ assert.Equal(t, tokens[0].ID, got[0].ID)
144+ assert.Equal(t, tokens[0].Subject, got[0].Subject)
145+ assert.Equal(t, tokens[0].Scopes, got[0].Scopes)
146+}
147+
148+func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
149+ items := []struct {
150+ Name string `json:"name"`
151+ URL string `json:"url"`
152+ Active bool `json:"active"`
153+ InsecureTLS bool `json:"insecure_tls"`
154+ }{
155+ {Name: "prod", URL: "https://prod.example.com", Active: true, InsecureTLS: true},
156+ }
157+
158+ payload, err := json.Marshal(items)
159+ require.NoError(t, err)
160+
161+ var got []struct {
162+ Name string `json:"name"`
163+ URL string `json:"url"`
164+ Active bool `json:"active"`
165+ InsecureTLS bool `json:"insecure_tls"`
166+ }
167+ require.NoError(t, json.Unmarshal(payload, &got))
168+ require.Len(t, got, 1)
169+ assert.Equal(t, "prod", got[0].Name)
170+ assert.Equal(t, "https://prod.example.com", got[0].URL)
171+ assert.True(t, got[0].Active)
172+ assert.True(t, got[0].InsecureTLS)
173+}
174+
175+func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) {
176+ payload := struct {
177+ Target string `json:"target"`
178+ Images []string `json:"images"`
179+ }{
180+ Target: "app.example.com",
181+ Images: []string{"postgres:16"},
182+ }
183+
184+ encoded, err := json.Marshal(payload)
185+ require.NoError(t, err)
186+
187+ var got struct {
188+ Target string `json:"target"`
189+ Images []string `json:"images"`
190+ }
191+ require.NoError(t, json.Unmarshal(encoded, &got))
192+ assert.Equal(t, payload.Target, got.Target)
193+ assert.Equal(t, payload.Images, got.Images)
194+}
195+
196+func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {
197+ payload := struct {
198+ Domain string `json:"domain"`
199+ Keys []string `json:"keys"`
200+ Attachments []remote.AttachmentSecrets `json:"attachments"`
201+ }{
202+ Domain: "app.example.com",
203+ Keys: []string{"API_KEY"},
204+ Attachments: []remote.AttachmentSecrets{{
205+ Service: "postgres",
206+ Keys: []string{"POSTGRES_PASSWORD"},
207+ }},
208+ }
209+
210+ encoded, err := json.Marshal(payload)
211+ require.NoError(t, err)
212+
213+ var got struct {
214+ Domain string `json:"domain"`
215+ Keys []string `json:"keys"`
216+ Attachments []remote.AttachmentSecrets `json:"attachments"`
217+ }
218+ require.NoError(t, json.Unmarshal(encoded, &got))
219+ assert.Equal(t, payload.Domain, got.Domain)
220+ assert.Equal(t, payload.Keys, got.Keys)
221+ assert.Equal(t, payload.Attachments, got.Attachments)
222+}
223+
224+func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) {
225+ payload := struct {
226+ Domain string `json:"domain"`
227+ Keys []string `json:"keys"`
228+ Attachments []remote.AttachmentSecrets `json:"attachments"`
229+ }{
230+ Domain: "app.example.com",
231+ Keys: []string{"API_KEY"},
232+ Attachments: []remote.AttachmentSecrets{},
233+ }
234+
235+ encoded, err := json.Marshal(payload)
236+ require.NoError(t, err)
237+ assert.Contains(t, string(encoded), `"attachments":[]`)
238+
239+ var got struct {
240+ Domain string `json:"domain"`
241+ Keys []string `json:"keys"`
242+ Attachments []remote.AttachmentSecrets `json:"attachments"`
243+ }
244+ require.NoError(t, json.Unmarshal(encoded, &got))
245+ assert.NotNil(t, got.Attachments)
246+ assert.Empty(t, got.Attachments)
247+}
248+
249+func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
250+ payload := []struct {
251+ Domain string `json:"domain"`
252+ Image string `json:"image"`
253+ }{
254+ {Domain: "app.example.com", Image: "app:latest"},
255+ }
256+
257+ encoded, err := json.Marshal(payload)
258+ require.NoError(t, err)
259+
260+ var got []struct {
261+ Domain string `json:"domain"`
262+ Image string `json:"image"`
263+ }
264+ require.NoError(t, json.Unmarshal(encoded, &got))
265+ require.Len(t, got, 1)
266+ assert.Equal(t, payload[0], got[0])
267+}
268+
269+func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270+ var out bytes.Buffer
271+ err := writeJSON(&out, map[string]any{"ok": true})
272+ require.NoError(t, err)
273+
274+ var got map[string]any
275+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
276+ assert.Equal(t, true, got["ok"])
277+ assert.Contains(t, out.String(), "\n")
278+}
@@ -0,0 +1,278 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+func TestRoutesList_JSONFlag_Accepted(t *testing.T) {
18+ cmd := newRoutesListCmd()
19+ f := cmd.Flags().Lookup("json")
20+ assert.NotNil(t, f)
21+ if f != nil {
22+ assert.Equal(t, "false", f.DefValue)
23+ }
24+}
25+
26+func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) {
27+ cmd := newAttachmentsListCmd()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f)
30+ if f != nil {
31+ assert.Equal(t, "false", f.DefValue)
32+ }
33+}
34+
35+func TestSecretsList_JSONFlag_Accepted(t *testing.T) {
36+ cmd := newSecretsListCmd()
37+ f := cmd.Flags().Lookup("json")
38+ assert.NotNil(t, f)
39+ if f != nil {
40+ assert.Equal(t, "false", f.DefValue)
41+ }
42+}
43+
44+func TestImagesList_JSONFlag_Accepted(t *testing.T) {
45+ cmd := newImagesListCmd()
46+ f := cmd.Flags().Lookup("json")
47+ assert.NotNil(t, f)
48+ if f != nil {
49+ assert.Equal(t, "false", f.DefValue)
50+ }
51+}
52+
53+func TestBackupList_JSONFlag_Accepted(t *testing.T) {
54+ cmd := newBackupListCmd()
55+ f := cmd.Flags().Lookup("json")
56+ assert.NotNil(t, f)
57+ if f != nil {
58+ assert.Equal(t, "false", f.DefValue)
59+ }
60+}
61+
62+func TestTokenList_JSONFlag_Accepted(t *testing.T) {
63+ cmd := newTokenListCmd()
64+ f := cmd.Flags().Lookup("json")
65+ assert.NotNil(t, f)
66+ if f != nil {
67+ assert.Equal(t, "false", f.DefValue)
68+ }
69+}
70+
71+func TestRemotesList_JSONFlag_Accepted(t *testing.T) {
72+ cmd := newRemotesListCmd()
73+ f := cmd.Flags().Lookup("json")
74+ assert.NotNil(t, f)
75+ if f != nil {
76+ assert.Equal(t, "false", f.DefValue)
77+ }
78+}
79+
80+func TestRollbackList_JSONFlag_Accepted(t *testing.T) {
81+ cmd := newRollbackListCmd()
82+ f := cmd.Flags().Lookup("json")
83+ assert.NotNil(t, f)
84+ if f != nil {
85+ assert.Equal(t, "false", f.DefValue)
86+ }
87+}
88+
89+func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) {
90+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
91+ images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}}
92+
93+ payload, err := json.Marshal(images)
94+ require.NoError(t, err)
95+
96+ var got []dto.Image
97+ require.NoError(t, json.Unmarshal(payload, &got))
98+ require.Len(t, got, 1)
99+ assert.Equal(t, images[0].Repository, got[0].Repository)
100+ assert.Equal(t, images[0].Tag, got[0].Tag)
101+ assert.Equal(t, images[0].ID, got[0].ID)
102+}
103+
104+func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) {
105+ tags := []string{"v1.2.0", "v1.1.0"}
106+ payload, err := json.Marshal(tags)
107+ require.NoError(t, err)
108+
109+ var got []string
110+ require.NoError(t, json.Unmarshal(payload, &got))
111+ assert.Equal(t, tags, got)
112+}
113+
114+func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) {
115+ startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)
116+ jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}}
117+
118+ payload, err := json.Marshal(jobs)
119+ require.NoError(t, err)
120+
121+ var got []domain.BackupJob
122+ require.NoError(t, json.Unmarshal(payload, &got))
123+ require.Len(t, got, 1)
124+ assert.Equal(t, "b1", got[0].ID)
125+ assert.Equal(t, "app.example.com", got[0].Domain)
126+}
127+
128+func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) {
129+ tokens := []domain.Token{{
130+ ID: "tok-1",
131+ Subject: "deploy",
132+ Scopes: []string{"admin:*:*"},
133+ IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC),
134+ ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
135+ }}
136+
137+ payload, err := json.Marshal(tokens)
138+ require.NoError(t, err)
139+
140+ var got []domain.Token
141+ require.NoError(t, json.Unmarshal(payload, &got))
142+ require.Len(t, got, 1)
143+ assert.Equal(t, tokens[0].ID, got[0].ID)
144+ assert.Equal(t, tokens[0].Subject, got[0].Subject)
145+ assert.Equal(t, tokens[0].Scopes, got[0].Scopes)
146+}
147+
148+func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) {
149+ items := []struct {
150+ Name string `json:"name"`
151+ URL string `json:"url"`
152+ Active bool `json:"active"`
153+ InsecureTLS bool `json:"insecure_tls"`
154+ }{
155+ {Name: "prod", URL: "https://prod.example.com", Active: true, InsecureTLS: true},
156+ }
157+
158+ payload, err := json.Marshal(items)
159+ require.NoError(t, err)
160+
161+ var got []struct {
162+ Name string `json:"name"`
163+ URL string `json:"url"`
164+ Active bool `json:"active"`
165+ InsecureTLS bool `json:"insecure_tls"`
166+ }
167+ require.NoError(t, json.Unmarshal(payload, &got))
168+ require.Len(t, got, 1)
169+ assert.Equal(t, "prod", got[0].Name)
170+ assert.Equal(t, "https://prod.example.com", got[0].URL)
171+ assert.True(t, got[0].Active)
172+ assert.True(t, got[0].InsecureTLS)
173+}
174+
175+func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) {
176+ payload := struct {
177+ Target string `json:"target"`
178+ Images []string `json:"images"`
179+ }{
180+ Target: "app.example.com",
181+ Images: []string{"postgres:16"},
182+ }
183+
184+ encoded, err := json.Marshal(payload)
185+ require.NoError(t, err)
186+
187+ var got struct {
188+ Target string `json:"target"`
189+ Images []string `json:"images"`
190+ }
191+ require.NoError(t, json.Unmarshal(encoded, &got))
192+ assert.Equal(t, payload.Target, got.Target)
193+ assert.Equal(t, payload.Images, got.Images)
194+}
195+
196+func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) {
197+ payload := struct {
198+ Domain string `json:"domain"`
199+ Keys []string `json:"keys"`
200+ Attachments []remote.AttachmentSecrets `json:"attachments"`
201+ }{
202+ Domain: "app.example.com",
203+ Keys: []string{"API_KEY"},
204+ Attachments: []remote.AttachmentSecrets{{
205+ Service: "postgres",
206+ Keys: []string{"POSTGRES_PASSWORD"},
207+ }},
208+ }
209+
210+ encoded, err := json.Marshal(payload)
211+ require.NoError(t, err)
212+
213+ var got struct {
214+ Domain string `json:"domain"`
215+ Keys []string `json:"keys"`
216+ Attachments []remote.AttachmentSecrets `json:"attachments"`
217+ }
218+ require.NoError(t, json.Unmarshal(encoded, &got))
219+ assert.Equal(t, payload.Domain, got.Domain)
220+ assert.Equal(t, payload.Keys, got.Keys)
221+ assert.Equal(t, payload.Attachments, got.Attachments)
222+}
223+
224+func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) {
225+ payload := struct {
226+ Domain string `json:"domain"`
227+ Keys []string `json:"keys"`
228+ Attachments []remote.AttachmentSecrets `json:"attachments"`
229+ }{
230+ Domain: "app.example.com",
231+ Keys: []string{"API_KEY"},
232+ Attachments: []remote.AttachmentSecrets{},
233+ }
234+
235+ encoded, err := json.Marshal(payload)
236+ require.NoError(t, err)
237+ assert.Contains(t, string(encoded), `"attachments":[]`)
238+
239+ var got struct {
240+ Domain string `json:"domain"`
241+ Keys []string `json:"keys"`
242+ Attachments []remote.AttachmentSecrets `json:"attachments"`
243+ }
244+ require.NoError(t, json.Unmarshal(encoded, &got))
245+ assert.NotNil(t, got.Attachments)
246+ assert.Empty(t, got.Attachments)
247+}
248+
249+func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
250+ payload := []struct {
251+ Domain string `json:"domain"`
252+ Image string `json:"image"`
253+ }{
254+ {Domain: "app.example.com", Image: "app:latest"},
255+ }
256+
257+ encoded, err := json.Marshal(payload)
258+ require.NoError(t, err)
259+
260+ var got []struct {
261+ Domain string `json:"domain"`
262+ Image string `json:"image"`
263+ }
264+ require.NoError(t, json.Unmarshal(encoded, &got))
265+ require.Len(t, got, 1)
266+ assert.Equal(t, payload[0], got[0])
267+}
268+
269+func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270+ var out bytes.Buffer
271+ err := writeJSON(&out, map[string]any{"ok": true})
272+ require.NoError(t, err)
273+
274+ var got map[string]any
275+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
276+ assert.Equal(t, true, got["ok"])
277+ assert.Contains(t, out.String(), "\n")
278+}
M internal/adapters/in/cli/push.go +4 -1
@@ -206,8 +206,11 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
206206 }
207207
208208 if len(routes) == 0 {
209+ // bootstrap command is registered in bootstrap.go (see issue #98)
209210 return "", "", "", fmt.Errorf(
210- "no route configured for image %q\n\nUse 'gordon routes add' to create a route first",
211+ "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'",
212+ imageArg,
213+ imageArg,
211214 imageArg,
212215 )
213216 }
@@ -206,8 +206,11 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile
206 }206 }
207 207
208 if len(routes) == 0 {208 if len(routes) == 0 {
209+ // bootstrap command is registered in bootstrap.go (see issue #98)
209 return "", "", "", fmt.Errorf(210 return "", "", "", fmt.Errorf(
210- "no route configured for image %q\n\nUse 'gordon routes add' to create a route first",211+ "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'",
212+ imageArg,
213+ imageArg,
211 imageArg,214 imageArg,
212 )215 )
213 }216 }
M internal/adapters/in/cli/push_test.go +150 -0
@@ -9,9 +9,145 @@ import (
99 "strings"
1010 "testing"
1111
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/domain"
1215 "github.com/stretchr/testify/assert"
1316 )
1417
18+type resolveFromImageTestControlPlane struct {
19+ findRoutesByImage func(context.Context, string) ([]domain.Route, error)
20+}
21+
22+var _ ControlPlane = (*resolveFromImageTestControlPlane)(nil)
23+
24+func (c *resolveFromImageTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
25+ panic("unexpected call")
26+}
27+
28+func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
29+ panic("unexpected call")
30+}
31+
32+func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
33+ panic("unexpected call")
34+}
35+
36+func (c *resolveFromImageTestControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
37+ if c.findRoutesByImage != nil {
38+ return c.findRoutesByImage(ctx, imageName)
39+ }
40+ return nil, nil
41+}
42+
43+func (c *resolveFromImageTestControlPlane) AddRoute(context.Context, domain.Route) error {
44+ panic("unexpected call")
45+}
46+
47+func (c *resolveFromImageTestControlPlane) UpdateRoute(context.Context, domain.Route) error {
48+ panic("unexpected call")
49+}
50+
51+func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) error {
52+ panic("unexpected call")
53+}
54+
55+func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
56+ panic("unexpected call")
57+}
58+
59+func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
60+ panic("unexpected call")
61+}
62+
63+func (c *resolveFromImageTestControlPlane) SetSecrets(context.Context, string, map[string]string) error {
64+ panic("unexpected call")
65+}
66+
67+func (c *resolveFromImageTestControlPlane) DeleteSecret(context.Context, string, string) error {
68+ panic("unexpected call")
69+}
70+
71+func (c *resolveFromImageTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error {
72+ panic("unexpected call")
73+}
74+
75+func (c *resolveFromImageTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error {
76+ panic("unexpected call")
77+}
78+
79+func (c *resolveFromImageTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) {
80+ panic("unexpected call")
81+}
82+
83+func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) {
84+ panic("unexpected call")
85+}
86+
87+func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error {
88+ panic("unexpected call")
89+}
90+
91+func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, string, string) error {
92+ panic("unexpected call")
93+}
94+
95+func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
96+ panic("unexpected call")
97+}
98+
99+func (c *resolveFromImageTestControlPlane) Reload(context.Context) error {
100+ panic("unexpected call")
101+}
102+
103+func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) {
108+ panic("unexpected call")
109+}
110+
111+func (c *resolveFromImageTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) {
112+ panic("unexpected call")
113+}
114+
115+func (c *resolveFromImageTestControlPlane) ListTags(context.Context, string) ([]string, error) {
116+ panic("unexpected call")
117+}
118+
119+func (c *resolveFromImageTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) {
120+ panic("unexpected call")
121+}
122+
123+func (c *resolveFromImageTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) {
124+ panic("unexpected call")
125+}
126+
127+func (c *resolveFromImageTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) {
128+ panic("unexpected call")
129+}
130+
131+func (c *resolveFromImageTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) {
132+ panic("unexpected call")
133+}
134+
135+func (c *resolveFromImageTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) {
136+ panic("unexpected call")
137+}
138+
139+func (c *resolveFromImageTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) {
140+ panic("unexpected call")
141+}
142+
143+func (c *resolveFromImageTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) {
144+ panic("unexpected call")
145+}
146+
147+func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) {
148+ panic("unexpected call")
149+}
150+
15151 func TestValidateBuildArg(t *testing.T) {
16152 tests := []struct {
17153 name string
@@ -421,3 +557,17 @@ func TestParseLabelPair(t *testing.T) {
421557 })
422558 }
423559 }
560+
561+func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
562+ cp := &resolveFromImageTestControlPlane{
563+ findRoutesByImage: func(context.Context, string) ([]domain.Route, error) {
564+ return nil, nil
565+ },
566+ }
567+
568+ _, _, _, err := resolveFromImage(context.Background(), cp, "myapp", "Dockerfile")
569+
570+ assert.Error(t, err)
571+ assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
572+ assert.Contains(t, err.Error(), "gordon bootstrap")
573+}
@@ -9,9 +9,145 @@ import (
9 "strings"9 "strings"
10 "testing"10 "testing"
11 11
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/domain"
12 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
13 )16 )
14 17
18+type resolveFromImageTestControlPlane struct {
19+ findRoutesByImage func(context.Context, string) ([]domain.Route, error)
20+}
21+
22+var _ ControlPlane = (*resolveFromImageTestControlPlane)(nil)
23+
24+func (c *resolveFromImageTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
25+ panic("unexpected call")
26+}
27+
28+func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) {
29+ panic("unexpected call")
30+}
31+
32+func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) {
33+ panic("unexpected call")
34+}
35+
36+func (c *resolveFromImageTestControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
37+ if c.findRoutesByImage != nil {
38+ return c.findRoutesByImage(ctx, imageName)
39+ }
40+ return nil, nil
41+}
42+
43+func (c *resolveFromImageTestControlPlane) AddRoute(context.Context, domain.Route) error {
44+ panic("unexpected call")
45+}
46+
47+func (c *resolveFromImageTestControlPlane) UpdateRoute(context.Context, domain.Route) error {
48+ panic("unexpected call")
49+}
50+
51+func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) error {
52+ panic("unexpected call")
53+}
54+
55+func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
56+ panic("unexpected call")
57+}
58+
59+func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
60+ panic("unexpected call")
61+}
62+
63+func (c *resolveFromImageTestControlPlane) SetSecrets(context.Context, string, map[string]string) error {
64+ panic("unexpected call")
65+}
66+
67+func (c *resolveFromImageTestControlPlane) DeleteSecret(context.Context, string, string) error {
68+ panic("unexpected call")
69+}
70+
71+func (c *resolveFromImageTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error {
72+ panic("unexpected call")
73+}
74+
75+func (c *resolveFromImageTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error {
76+ panic("unexpected call")
77+}
78+
79+func (c *resolveFromImageTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) {
80+ panic("unexpected call")
81+}
82+
83+func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) {
84+ panic("unexpected call")
85+}
86+
87+func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error {
88+ panic("unexpected call")
89+}
90+
91+func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, string, string) error {
92+ panic("unexpected call")
93+}
94+
95+func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) {
96+ panic("unexpected call")
97+}
98+
99+func (c *resolveFromImageTestControlPlane) Reload(context.Context) error {
100+ panic("unexpected call")
101+}
102+
103+func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error {
104+ panic("unexpected call")
105+}
106+
107+func (c *resolveFromImageTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) {
108+ panic("unexpected call")
109+}
110+
111+func (c *resolveFromImageTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) {
112+ panic("unexpected call")
113+}
114+
115+func (c *resolveFromImageTestControlPlane) ListTags(context.Context, string) ([]string, error) {
116+ panic("unexpected call")
117+}
118+
119+func (c *resolveFromImageTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) {
120+ panic("unexpected call")
121+}
122+
123+func (c *resolveFromImageTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) {
124+ panic("unexpected call")
125+}
126+
127+func (c *resolveFromImageTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) {
128+ panic("unexpected call")
129+}
130+
131+func (c *resolveFromImageTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) {
132+ panic("unexpected call")
133+}
134+
135+func (c *resolveFromImageTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) {
136+ panic("unexpected call")
137+}
138+
139+func (c *resolveFromImageTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) {
140+ panic("unexpected call")
141+}
142+
143+func (c *resolveFromImageTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) {
144+ panic("unexpected call")
145+}
146+
147+func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) {
148+ panic("unexpected call")
149+}
150+
15 func TestValidateBuildArg(t *testing.T) {151 func TestValidateBuildArg(t *testing.T) {
16 tests := []struct {152 tests := []struct {
17 name string153 name string
@@ -421,3 +557,17 @@ func TestParseLabelPair(t *testing.T) {
421 })557 })
422 }558 }
423 }559 }
560+
561+func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) {
562+ cp := &resolveFromImageTestControlPlane{
563+ findRoutesByImage: func(context.Context, string) ([]domain.Route, error) {
564+ return nil, nil
565+ },
566+ }
567+
568+ _, _, _, err := resolveFromImage(context.Background(), cp, "myapp", "Dockerfile")
569+
570+ assert.Error(t, err)
571+ assert.Contains(t, err.Error(), `no route configured for image "myapp"`)
572+ assert.Contains(t, err.Error(), "gordon bootstrap")
573+}
M internal/adapters/in/cli/remote/client.go +12 -0
@@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
407407 return parseResponse(resp, nil)
408408 }
409409
410+func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
411+ resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
412+ if err != nil {
413+ return nil, err
414+ }
415+ var result dto.BootstrapResponse
416+ if err := parseResponse(resp, &result); err != nil {
417+ return nil, err
418+ }
419+ return &result, nil
420+}
421+
410422 // Secrets API
411423
412424 // AttachmentSecrets represents secrets for an attachment container.
@@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
407 return parseResponse(resp, nil)407 return parseResponse(resp, nil)
408 }408 }
409 409
410+func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
411+ resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req)
412+ if err != nil {
413+ return nil, err
414+ }
415+ var result dto.BootstrapResponse
416+ if err := parseResponse(resp, &result); err != nil {
417+ return nil, err
418+ }
419+ return &result, nil
420+}
421+
410 // Secrets API422 // Secrets API
411 423
412 // AttachmentSecrets represents secrets for an attachment container.424 // AttachmentSecrets represents secrets for an attachment container.
M internal/adapters/in/cli/remotes.go +102 -48
@@ -2,6 +2,8 @@ package cli
22
33 import (
44 "fmt"
5+ "io"
6+ "sort"
57 "strings"
68
79 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -35,67 +37,119 @@ Configuration is stored in ~/.config/gordon/remotes.toml`,
3537
3638 // newRemotesListCmd creates the remotes list command.
3739 func newRemotesListCmd() *cobra.Command {
38- return &cobra.Command{
40+ var jsonOut bool
41+
42+ cmd := &cobra.Command{
3943 Use: "list",
4044 Short: "List saved remotes",
4145 RunE: func(cmd *cobra.Command, args []string) error {
42- remotes, active, err := remote.ListRemotes()
43- if err != nil {
44- return fmt.Errorf("failed to list remotes: %w", err)
45- }
46+ return runRemotesList(cmd.OutOrStdout(), jsonOut)
47+ },
48+ }
4649
47- if len(remotes) == 0 {
48- fmt.Println(styles.Theme.Muted.Render("No remotes configured"))
49- fmt.Println()
50- fmt.Println("Add a remote with:")
51- fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
52- return nil
53- }
50+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
5451
55- fmt.Println(styles.Theme.Title.Render("Saved Remotes"))
56- fmt.Println()
52+ return cmd
53+}
5754
58- // Build table rows
59- rows := make([][]string, 0, len(remotes))
60- for name, r := range remotes {
61- status := ""
62- if name == active {
63- status = styles.Theme.Success.Render("active")
64- }
55+func runRemotesList(out io.Writer, jsonOut bool) error {
56+ remotes, active, err := remote.ListRemotes()
57+ if err != nil {
58+ return fmt.Errorf("failed to list remotes: %w", err)
59+ }
6560
66- // Mask token if present
67- tokenStatus := styles.Theme.Muted.Render("none")
68- if r.Token != "" {
69- tokenStatus = styles.Theme.Success.Render("set")
70- } else if r.TokenEnv != "" {
71- tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
72- }
61+ if len(remotes) == 0 {
62+ return remotesListEmpty(out, jsonOut)
63+ }
7364
74- tlsStatus := ""
75- if r.InsecureTLS {
76- tlsStatus = styles.Theme.Warning.Render("insecure")
77- }
65+ names := sortedRemoteNames(remotes)
7866
79- rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status})
80- }
67+ if jsonOut {
68+ return remotesListJSON(out, remotes, names, active)
69+ }
8170
82- // Render table
83- table := components.NewTable(
84- components.WithColumns([]components.TableColumn{
85- {Title: "Name", Width: 15},
86- {Title: "URL", Width: 35},
87- {Title: "Token", Width: 15},
88- {Title: "TLS", Width: 10},
89- {Title: "Status", Width: 10},
90- }),
91- components.WithRows(rows),
92- )
71+ if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil {
72+ return err
73+ }
74+ if err := cliWriteLine(out, ""); err != nil {
75+ return err
76+ }
9377
94- fmt.Println(table.View())
78+ rows := make([][]string, 0, len(names))
79+ for _, name := range names {
80+ r := remotes[name]
81+ status := ""
82+ if name == active {
83+ status = styles.Theme.Success.Render("active")
84+ }
85+
86+ tokenStatus := styles.Theme.Muted.Render("none")
87+ if r.Token != "" {
88+ tokenStatus = styles.Theme.Success.Render("set")
89+ } else if r.TokenEnv != "" {
90+ tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
91+ }
92+
93+ tlsStatus := ""
94+ if r.InsecureTLS {
95+ tlsStatus = styles.Theme.Warning.Render("insecure")
96+ }
97+
98+ rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status})
99+ }
95100
96- return nil
97- },
101+ table := components.NewTable(
102+ components.WithColumns([]components.TableColumn{
103+ {Title: "Name", Width: 15},
104+ {Title: "URL", Width: 35},
105+ {Title: "Token", Width: 15},
106+ {Title: "TLS", Width: 10},
107+ {Title: "Status", Width: 10},
108+ }),
109+ components.WithRows(rows),
110+ )
111+
112+ _, err = fmt.Fprintln(out, table.View())
113+ return err
114+}
115+
116+func remotesListEmpty(out io.Writer, jsonOut bool) error {
117+ if jsonOut {
118+ return writeJSON(out, []map[string]any{})
119+ }
120+ if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil {
121+ return err
122+ }
123+ if err := cliWriteLine(out, ""); err != nil {
124+ return err
125+ }
126+ if err := cliWriteLine(out, "Add a remote with:"); err != nil {
127+ return err
128+ }
129+ return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
130+}
131+
132+func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string {
133+ names := make([]string, 0, len(remotes))
134+ for name := range remotes {
135+ names = append(names, name)
136+ }
137+ sort.Strings(names)
138+ return names
139+}
140+
141+func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error {
142+ payload := make([]map[string]any, 0, len(names))
143+ for _, name := range names {
144+ r := remotes[name]
145+ payload = append(payload, map[string]any{
146+ "name": name,
147+ "url": r.URL,
148+ "active": name == active,
149+ "insecure_tls": r.InsecureTLS,
150+ })
98151 }
152+ return writeJSON(out, payload)
99153 }
100154
101155 // newRemotesAddCmd creates the remotes add command.
@@ -2,6 +2,8 @@ package cli
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "io"
6+ "sort"
5 "strings"7 "strings"
6 8
7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -35,67 +37,119 @@ Configuration is stored in ~/.config/gordon/remotes.toml`,
35 37
36 // newRemotesListCmd creates the remotes list command.38 // newRemotesListCmd creates the remotes list command.
37 func newRemotesListCmd() *cobra.Command {39 func newRemotesListCmd() *cobra.Command {
38- return &cobra.Command{40+ var jsonOut bool
41+
42+ cmd := &cobra.Command{
39 Use: "list",43 Use: "list",
40 Short: "List saved remotes",44 Short: "List saved remotes",
41 RunE: func(cmd *cobra.Command, args []string) error {45 RunE: func(cmd *cobra.Command, args []string) error {
42- remotes, active, err := remote.ListRemotes()46+ return runRemotesList(cmd.OutOrStdout(), jsonOut)
43- if err != nil {47+ },
44- return fmt.Errorf("failed to list remotes: %w", err)48+ }
45- }
46 49
47- if len(remotes) == 0 {50+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
48- fmt.Println(styles.Theme.Muted.Render("No remotes configured"))
49- fmt.Println()
50- fmt.Println("Add a remote with:")
51- fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
52- return nil
53- }
54 51
55- fmt.Println(styles.Theme.Title.Render("Saved Remotes"))52+ return cmd
56- fmt.Println()53+}
57 54
58- // Build table rows55+func runRemotesList(out io.Writer, jsonOut bool) error {
59- rows := make([][]string, 0, len(remotes))56+ remotes, active, err := remote.ListRemotes()
60- for name, r := range remotes {57+ if err != nil {
61- status := ""58+ return fmt.Errorf("failed to list remotes: %w", err)
62- if name == active {59+ }
63- status = styles.Theme.Success.Render("active")
64- }
65 60
66- // Mask token if present61+ if len(remotes) == 0 {
67- tokenStatus := styles.Theme.Muted.Render("none")62+ return remotesListEmpty(out, jsonOut)
68- if r.Token != "" {63+ }
69- tokenStatus = styles.Theme.Success.Render("set")
70- } else if r.TokenEnv != "" {
71- tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
72- }
73 64
74- tlsStatus := ""65+ names := sortedRemoteNames(remotes)
75- if r.InsecureTLS {
76- tlsStatus = styles.Theme.Warning.Render("insecure")
77- }
78 66
79- rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status})67+ if jsonOut {
80- }68+ return remotesListJSON(out, remotes, names, active)
69+ }
81 70
82- // Render table71+ if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil {
83- table := components.NewTable(72+ return err
84- components.WithColumns([]components.TableColumn{73+ }
85- {Title: "Name", Width: 15},74+ if err := cliWriteLine(out, ""); err != nil {
86- {Title: "URL", Width: 35},75+ return err
87- {Title: "Token", Width: 15},76+ }
88- {Title: "TLS", Width: 10},
89- {Title: "Status", Width: 10},
90- }),
91- components.WithRows(rows),
92- )
93 77
94- fmt.Println(table.View())78+ rows := make([][]string, 0, len(names))
79+ for _, name := range names {
80+ r := remotes[name]
81+ status := ""
82+ if name == active {
83+ status = styles.Theme.Success.Render("active")
84+ }
85+
86+ tokenStatus := styles.Theme.Muted.Render("none")
87+ if r.Token != "" {
88+ tokenStatus = styles.Theme.Success.Render("set")
89+ } else if r.TokenEnv != "" {
90+ tokenStatus = fmt.Sprintf("$%s", r.TokenEnv)
91+ }
92+
93+ tlsStatus := ""
94+ if r.InsecureTLS {
95+ tlsStatus = styles.Theme.Warning.Render("insecure")
96+ }
97+
98+ rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status})
99+ }
95 100
96- return nil101+ table := components.NewTable(
97- },102+ components.WithColumns([]components.TableColumn{
103+ {Title: "Name", Width: 15},
104+ {Title: "URL", Width: 35},
105+ {Title: "Token", Width: 15},
106+ {Title: "TLS", Width: 10},
107+ {Title: "Status", Width: 10},
108+ }),
109+ components.WithRows(rows),
110+ )
111+
112+ _, err = fmt.Fprintln(out, table.View())
113+ return err
114+}
115+
116+func remotesListEmpty(out io.Writer, jsonOut bool) error {
117+ if jsonOut {
118+ return writeJSON(out, []map[string]any{})
119+ }
120+ if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil {
121+ return err
122+ }
123+ if err := cliWriteLine(out, ""); err != nil {
124+ return err
125+ }
126+ if err := cliWriteLine(out, "Add a remote with:"); err != nil {
127+ return err
128+ }
129+ return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add <name> <url>"))
130+}
131+
132+func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string {
133+ names := make([]string, 0, len(remotes))
134+ for name := range remotes {
135+ names = append(names, name)
136+ }
137+ sort.Strings(names)
138+ return names
139+}
140+
141+func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error {
142+ payload := make([]map[string]any, 0, len(names))
143+ for _, name := range names {
144+ r := remotes[name]
145+ payload = append(payload, map[string]any{
146+ "name": name,
147+ "url": r.URL,
148+ "active": name == active,
149+ "insecure_tls": r.InsecureTLS,
150+ })
98 }151 }
152+ return writeJSON(out, payload)
99 }153 }
100 154
101 // newRemotesAddCmd creates the remotes add command.155 // newRemotesAddCmd creates the remotes add command.
M internal/adapters/in/cli/rollback.go +19 -5
@@ -42,14 +42,20 @@ Examples:
4242 }
4343
4444 func newRollbackListCmd() *cobra.Command {
45- return &cobra.Command{
45+ var jsonOut bool
46+
47+ cmd := &cobra.Command{
4648 Use: "list <domain>",
4749 Short: "List available rollback tags",
4850 Args: cobra.ExactArgs(1),
4951 RunE: func(cmd *cobra.Command, args []string) error {
50- return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0])
52+ return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
5153 },
5254 }
55+
56+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57+
58+ return cmd
5359 }
5460
5561 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
@@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
8793 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
8894 }
8995
90-func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error {
96+func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error {
9197 handle, err := resolveControlPlane(configPath)
9298 if err != nil {
9399 return err
@@ -109,10 +115,18 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er
109115 return err
110116 }
111117
112- return printRollbackTags(w, rollbackDomain, currentTag, tags)
118+ return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)
113119 }
114120
115-func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error {
121+func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error {
122+ if jsonOut {
123+ return writeJSON(w, map[string]any{
124+ "domain": rollbackDomain,
125+ "current_tag": currentTag,
126+ "tags": tags,
127+ })
128+ }
129+
116130 if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {
117131 return err
118132 }
@@ -42,14 +42,20 @@ Examples:
42 }42 }
43 43
44 func newRollbackListCmd() *cobra.Command {44 func newRollbackListCmd() *cobra.Command {
45- return &cobra.Command{45+ var jsonOut bool
46+
47+ cmd := &cobra.Command{
46 Use: "list <domain>",48 Use: "list <domain>",
47 Short: "List available rollback tags",49 Short: "List available rollback tags",
48 Args: cobra.ExactArgs(1),50 Args: cobra.ExactArgs(1),
49 RunE: func(cmd *cobra.Command, args []string) error {51 RunE: func(cmd *cobra.Command, args []string) error {
50- return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0])52+ return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut)
51 },53 },
52 }54 }
55+
56+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
57+
58+ return cmd
53 }59 }
54 60
55 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {61 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
@@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
87 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)93 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
88 }94 }
89 95
90-func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error {96+func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error {
91 handle, err := resolveControlPlane(configPath)97 handle, err := resolveControlPlane(configPath)
92 if err != nil {98 if err != nil {
93 return err99 return err
@@ -109,10 +115,18 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er
109 return err115 return err
110 }116 }
111 117
112- return printRollbackTags(w, rollbackDomain, currentTag, tags)118+ return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut)
113 }119 }
114 120
115-func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error {121+func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error {
122+ if jsonOut {
123+ return writeJSON(w, map[string]any{
124+ "domain": rollbackDomain,
125+ "current_tag": currentTag,
126+ "tags": tags,
127+ })
128+ }
129+
116 if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {130 if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil {
117 return err131 return err
118 }132 }
M internal/adapters/in/cli/rollback_test.go +2 -2
@@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) {
6363 func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
6464 var buf bytes.Buffer
6565
66- err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"})
66+ err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
6767 assert.NoError(t, err)
6868
6969 output := buf.String()
@@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
7474 }
7575
7676 func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) {
77- err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"})
77+ err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
7878 assert.Error(t, err)
7979 assert.Contains(t, err.Error(), "write failed")
8080 }
@@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) {
63 func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {63 func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
64 var buf bytes.Buffer64 var buf bytes.Buffer
65 65
66- err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"})66+ err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false)
67 assert.NoError(t, err)67 assert.NoError(t, err)
68 68
69 output := buf.String()69 output := buf.String()
@@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) {
74 }74 }
75 75
76 func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) {76 func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) {
77- err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"})77+ err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false)
78 assert.Error(t, err)78 assert.Error(t, err)
79 assert.Contains(t, err.Error(), "write failed")79 assert.Contains(t, err.Error(), "write failed")
80 }80 }
M internal/adapters/in/cli/root.go +4 -0
@@ -125,6 +125,10 @@ Commands are organized by where they run:
125125 imagesCmd.GroupID = groupManage
126126 rootCmd.AddCommand(imagesCmd)
127127
128+ bootstrapCmd := newBootstrapCmd()
129+ bootstrapCmd.GroupID = groupManage
130+ rootCmd.AddCommand(bootstrapCmd)
131+
128132 // Client-only commands (no server needed)
129133 remotesCmd := newRemotesCmd()
130134 remotesCmd.GroupID = groupClient
@@ -125,6 +125,10 @@ Commands are organized by where they run:
125 imagesCmd.GroupID = groupManage125 imagesCmd.GroupID = groupManage
126 rootCmd.AddCommand(imagesCmd)126 rootCmd.AddCommand(imagesCmd)
127 127
128+ bootstrapCmd := newBootstrapCmd()
129+ bootstrapCmd.GroupID = groupManage
130+ rootCmd.AddCommand(bootstrapCmd)
131+
128 // Client-only commands (no server needed)132 // Client-only commands (no server needed)
129 remotesCmd := newRemotesCmd()133 remotesCmd := newRemotesCmd()
130 remotesCmd.GroupID = groupClient134 remotesCmd.GroupID = groupClient
M internal/adapters/in/cli/routes.go +85 -20
@@ -3,6 +3,7 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "strings"
78
89 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string {
111112
112113 // newRoutesListCmd creates the routes list command.
113114 func newRoutesListCmd() *cobra.Command {
114- return &cobra.Command{
115+ var jsonOut bool
116+
117+ cmd := &cobra.Command{
115118 Use: "list",
116119 Short: "List all routes",
117120 RunE: func(cmd *cobra.Command, args []string) error {
@@ -119,31 +122,39 @@ func newRoutesListCmd() *cobra.Command {
119122
120123 client, isRemote := GetRemoteClient()
121124 if isRemote {
122- return runRoutesListRemote(ctx, client)
125+ return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
123126 }
124- return runRoutesListLocal(ctx, configPath)
127+ return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
125128 },
126129 }
130+
131+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
132+
133+ return cmd
127134 }
128135
129136 // runRoutesListRemote lists routes from a remote Gordon instance.
130-func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
137+func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
131138 routes, err := client.ListRoutesWithDetails(ctx)
132139 if err != nil {
133140 return fmt.Errorf("failed to list routes: %w", err)
134141 }
135142
136- if len(routes) == 0 {
137- fmt.Println(styles.Theme.Muted.Render("No routes configured"))
138- return nil
139- }
140-
141143 // Get health status for each route (includes container status and HTTP probe)
142144 health, _ := client.GetHealth(ctx)
143145 if health == nil {
144146 health = make(map[string]*remote.RouteHealth)
145147 }
146148
149+ if jsonOut {
150+ return routesListJSON(out, routes, health)
151+ }
152+
153+ if len(routes) == 0 {
154+ _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
155+ return nil
156+ }
157+
147158 const imageColWidth = 35
148159 const networkColWidth = 22
149160 rows := make([][]string, 0, len(routes))
@@ -199,15 +210,55 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
199210 components.WithRows(rows),
200211 )
201212
202- fmt.Println(styles.Theme.Title.Render("Routes"))
203- fmt.Println()
204- fmt.Println(table.View())
213+ _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
214+ _, _ = fmt.Fprintln(out)
215+ _, _ = fmt.Fprintln(out, table.View())
205216
206217 return nil
207218 }
208219
220+func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
221+ payload := make([]map[string]any, 0, len(routes))
222+ for _, route := range routes {
223+ routeHealth := health[route.Domain]
224+ containerStatus := route.ContainerStatus
225+ if containerStatus == "" {
226+ if routeHealth != nil {
227+ containerStatus = routeHealth.ContainerStatus
228+ } else {
229+ containerStatus = "unknown"
230+ }
231+ }
232+
233+ httpStatus := 0
234+ if routeHealth != nil {
235+ httpStatus = routeHealth.HTTPStatus
236+ }
237+
238+ attachments := make([]map[string]string, 0, len(route.Attachments))
239+ for _, attachment := range route.Attachments {
240+ attachments = append(attachments, map[string]string{
241+ "name": attachment.Name,
242+ "image": attachment.Image,
243+ "status": attachment.Status,
244+ })
245+ }
246+
247+ payload = append(payload, map[string]any{
248+ "domain": route.Domain,
249+ "image": route.Image,
250+ "container_status": containerStatus,
251+ "network": route.Network,
252+ "http_status": httpStatus,
253+ "attachments": attachments,
254+ })
255+ }
256+
257+ return writeJSON(out, payload)
258+}
259+
209260 // runRoutesListLocal lists routes from local configuration.
210-func runRoutesListLocal(ctx context.Context, cfgPath string) error {
261+func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
211262 local, err := GetLocalServices(cfgPath)
212263 if err != nil {
213264 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -215,8 +266,19 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error {
215266
216267 routes := local.GetConfigService().GetRoutes(ctx)
217268
269+ if jsonOut {
270+ payload := make([]map[string]string, 0, len(routes))
271+ for _, route := range routes {
272+ payload = append(payload, map[string]string{
273+ "domain": route.Domain,
274+ "image": route.Image,
275+ })
276+ }
277+ return writeJSON(out, payload)
278+ }
279+
218280 if len(routes) == 0 {
219- fmt.Println(styles.Theme.Muted.Render("No routes configured"))
281+ _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
220282 return nil
221283 }
222284
@@ -237,9 +299,9 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error {
237299 components.WithRows(rows),
238300 )
239301
240- fmt.Println(styles.Theme.Title.Render("Routes (local)"))
241- fmt.Println()
242- fmt.Println(table.View())
302+ _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
303+ _, _ = fmt.Fprintln(out)
304+ _, _ = fmt.Fprintln(out, table.View())
243305
244306 return nil
245307 }
@@ -250,8 +312,11 @@ func newRoutesAddCmd() *cobra.Command {
250312
251313 cmd := &cobra.Command{
252314 Use: "add <domain> <image>",
253- Short: "Add a new route",
254- Long: `Add a new route mapping a domain to a container image.
315+ Short: "Create or update a route",
316+ Long: `Create or update a route mapping a domain to a container image.
317+
318+If the route already exists with the same image, this is a no-op.
319+If it exists with a different image, the image is updated.
255320
256321 Examples:
257322 gordon routes add app.mydomain.com myapp:latest
@@ -299,7 +364,7 @@ Examples:
299364 }
300365 }
301366
302- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
367+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
303368 return nil
304369 },
305370 }
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "io"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"9 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string {
111 112
112 // newRoutesListCmd creates the routes list command.113 // newRoutesListCmd creates the routes list command.
113 func newRoutesListCmd() *cobra.Command {114 func newRoutesListCmd() *cobra.Command {
114- return &cobra.Command{115+ var jsonOut bool
116+
117+ cmd := &cobra.Command{
115 Use: "list",118 Use: "list",
116 Short: "List all routes",119 Short: "List all routes",
117 RunE: func(cmd *cobra.Command, args []string) error {120 RunE: func(cmd *cobra.Command, args []string) error {
@@ -119,31 +122,39 @@ func newRoutesListCmd() *cobra.Command {
119 122
120 client, isRemote := GetRemoteClient()123 client, isRemote := GetRemoteClient()
121 if isRemote {124 if isRemote {
122- return runRoutesListRemote(ctx, client)125+ return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
123 }126 }
124- return runRoutesListLocal(ctx, configPath)127+ return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
125 },128 },
126 }129 }
130+
131+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
132+
133+ return cmd
127 }134 }
128 135
129 // runRoutesListRemote lists routes from a remote Gordon instance.136 // runRoutesListRemote lists routes from a remote Gordon instance.
130-func runRoutesListRemote(ctx context.Context, client *remote.Client) error {137+func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
131 routes, err := client.ListRoutesWithDetails(ctx)138 routes, err := client.ListRoutesWithDetails(ctx)
132 if err != nil {139 if err != nil {
133 return fmt.Errorf("failed to list routes: %w", err)140 return fmt.Errorf("failed to list routes: %w", err)
134 }141 }
135 142
136- if len(routes) == 0 {
137- fmt.Println(styles.Theme.Muted.Render("No routes configured"))
138- return nil
139- }
140-
141 // Get health status for each route (includes container status and HTTP probe)143 // Get health status for each route (includes container status and HTTP probe)
142 health, _ := client.GetHealth(ctx)144 health, _ := client.GetHealth(ctx)
143 if health == nil {145 if health == nil {
144 health = make(map[string]*remote.RouteHealth)146 health = make(map[string]*remote.RouteHealth)
145 }147 }
146 148
149+ if jsonOut {
150+ return routesListJSON(out, routes, health)
151+ }
152+
153+ if len(routes) == 0 {
154+ _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
155+ return nil
156+ }
157+
147 const imageColWidth = 35158 const imageColWidth = 35
148 const networkColWidth = 22159 const networkColWidth = 22
149 rows := make([][]string, 0, len(routes))160 rows := make([][]string, 0, len(routes))
@@ -199,15 +210,55 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
199 components.WithRows(rows),210 components.WithRows(rows),
200 )211 )
201 212
202- fmt.Println(styles.Theme.Title.Render("Routes"))213+ _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
203- fmt.Println()214+ _, _ = fmt.Fprintln(out)
204- fmt.Println(table.View())215+ _, _ = fmt.Fprintln(out, table.View())
205 216
206 return nil217 return nil
207 }218 }
208 219
220+func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
221+ payload := make([]map[string]any, 0, len(routes))
222+ for _, route := range routes {
223+ routeHealth := health[route.Domain]
224+ containerStatus := route.ContainerStatus
225+ if containerStatus == "" {
226+ if routeHealth != nil {
227+ containerStatus = routeHealth.ContainerStatus
228+ } else {
229+ containerStatus = "unknown"
230+ }
231+ }
232+
233+ httpStatus := 0
234+ if routeHealth != nil {
235+ httpStatus = routeHealth.HTTPStatus
236+ }
237+
238+ attachments := make([]map[string]string, 0, len(route.Attachments))
239+ for _, attachment := range route.Attachments {
240+ attachments = append(attachments, map[string]string{
241+ "name": attachment.Name,
242+ "image": attachment.Image,
243+ "status": attachment.Status,
244+ })
245+ }
246+
247+ payload = append(payload, map[string]any{
248+ "domain": route.Domain,
249+ "image": route.Image,
250+ "container_status": containerStatus,
251+ "network": route.Network,
252+ "http_status": httpStatus,
253+ "attachments": attachments,
254+ })
255+ }
256+
257+ return writeJSON(out, payload)
258+}
259+
209 // runRoutesListLocal lists routes from local configuration.260 // runRoutesListLocal lists routes from local configuration.
210-func runRoutesListLocal(ctx context.Context, cfgPath string) error {261+func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
211 local, err := GetLocalServices(cfgPath)262 local, err := GetLocalServices(cfgPath)
212 if err != nil {263 if err != nil {
213 return fmt.Errorf("failed to initialize local services: %w", err)264 return fmt.Errorf("failed to initialize local services: %w", err)
@@ -215,8 +266,19 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error {
215 266
216 routes := local.GetConfigService().GetRoutes(ctx)267 routes := local.GetConfigService().GetRoutes(ctx)
217 268
269+ if jsonOut {
270+ payload := make([]map[string]string, 0, len(routes))
271+ for _, route := range routes {
272+ payload = append(payload, map[string]string{
273+ "domain": route.Domain,
274+ "image": route.Image,
275+ })
276+ }
277+ return writeJSON(out, payload)
278+ }
279+
218 if len(routes) == 0 {280 if len(routes) == 0 {
219- fmt.Println(styles.Theme.Muted.Render("No routes configured"))281+ _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
220 return nil282 return nil
221 }283 }
222 284
@@ -237,9 +299,9 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error {
237 components.WithRows(rows),299 components.WithRows(rows),
238 )300 )
239 301
240- fmt.Println(styles.Theme.Title.Render("Routes (local)"))302+ _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
241- fmt.Println()303+ _, _ = fmt.Fprintln(out)
242- fmt.Println(table.View())304+ _, _ = fmt.Fprintln(out, table.View())
243 305
244 return nil306 return nil
245 }307 }
@@ -250,8 +312,11 @@ func newRoutesAddCmd() *cobra.Command {
250 312
251 cmd := &cobra.Command{313 cmd := &cobra.Command{
252 Use: "add <domain> <image>",314 Use: "add <domain> <image>",
253- Short: "Add a new route",315+ Short: "Create or update a route",
254- Long: `Add a new route mapping a domain to a container image.316+ Long: `Create or update a route mapping a domain to a container image.
317+
318+If the route already exists with the same image, this is a no-op.
319+If it exists with a different image, the image is updated.
255 320
256 Examples:321 Examples:
257 gordon routes add app.mydomain.com myapp:latest322 gordon routes add app.mydomain.com myapp:latest
@@ -299,7 +364,7 @@ Examples:
299 }364 }
300 }365 }
301 366
302- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))367+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image)))
303 return nil368 return nil
304 },369 },
305 }370 }
M internal/adapters/in/cli/secrets.go +29 -3
@@ -35,7 +35,9 @@ these commands operate on the remote server.`,
3535
3636 // newSecretsListCmd creates the secrets list command.
3737 func newSecretsListCmd() *cobra.Command {
38- return &cobra.Command{
38+ var jsonOut bool
39+
40+ cmd := &cobra.Command{
3941 Use: "list <domain>",
4042 Short: "List secrets for a domain",
4143 Long: `List all secret keys configured for a domain.
@@ -47,12 +49,18 @@ Examples:
4749 gordon secrets list app.mydomain.com
4850 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
4951 Args: cobra.ExactArgs(1),
50- RunE: runSecretsListCmd,
52+ RunE: func(cmd *cobra.Command, args []string) error {
53+ return runSecretsListCmd(cmd, args, jsonOut)
54+ },
5155 }
56+
57+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
58+
59+ return cmd
5260 }
5361
5462 // runSecretsListCmd executes the secrets list command.
55-func runSecretsListCmd(_ *cobra.Command, args []string) error {
63+func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
5664 ctx := context.Background()
5765 secretDomain := args[0]
5866
@@ -67,10 +75,28 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error {
6775 }
6876
6977 if totalSecrets == 0 {
78+ if jsonOut {
79+ return writeJSON(cmd.OutOrStdout(), map[string]any{
80+ "domain": secretDomain,
81+ "keys": []string{},
82+ "attachments": []any{},
83+ })
84+ }
7085 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
7186 return nil
7287 }
7388
89+ if jsonOut {
90+ if attachments == nil {
91+ attachments = []remote.AttachmentSecrets{}
92+ }
93+ return writeJSON(cmd.OutOrStdout(), map[string]any{
94+ "domain": secretDomain,
95+ "keys": keys,
96+ "attachments": attachments,
97+ })
98+ }
99+
74100 title := fmt.Sprintf("Secrets for %s", secretDomain)
75101 if !isRemote {
76102 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
@@ -35,7 +35,9 @@ these commands operate on the remote server.`,
35 35
36 // newSecretsListCmd creates the secrets list command.36 // newSecretsListCmd creates the secrets list command.
37 func newSecretsListCmd() *cobra.Command {37 func newSecretsListCmd() *cobra.Command {
38- return &cobra.Command{38+ var jsonOut bool
39+
40+ cmd := &cobra.Command{
39 Use: "list <domain>",41 Use: "list <domain>",
40 Short: "List secrets for a domain",42 Short: "List secrets for a domain",
41 Long: `List all secret keys configured for a domain.43 Long: `List all secret keys configured for a domain.
@@ -47,12 +49,18 @@ Examples:
47 gordon secrets list app.mydomain.com49 gordon secrets list app.mydomain.com
48 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,50 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
49 Args: cobra.ExactArgs(1),51 Args: cobra.ExactArgs(1),
50- RunE: runSecretsListCmd,52+ RunE: func(cmd *cobra.Command, args []string) error {
53+ return runSecretsListCmd(cmd, args, jsonOut)
54+ },
51 }55 }
56+
57+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
58+
59+ return cmd
52 }60 }
53 61
54 // runSecretsListCmd executes the secrets list command.62 // runSecretsListCmd executes the secrets list command.
55-func runSecretsListCmd(_ *cobra.Command, args []string) error {63+func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
56 ctx := context.Background()64 ctx := context.Background()
57 secretDomain := args[0]65 secretDomain := args[0]
58 66
@@ -67,10 +75,28 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error {
67 }75 }
68 76
69 if totalSecrets == 0 {77 if totalSecrets == 0 {
78+ if jsonOut {
79+ return writeJSON(cmd.OutOrStdout(), map[string]any{
80+ "domain": secretDomain,
81+ "keys": []string{},
82+ "attachments": []any{},
83+ })
84+ }
70 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))85 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
71 return nil86 return nil
72 }87 }
73 88
89+ if jsonOut {
90+ if attachments == nil {
91+ attachments = []remote.AttachmentSecrets{}
92+ }
93+ return writeJSON(cmd.OutOrStdout(), map[string]any{
94+ "domain": secretDomain,
95+ "keys": keys,
96+ "attachments": attachments,
97+ })
98+ }
99+
74 title := fmt.Sprintf("Secrets for %s", secretDomain)100 title := fmt.Sprintf("Secrets for %s", secretDomain)
75 if !isRemote {101 if !isRemote {
76 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)102 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
M internal/adapters/in/cli/ui_adoption_test.go +1 -1
@@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
202202 }
203203
204204 imgBuf := new(bytes.Buffer)
205- if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {
205+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil {
206206 t.Fatalf("runImagesList failed: %v", err)
207207 }
208208
@@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) {
202 }202 }
203 203
204 imgBuf := new(bytes.Buffer)204 imgBuf := new(bytes.Buffer)
205- if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil {205+ if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil {
206 t.Fatalf("runImagesList failed: %v", err)206 t.Fatalf("runImagesList failed: %v", err)
207 }207 }
208 208
A internal/adapters/in/http/admin/bootstrap_test.go +252 -0
@@ -0,0 +1,252 @@
1+package admin
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
21+ configSvc := inmocks.NewMockConfigService(t)
22+ authSvc := inmocks.NewMockAuthService(t)
23+ containerSvc := inmocks.NewMockContainerService(t)
24+ secretSvc := inmocks.NewMockSecretService(t)
25+ registrySvc := inmocks.NewMockRegistryService(t)
26+
27+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
28+
29+ payload := dto.BootstrapRequest{
30+ Domain: "app.example.com",
31+ Image: "myapp:latest",
32+ Attachments: []string{"postgres:16"},
33+ Env: map[string]string{"APP_ENV": "prod"},
34+ AttachmentEnv: map[string]map[string]string{
35+ "postgres": {"POSTGRES_PASSWORD": "secret"},
36+ },
37+ }
38+
39+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
40+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
41+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
42+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
43+
44+ body, err := json.Marshal(payload)
45+ require.NoError(t, err)
46+
47+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
48+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
49+ rec := httptest.NewRecorder()
50+
51+ handler.ServeHTTP(rec, req)
52+
53+ assert.Equal(t, http.StatusOK, rec.Code)
54+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
55+
56+ var response dto.BootstrapResponse
57+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
58+ assert.Equal(t, payload.Domain, response.Domain)
59+ assert.Equal(t, payload.Image, response.Image)
60+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
61+ assert.Equal(t, []dto.BootstrapStep{
62+ {Name: "route", Status: "configured"},
63+ {Name: "attachment:postgres:16", Status: "created"},
64+ {Name: "env", Status: "updated"},
65+ {Name: "attachment_env:postgres", Status: "updated"},
66+ }, response.Steps)
67+}
68+
69+func TestBootstrap_Idempotent_Rerun(t *testing.T) {
70+ configSvc := inmocks.NewMockConfigService(t)
71+ authSvc := inmocks.NewMockAuthService(t)
72+ containerSvc := inmocks.NewMockContainerService(t)
73+ secretSvc := inmocks.NewMockSecretService(t)
74+
75+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
76+
77+ payload := dto.BootstrapRequest{
78+ Domain: "app.example.com",
79+ Image: "myapp:latest",
80+ Attachments: []string{"postgres:16"},
81+ Env: map[string]string{"APP_ENV": "prod"},
82+ AttachmentEnv: map[string]map[string]string{
83+ "postgres": {"POSTGRES_PASSWORD": "secret"},
84+ },
85+ }
86+
87+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
88+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
89+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
90+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
91+
92+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
93+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
94+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
95+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
96+
97+ body, err := json.Marshal(payload)
98+ require.NoError(t, err)
99+
100+ for i := range 2 {
101+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
102+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
103+ rec := httptest.NewRecorder()
104+
105+ handler.ServeHTTP(rec, req)
106+
107+ assert.Equal(t, http.StatusOK, rec.Code)
108+
109+ var response dto.BootstrapResponse
110+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111+ assert.Equal(t, payload.Domain, response.Domain)
112+ assert.Equal(t, payload.Image, response.Image)
113+ require.Len(t, response.Steps, 4)
114+ if i == 0 {
115+ assert.Equal(t, []dto.BootstrapStep{
116+ {Name: "route", Status: "configured"},
117+ {Name: "attachment:postgres:16", Status: "created"},
118+ {Name: "env", Status: "updated"},
119+ {Name: "attachment_env:postgres", Status: "updated"},
120+ }, response.Steps)
121+ } else {
122+ assert.Equal(t, []dto.BootstrapStep{
123+ {Name: "route", Status: "configured"},
124+ {Name: "attachment:postgres:16", Status: "noop"},
125+ {Name: "env", Status: "updated"},
126+ {Name: "attachment_env:postgres", Status: "updated"},
127+ }, response.Steps)
128+ }
129+ }
130+}
131+
132+func TestBootstrap_MissingDomain(t *testing.T) {
133+ configSvc := inmocks.NewMockConfigService(t)
134+ authSvc := inmocks.NewMockAuthService(t)
135+ containerSvc := inmocks.NewMockContainerService(t)
136+ secretSvc := inmocks.NewMockSecretService(t)
137+
138+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
139+
140+ body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
141+ require.NoError(t, err)
142+
143+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
144+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
145+ rec := httptest.NewRecorder()
146+
147+ handler.ServeHTTP(rec, req)
148+
149+ assert.Equal(t, http.StatusBadRequest, rec.Code)
150+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
151+}
152+
153+func TestBootstrap_MissingImage(t *testing.T) {
154+ configSvc := inmocks.NewMockConfigService(t)
155+ authSvc := inmocks.NewMockAuthService(t)
156+ containerSvc := inmocks.NewMockContainerService(t)
157+ secretSvc := inmocks.NewMockSecretService(t)
158+
159+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
160+
161+ body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
162+ require.NoError(t, err)
163+
164+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
165+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
166+ rec := httptest.NewRecorder()
167+
168+ handler.ServeHTTP(rec, req)
169+
170+ assert.Equal(t, http.StatusBadRequest, rec.Code)
171+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172+}
173+
174+func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ mockErr error
178+ }{
179+ {
180+ name: "empty domain from service",
181+ mockErr: domain.ErrRouteDomainEmpty,
182+ },
183+ {
184+ name: "empty image from service",
185+ mockErr: domain.ErrRouteImageEmpty,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ configSvc := inmocks.NewMockConfigService(t)
192+ authSvc := inmocks.NewMockAuthService(t)
193+ containerSvc := inmocks.NewMockContainerService(t)
194+ secretSvc := inmocks.NewMockSecretService(t)
195+
196+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197+
198+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()
200+
201+ body, err := json.Marshal(payload)
202+ require.NoError(t, err)
203+
204+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
205+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
206+ rec := httptest.NewRecorder()
207+
208+ handler.ServeHTTP(rec, req)
209+
210+ assert.Equal(t, http.StatusBadRequest, rec.Code)
211+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
212+ })
213+ }
214+}
215+
216+func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
217+ configSvc := inmocks.NewMockConfigService(t)
218+ authSvc := inmocks.NewMockAuthService(t)
219+ containerSvc := inmocks.NewMockContainerService(t)
220+ secretSvc := inmocks.NewMockSecretService(t)
221+
222+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
223+
224+ payload := dto.BootstrapRequest{
225+ Domain: "app.example.com",
226+ Image: "myapp:latest",
227+ Attachments: []string{"postgres:16"},
228+ }
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
231+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232+
233+ body, err := json.Marshal(payload)
234+ require.NoError(t, err)
235+
236+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
237+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
238+ rec := httptest.NewRecorder()
239+
240+ handler.ServeHTTP(rec, req)
241+
242+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
243+
244+ var response dto.BootstrapResponse
245+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
246+ assert.Equal(t, []dto.BootstrapStep{
247+ {Name: "route", Status: "configured"},
248+ {Name: "attachment:postgres:16", Status: "failed"},
249+ }, response.Steps)
250+ secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
251+ secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
252+}
@@ -0,0 +1,252 @@
1+package admin
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
21+ configSvc := inmocks.NewMockConfigService(t)
22+ authSvc := inmocks.NewMockAuthService(t)
23+ containerSvc := inmocks.NewMockContainerService(t)
24+ secretSvc := inmocks.NewMockSecretService(t)
25+ registrySvc := inmocks.NewMockRegistryService(t)
26+
27+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
28+
29+ payload := dto.BootstrapRequest{
30+ Domain: "app.example.com",
31+ Image: "myapp:latest",
32+ Attachments: []string{"postgres:16"},
33+ Env: map[string]string{"APP_ENV": "prod"},
34+ AttachmentEnv: map[string]map[string]string{
35+ "postgres": {"POSTGRES_PASSWORD": "secret"},
36+ },
37+ }
38+
39+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
40+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
41+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
42+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
43+
44+ body, err := json.Marshal(payload)
45+ require.NoError(t, err)
46+
47+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
48+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
49+ rec := httptest.NewRecorder()
50+
51+ handler.ServeHTTP(rec, req)
52+
53+ assert.Equal(t, http.StatusOK, rec.Code)
54+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
55+
56+ var response dto.BootstrapResponse
57+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
58+ assert.Equal(t, payload.Domain, response.Domain)
59+ assert.Equal(t, payload.Image, response.Image)
60+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
61+ assert.Equal(t, []dto.BootstrapStep{
62+ {Name: "route", Status: "configured"},
63+ {Name: "attachment:postgres:16", Status: "created"},
64+ {Name: "env", Status: "updated"},
65+ {Name: "attachment_env:postgres", Status: "updated"},
66+ }, response.Steps)
67+}
68+
69+func TestBootstrap_Idempotent_Rerun(t *testing.T) {
70+ configSvc := inmocks.NewMockConfigService(t)
71+ authSvc := inmocks.NewMockAuthService(t)
72+ containerSvc := inmocks.NewMockContainerService(t)
73+ secretSvc := inmocks.NewMockSecretService(t)
74+
75+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
76+
77+ payload := dto.BootstrapRequest{
78+ Domain: "app.example.com",
79+ Image: "myapp:latest",
80+ Attachments: []string{"postgres:16"},
81+ Env: map[string]string{"APP_ENV": "prod"},
82+ AttachmentEnv: map[string]map[string]string{
83+ "postgres": {"POSTGRES_PASSWORD": "secret"},
84+ },
85+ }
86+
87+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
88+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
89+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
90+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
91+
92+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
93+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
94+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
95+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
96+
97+ body, err := json.Marshal(payload)
98+ require.NoError(t, err)
99+
100+ for i := range 2 {
101+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
102+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
103+ rec := httptest.NewRecorder()
104+
105+ handler.ServeHTTP(rec, req)
106+
107+ assert.Equal(t, http.StatusOK, rec.Code)
108+
109+ var response dto.BootstrapResponse
110+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111+ assert.Equal(t, payload.Domain, response.Domain)
112+ assert.Equal(t, payload.Image, response.Image)
113+ require.Len(t, response.Steps, 4)
114+ if i == 0 {
115+ assert.Equal(t, []dto.BootstrapStep{
116+ {Name: "route", Status: "configured"},
117+ {Name: "attachment:postgres:16", Status: "created"},
118+ {Name: "env", Status: "updated"},
119+ {Name: "attachment_env:postgres", Status: "updated"},
120+ }, response.Steps)
121+ } else {
122+ assert.Equal(t, []dto.BootstrapStep{
123+ {Name: "route", Status: "configured"},
124+ {Name: "attachment:postgres:16", Status: "noop"},
125+ {Name: "env", Status: "updated"},
126+ {Name: "attachment_env:postgres", Status: "updated"},
127+ }, response.Steps)
128+ }
129+ }
130+}
131+
132+func TestBootstrap_MissingDomain(t *testing.T) {
133+ configSvc := inmocks.NewMockConfigService(t)
134+ authSvc := inmocks.NewMockAuthService(t)
135+ containerSvc := inmocks.NewMockContainerService(t)
136+ secretSvc := inmocks.NewMockSecretService(t)
137+
138+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
139+
140+ body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
141+ require.NoError(t, err)
142+
143+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
144+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
145+ rec := httptest.NewRecorder()
146+
147+ handler.ServeHTTP(rec, req)
148+
149+ assert.Equal(t, http.StatusBadRequest, rec.Code)
150+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
151+}
152+
153+func TestBootstrap_MissingImage(t *testing.T) {
154+ configSvc := inmocks.NewMockConfigService(t)
155+ authSvc := inmocks.NewMockAuthService(t)
156+ containerSvc := inmocks.NewMockContainerService(t)
157+ secretSvc := inmocks.NewMockSecretService(t)
158+
159+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
160+
161+ body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
162+ require.NoError(t, err)
163+
164+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
165+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
166+ rec := httptest.NewRecorder()
167+
168+ handler.ServeHTTP(rec, req)
169+
170+ assert.Equal(t, http.StatusBadRequest, rec.Code)
171+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172+}
173+
174+func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ mockErr error
178+ }{
179+ {
180+ name: "empty domain from service",
181+ mockErr: domain.ErrRouteDomainEmpty,
182+ },
183+ {
184+ name: "empty image from service",
185+ mockErr: domain.ErrRouteImageEmpty,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ configSvc := inmocks.NewMockConfigService(t)
192+ authSvc := inmocks.NewMockAuthService(t)
193+ containerSvc := inmocks.NewMockContainerService(t)
194+ secretSvc := inmocks.NewMockSecretService(t)
195+
196+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197+
198+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()
200+
201+ body, err := json.Marshal(payload)
202+ require.NoError(t, err)
203+
204+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
205+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
206+ rec := httptest.NewRecorder()
207+
208+ handler.ServeHTTP(rec, req)
209+
210+ assert.Equal(t, http.StatusBadRequest, rec.Code)
211+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
212+ })
213+ }
214+}
215+
216+func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
217+ configSvc := inmocks.NewMockConfigService(t)
218+ authSvc := inmocks.NewMockAuthService(t)
219+ containerSvc := inmocks.NewMockContainerService(t)
220+ secretSvc := inmocks.NewMockSecretService(t)
221+
222+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
223+
224+ payload := dto.BootstrapRequest{
225+ Domain: "app.example.com",
226+ Image: "myapp:latest",
227+ Attachments: []string{"postgres:16"},
228+ }
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
231+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232+
233+ body, err := json.Marshal(payload)
234+ require.NoError(t, err)
235+
236+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
237+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
238+ rec := httptest.NewRecorder()
239+
240+ handler.ServeHTTP(rec, req)
241+
242+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
243+
244+ var response dto.BootstrapResponse
245+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
246+ assert.Equal(t, []dto.BootstrapStep{
247+ {Name: "route", Status: "configured"},
248+ {Name: "attachment:postgres:16", Status: "failed"},
249+ }, response.Steps)
250+ secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
251+ secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
252+}
M internal/adapters/in/http/admin/handler.go +140 -30
@@ -2,6 +2,7 @@
22 package admin
33
44 import (
5+ "context"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -201,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201202 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
202203 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
203204 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
205+ "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
204206 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
205207 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
206208 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
@@ -277,6 +279,144 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat
277279 })
278280 }
279281
282+func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
283+ if r.Method != http.MethodPost {
284+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
285+ return
286+ }
287+
288+ ctx := r.Context()
289+ log := zerowrap.FromCtx(ctx)
290+
291+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
292+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
293+ return
294+ }
295+
296+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
297+
298+ var req dto.BootstrapRequest
299+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
300+ log.Warn().Err(err).Msg("invalid bootstrap JSON")
301+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
302+ return
303+ }
304+
305+ if req.Domain == "" {
306+ h.sendError(w, http.StatusBadRequest, "domain is required")
307+ return
308+ }
309+
310+ if req.Image == "" {
311+ h.sendError(w, http.StatusBadRequest, "image is required")
312+ return
313+ }
314+
315+ if err := h.validateBootstrapPermissions(ctx, req); err != nil {
316+ h.sendError(w, http.StatusForbidden, err.Error())
317+ return
318+ }
319+
320+ resp := dto.BootstrapResponse{
321+ Domain: req.Domain,
322+ Image: req.Image,
323+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
324+ }
325+ addStep := func(name, status string) {
326+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
327+ }
328+
329+ err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
330+ switch {
331+ case err == nil:
332+ addStep("route", "configured")
333+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
334+ addStep("route", "failed")
335+ h.sendError(w, http.StatusBadRequest, err.Error())
336+ return
337+ default:
338+ addStep("route", "failed")
339+ log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
340+ h.sendJSON(w, http.StatusInternalServerError, resp)
341+ return
342+ }
343+
344+ if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil {
345+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments")
346+ h.sendJSON(w, http.StatusInternalServerError, resp)
347+ return
348+ }
349+
350+ if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil {
351+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
352+ h.sendJSON(w, http.StatusInternalServerError, resp)
353+ return
354+ }
355+
356+ if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil {
357+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env")
358+ h.sendJSON(w, http.StatusInternalServerError, resp)
359+ return
360+ }
361+
362+ h.sendJSON(w, http.StatusOK, resp)
363+}
364+
365+func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error {
366+ if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
367+ return errors.New("insufficient permissions for secrets:write")
368+ }
369+ if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
370+ return errors.New("insufficient permissions for config:write")
371+ }
372+
373+ return nil
374+}
375+
376+func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error {
377+ for _, attachment := range attachments {
378+ err := configSvc.AddAttachment(ctx, domainName, attachment)
379+ switch {
380+ case err == nil:
381+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"})
382+ case errors.Is(err, domain.ErrAttachmentExists):
383+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"})
384+ default:
385+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"})
386+ return err
387+ }
388+ }
389+
390+ return nil
391+}
392+
393+func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error {
394+ if len(env) == 0 {
395+ return nil
396+ }
397+
398+ if err := secretSvc.Set(ctx, domainName, env); err != nil {
399+ *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"})
400+ return err
401+ }
402+
403+ *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"})
404+ return nil
405+}
406+
407+func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error {
408+ for service, env := range attachmentEnv {
409+ if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil {
410+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"})
411+ return err
412+ }
413+
414+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"})
415+ }
416+
417+ return nil
418+}
419+
280420 // sendJSON sends a JSON response.
281421 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
282422 w.Header().Set("Content-Type", "application/json")
@@ -385,21 +525,6 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
385525 return
386526 }
387527
388- // Validate image exists in registry before adding route
389- if h.registrySvc != nil && route.Image != "" {
390- imageName, imageRef := validation.ParseImageReference(route.Image)
391- if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
392- if errors.Is(err, domain.ErrManifestNotFound) {
393- log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
394- h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
395- } else {
396- log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
397- h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
398- }
399- return
400- }
401- }
402-
403528 if err := h.configSvc.AddRoute(ctx, route); err != nil {
404529 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
405530 switch {
@@ -442,21 +567,6 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
442567
443568 route.Domain = routeDomain
444569
445- // Validate image exists in registry before updating route
446- if h.registrySvc != nil && route.Image != "" {
447- imageName, imageRef := validation.ParseImageReference(route.Image)
448- if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
449- if errors.Is(err, domain.ErrManifestNotFound) {
450- log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
451- h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
452- } else {
453- log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
454- h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
455- }
456- return
457- }
458- }
459-
460570 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
461571 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
462572 switch {
@@ -2,6 +2,7 @@
2 package admin2 package admin
3 3
4 import (4 import (
5+ "context"
5 "encoding/json"6 "encoding/json"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
@@ -201,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },202 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
202 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },203 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
203 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },204 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
205+ "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
204 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },206 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
205 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },207 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
206 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },208 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
@@ -277,6 +279,144 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat
277 })279 })
278 }280 }
279 281
282+func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
283+ if r.Method != http.MethodPost {
284+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
285+ return
286+ }
287+
288+ ctx := r.Context()
289+ log := zerowrap.FromCtx(ctx)
290+
291+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
292+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
293+ return
294+ }
295+
296+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
297+
298+ var req dto.BootstrapRequest
299+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
300+ log.Warn().Err(err).Msg("invalid bootstrap JSON")
301+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
302+ return
303+ }
304+
305+ if req.Domain == "" {
306+ h.sendError(w, http.StatusBadRequest, "domain is required")
307+ return
308+ }
309+
310+ if req.Image == "" {
311+ h.sendError(w, http.StatusBadRequest, "image is required")
312+ return
313+ }
314+
315+ if err := h.validateBootstrapPermissions(ctx, req); err != nil {
316+ h.sendError(w, http.StatusForbidden, err.Error())
317+ return
318+ }
319+
320+ resp := dto.BootstrapResponse{
321+ Domain: req.Domain,
322+ Image: req.Image,
323+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
324+ }
325+ addStep := func(name, status string) {
326+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
327+ }
328+
329+ err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
330+ switch {
331+ case err == nil:
332+ addStep("route", "configured")
333+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
334+ addStep("route", "failed")
335+ h.sendError(w, http.StatusBadRequest, err.Error())
336+ return
337+ default:
338+ addStep("route", "failed")
339+ log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
340+ h.sendJSON(w, http.StatusInternalServerError, resp)
341+ return
342+ }
343+
344+ if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil {
345+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments")
346+ h.sendJSON(w, http.StatusInternalServerError, resp)
347+ return
348+ }
349+
350+ if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil {
351+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
352+ h.sendJSON(w, http.StatusInternalServerError, resp)
353+ return
354+ }
355+
356+ if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil {
357+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env")
358+ h.sendJSON(w, http.StatusInternalServerError, resp)
359+ return
360+ }
361+
362+ h.sendJSON(w, http.StatusOK, resp)
363+}
364+
365+func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error {
366+ if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
367+ return errors.New("insufficient permissions for secrets:write")
368+ }
369+ if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
370+ return errors.New("insufficient permissions for config:write")
371+ }
372+
373+ return nil
374+}
375+
376+func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error {
377+ for _, attachment := range attachments {
378+ err := configSvc.AddAttachment(ctx, domainName, attachment)
379+ switch {
380+ case err == nil:
381+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"})
382+ case errors.Is(err, domain.ErrAttachmentExists):
383+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"})
384+ default:
385+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"})
386+ return err
387+ }
388+ }
389+
390+ return nil
391+}
392+
393+func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error {
394+ if len(env) == 0 {
395+ return nil
396+ }
397+
398+ if err := secretSvc.Set(ctx, domainName, env); err != nil {
399+ *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"})
400+ return err
401+ }
402+
403+ *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"})
404+ return nil
405+}
406+
407+func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error {
408+ for service, env := range attachmentEnv {
409+ if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil {
410+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"})
411+ return err
412+ }
413+
414+ *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"})
415+ }
416+
417+ return nil
418+}
419+
280 // sendJSON sends a JSON response.420 // sendJSON sends a JSON response.
281 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {421 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
282 w.Header().Set("Content-Type", "application/json")422 w.Header().Set("Content-Type", "application/json")
@@ -385,21 +525,6 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
385 return525 return
386 }526 }
387 527
388- // Validate image exists in registry before adding route
389- if h.registrySvc != nil && route.Image != "" {
390- imageName, imageRef := validation.ParseImageReference(route.Image)
391- if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
392- if errors.Is(err, domain.ErrManifestNotFound) {
393- log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
394- h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
395- } else {
396- log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
397- h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
398- }
399- return
400- }
401- }
402-
403 if err := h.configSvc.AddRoute(ctx, route); err != nil {528 if err := h.configSvc.AddRoute(ctx, route); err != nil {
404 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")529 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
405 switch {530 switch {
@@ -442,21 +567,6 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
442 567
443 route.Domain = routeDomain568 route.Domain = routeDomain
444 569
445- // Validate image exists in registry before updating route
446- if h.registrySvc != nil && route.Image != "" {
447- imageName, imageRef := validation.ParseImageReference(route.Image)
448- if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
449- if errors.Is(err, domain.ErrManifestNotFound) {
450- log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
451- h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
452- } else {
453- log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
454- h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
455- }
456- return
457- }
458- }
459-
460 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {570 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
461 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")571 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
462 switch {572 switch {
M internal/adapters/in/http/admin/handler_test.go +131 -58
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/mock"
1717
18+ "github.com/bnema/gordon/internal/adapters/dto"
1819 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1920 "github.com/bnema/gordon/internal/domain"
2021 )
@@ -158,7 +159,6 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
158159 t.Run(tt.name, func(t *testing.T) {
159160 if tt.wantStatus == http.StatusCreated {
160161 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
161- registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
162162 }
163163
164164 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
@@ -204,7 +204,6 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
204204 t.Run(tt.name, func(t *testing.T) {
205205 if tt.wantStatus == http.StatusOK {
206206 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
207- registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
208207 }
209208
210209 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
@@ -218,6 +217,52 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
218217 }
219218 }
220219
220+func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
221+ configSvc := inmocks.NewMockConfigService(t)
222+ authSvc := inmocks.NewMockAuthService(t)
223+ containerSvc := inmocks.NewMockContainerService(t)
224+ secretSvc := inmocks.NewMockSecretService(t)
225+ registrySvc := inmocks.NewMockRegistryService(t)
226+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
227+
228+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
231+
232+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
233+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
234+ rec := httptest.NewRecorder()
235+
236+ handler.ServeHTTP(rec, req)
237+
238+ assert.Equal(t, http.StatusCreated, rec.Code)
239+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String())
240+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
241+}
242+
243+func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T) {
244+ configSvc := inmocks.NewMockConfigService(t)
245+ authSvc := inmocks.NewMockAuthService(t)
246+ containerSvc := inmocks.NewMockContainerService(t)
247+ secretSvc := inmocks.NewMockSecretService(t)
248+ registrySvc := inmocks.NewMockRegistryService(t)
249+ route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}
250+
251+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
252+
253+ configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
254+
255+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))
256+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
257+ rec := httptest.NewRecorder()
258+
259+ handler.ServeHTTP(rec, req)
260+
261+ assert.Equal(t, http.StatusOK, rec.Code)
262+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String())
263+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
264+}
265+
221266 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
222267 configSvc := inmocks.NewMockConfigService(t)
223268 authSvc := inmocks.NewMockAuthService(t)
@@ -463,6 +508,90 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
463508 assert.Equal(t, "deleted", response.Status)
464509 }
465510
511+func TestHandler_Bootstrap_HappyPath(t *testing.T) {
512+ configSvc := inmocks.NewMockConfigService(t)
513+ authSvc := inmocks.NewMockAuthService(t)
514+ containerSvc := inmocks.NewMockContainerService(t)
515+ secretSvc := inmocks.NewMockSecretService(t)
516+ registrySvc := inmocks.NewMockRegistryService(t)
517+
518+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
519+
520+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
521+ configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
522+ secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
523+ secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
524+
525+ body, err := json.Marshal(dto.BootstrapRequest{
526+ Domain: "app.example.com",
527+ Image: "myapp:latest",
528+ Attachments: []string{"postgres:16"},
529+ Env: map[string]string{"APP_ENV": "prod"},
530+ AttachmentEnv: map[string]map[string]string{
531+ "postgres": {"POSTGRES_PASSWORD": "secret"},
532+ },
533+ })
534+ assert.NoError(t, err)
535+
536+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body))
537+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
538+ rec := httptest.NewRecorder()
539+
540+ handler.ServeHTTP(rec, req)
541+
542+ assert.Equal(t, http.StatusOK, rec.Code)
543+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
544+
545+ var response dto.BootstrapResponse
546+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
547+ assert.Equal(t, "app.example.com", response.Domain)
548+ assert.Equal(t, "myapp:latest", response.Image)
549+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
550+ assert.Equal(t, []dto.BootstrapStep{
551+ {Name: "route", Status: "configured"},
552+ {Name: "attachment:postgres:16", Status: "created"},
553+ {Name: "env", Status: "updated"},
554+ {Name: "attachment_env:postgres", Status: "updated"},
555+ }, response.Steps)
556+}
557+
558+func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
559+ configSvc := inmocks.NewMockConfigService(t)
560+ authSvc := inmocks.NewMockAuthService(t)
561+ containerSvc := inmocks.NewMockContainerService(t)
562+ secretSvc := inmocks.NewMockSecretService(t)
563+
564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
565+
566+ tests := []struct {
567+ name string
568+ scopes []string
569+ body string
570+ wantStatus int
571+ }{
572+ {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK},
573+ {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden},
574+ {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden},
575+ {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden},
576+ }
577+
578+ for _, tt := range tests {
579+ t.Run(tt.name, func(t *testing.T) {
580+ if tt.wantStatus == http.StatusOK {
581+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
582+ }
583+
584+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
585+ req = req.WithContext(ctxWithScopes(tt.scopes...))
586+ rec := httptest.NewRecorder()
587+
588+ handler.ServeHTTP(rec, req)
589+
590+ assert.Equal(t, tt.wantStatus, rec.Code)
591+ })
592+ }
593+}
594+
466595 // Status endpoint tests
467596
468597 func TestHandler_Status_RequiresReadScope(t *testing.T) {
@@ -749,61 +878,6 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
749878 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
750879
751880 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
752- registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
753-
754- req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
755- req = req.WithContext(ctxWithScopes("admin:routes:write"))
756- rec := httptest.NewRecorder()
757-
758- handler.ServeHTTP(rec, req)
759-
760- assert.Equal(t, http.StatusBadRequest, rec.Code)
761- })
762- }
763-}
764-
765-func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
766- configSvc := inmocks.NewMockConfigService(t)
767- authSvc := inmocks.NewMockAuthService(t)
768- containerSvc := inmocks.NewMockContainerService(t)
769- secretSvc := inmocks.NewMockSecretService(t)
770- registrySvc := inmocks.NewMockRegistryService(t)
771-
772- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
773-
774- tests := []struct {
775- name string
776- image string
777- body string
778- manifestName string
779- manifestRef string
780- }{
781- {
782- name: "tagged image not found",
783- image: "myapp:latest",
784- body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
785- manifestName: "myapp",
786- manifestRef: "latest",
787- },
788- {
789- name: "digest image not found",
790- image: "myapp@sha256:abc123",
791- body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
792- manifestName: "myapp",
793- manifestRef: "sha256:abc123",
794- },
795- {
796- name: "implicit latest tag not found",
797- image: "myapp",
798- body: `{"domain": "app.example.com", "image": "myapp"}`,
799- manifestName: "myapp",
800- manifestRef: "latest",
801- },
802- }
803-
804- for _, tt := range tests {
805- t.Run(tt.name, func(t *testing.T) {
806- registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
807881
808882 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
809883 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -812,7 +886,6 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
812886 handler.ServeHTTP(rec, req)
813887
814888 assert.Equal(t, http.StatusBadRequest, rec.Code)
815- assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
816889 })
817890 }
818891 }
@@ -15,6 +15,7 @@ import (
15 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
17 17
18+ "github.com/bnema/gordon/internal/adapters/dto"
18 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"19 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
19 "github.com/bnema/gordon/internal/domain"20 "github.com/bnema/gordon/internal/domain"
20 )21 )
@@ -158,7 +159,6 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
158 t.Run(tt.name, func(t *testing.T) {159 t.Run(tt.name, func(t *testing.T) {
159 if tt.wantStatus == http.StatusCreated {160 if tt.wantStatus == http.StatusCreated {
160 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()161 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
161- registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
162 }162 }
163 163
164 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))164 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
@@ -204,7 +204,6 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
204 t.Run(tt.name, func(t *testing.T) {204 t.Run(tt.name, func(t *testing.T) {
205 if tt.wantStatus == http.StatusOK {205 if tt.wantStatus == http.StatusOK {
206 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()206 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
207- registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
208 }207 }
209 208
210 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))209 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
@@ -218,6 +217,52 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
218 }217 }
219 }218 }
220 219
220+func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
221+ configSvc := inmocks.NewMockConfigService(t)
222+ authSvc := inmocks.NewMockAuthService(t)
223+ containerSvc := inmocks.NewMockContainerService(t)
224+ secretSvc := inmocks.NewMockSecretService(t)
225+ registrySvc := inmocks.NewMockRegistryService(t)
226+ route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"}
227+
228+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
231+
232+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
233+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
234+ rec := httptest.NewRecorder()
235+
236+ handler.ServeHTTP(rec, req)
237+
238+ assert.Equal(t, http.StatusCreated, rec.Code)
239+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String())
240+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
241+}
242+
243+func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T) {
244+ configSvc := inmocks.NewMockConfigService(t)
245+ authSvc := inmocks.NewMockAuthService(t)
246+ containerSvc := inmocks.NewMockContainerService(t)
247+ secretSvc := inmocks.NewMockSecretService(t)
248+ registrySvc := inmocks.NewMockRegistryService(t)
249+ route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"}
250+
251+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
252+
253+ configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
254+
255+ req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))
256+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
257+ rec := httptest.NewRecorder()
258+
259+ handler.ServeHTTP(rec, req)
260+
261+ assert.Equal(t, http.StatusOK, rec.Code)
262+ assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String())
263+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
264+}
265+
221 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {266 func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
222 configSvc := inmocks.NewMockConfigService(t)267 configSvc := inmocks.NewMockConfigService(t)
223 authSvc := inmocks.NewMockAuthService(t)268 authSvc := inmocks.NewMockAuthService(t)
@@ -463,6 +508,90 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
463 assert.Equal(t, "deleted", response.Status)508 assert.Equal(t, "deleted", response.Status)
464 }509 }
465 510
511+func TestHandler_Bootstrap_HappyPath(t *testing.T) {
512+ configSvc := inmocks.NewMockConfigService(t)
513+ authSvc := inmocks.NewMockAuthService(t)
514+ containerSvc := inmocks.NewMockContainerService(t)
515+ secretSvc := inmocks.NewMockSecretService(t)
516+ registrySvc := inmocks.NewMockRegistryService(t)
517+
518+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
519+
520+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
521+ configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
522+ secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
523+ secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
524+
525+ body, err := json.Marshal(dto.BootstrapRequest{
526+ Domain: "app.example.com",
527+ Image: "myapp:latest",
528+ Attachments: []string{"postgres:16"},
529+ Env: map[string]string{"APP_ENV": "prod"},
530+ AttachmentEnv: map[string]map[string]string{
531+ "postgres": {"POSTGRES_PASSWORD": "secret"},
532+ },
533+ })
534+ assert.NoError(t, err)
535+
536+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body))
537+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
538+ rec := httptest.NewRecorder()
539+
540+ handler.ServeHTTP(rec, req)
541+
542+ assert.Equal(t, http.StatusOK, rec.Code)
543+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
544+
545+ var response dto.BootstrapResponse
546+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
547+ assert.Equal(t, "app.example.com", response.Domain)
548+ assert.Equal(t, "myapp:latest", response.Image)
549+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
550+ assert.Equal(t, []dto.BootstrapStep{
551+ {Name: "route", Status: "configured"},
552+ {Name: "attachment:postgres:16", Status: "created"},
553+ {Name: "env", Status: "updated"},
554+ {Name: "attachment_env:postgres", Status: "updated"},
555+ }, response.Steps)
556+}
557+
558+func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
559+ configSvc := inmocks.NewMockConfigService(t)
560+ authSvc := inmocks.NewMockAuthService(t)
561+ containerSvc := inmocks.NewMockContainerService(t)
562+ secretSvc := inmocks.NewMockSecretService(t)
563+
564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
565+
566+ tests := []struct {
567+ name string
568+ scopes []string
569+ body string
570+ wantStatus int
571+ }{
572+ {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK},
573+ {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden},
574+ {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden},
575+ {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden},
576+ }
577+
578+ for _, tt := range tests {
579+ t.Run(tt.name, func(t *testing.T) {
580+ if tt.wantStatus == http.StatusOK {
581+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
582+ }
583+
584+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
585+ req = req.WithContext(ctxWithScopes(tt.scopes...))
586+ rec := httptest.NewRecorder()
587+
588+ handler.ServeHTTP(rec, req)
589+
590+ assert.Equal(t, tt.wantStatus, rec.Code)
591+ })
592+ }
593+}
594+
466 // Status endpoint tests595 // Status endpoint tests
467 596
468 func TestHandler_Status_RequiresReadScope(t *testing.T) {597 func TestHandler_Status_RequiresReadScope(t *testing.T) {
@@ -749,61 +878,6 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
749 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)878 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
750 879
751 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)880 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
752- registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
753-
754- req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
755- req = req.WithContext(ctxWithScopes("admin:routes:write"))
756- rec := httptest.NewRecorder()
757-
758- handler.ServeHTTP(rec, req)
759-
760- assert.Equal(t, http.StatusBadRequest, rec.Code)
761- })
762- }
763-}
764-
765-func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
766- configSvc := inmocks.NewMockConfigService(t)
767- authSvc := inmocks.NewMockAuthService(t)
768- containerSvc := inmocks.NewMockContainerService(t)
769- secretSvc := inmocks.NewMockSecretService(t)
770- registrySvc := inmocks.NewMockRegistryService(t)
771-
772- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
773-
774- tests := []struct {
775- name string
776- image string
777- body string
778- manifestName string
779- manifestRef string
780- }{
781- {
782- name: "tagged image not found",
783- image: "myapp:latest",
784- body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
785- manifestName: "myapp",
786- manifestRef: "latest",
787- },
788- {
789- name: "digest image not found",
790- image: "myapp@sha256:abc123",
791- body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
792- manifestName: "myapp",
793- manifestRef: "sha256:abc123",
794- },
795- {
796- name: "implicit latest tag not found",
797- image: "myapp",
798- body: `{"domain": "app.example.com", "image": "myapp"}`,
799- manifestName: "myapp",
800- manifestRef: "latest",
801- },
802- }
803-
804- for _, tt := range tests {
805- t.Run(tt.name, func(t *testing.T) {
806- registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
807 881
808 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))882 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
809 req = req.WithContext(ctxWithScopes("admin:routes:write"))883 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -812,7 +886,6 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
812 handler.ServeHTTP(rec, req)886 handler.ServeHTTP(rec, req)
813 887
814 assert.Equal(t, http.StatusBadRequest, rec.Code)888 assert.Equal(t, http.StatusBadRequest, rec.Code)
815- assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
816 })889 })
817 }890 }
818 }891 }
M internal/usecase/config/service.go +4 -0
@@ -315,6 +315,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
315315 if s.config.Routes == nil {
316316 s.config.Routes = make(map[string]string)
317317 }
318+ if existed && previousImage == route.Image {
319+ s.mu.Unlock()
320+ return nil
321+ }
318322 s.config.Routes[route.Domain] = route.Image
319323 s.mu.Unlock()
320324
@@ -315,6 +315,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
315 if s.config.Routes == nil {315 if s.config.Routes == nil {
316 s.config.Routes = make(map[string]string)316 s.config.Routes = make(map[string]string)
317 }317 }
318+ if existed && previousImage == route.Image {
319+ s.mu.Unlock()
320+ return nil
321+ }
318 s.config.Routes[route.Domain] = route.Image322 s.config.Routes[route.Domain] = route.Image
319 s.mu.Unlock()323 s.mu.Unlock()
320 324
M internal/usecase/config/service_test.go +35 -0
@@ -260,6 +260,41 @@ func TestService_AddRoute(t *testing.T) {
260260 assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
261261 })
262262
263+ t.Run("idempotent same domain and image saves once", func(t *testing.T) {
264+ tmpDir := t.TempDir()
265+ configFile := filepath.Join(tmpDir, "gordon.toml")
266+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
267+ require.NoError(t, err)
268+
269+ v := viper.New()
270+ v.SetConfigFile(configFile)
271+ eventBus := mocks.NewMockEventPublisher(t)
272+ svc := NewService(v, eventBus)
273+ ctx := testContext()
274+
275+ _ = svc.Load(ctx)
276+
277+ route := domain.Route{
278+ Domain: "new.example.com",
279+ Image: "newapp:latest",
280+ }
281+
282+ err = svc.AddRoute(ctx, route)
283+ require.NoError(t, err)
284+
285+ before, err := os.Stat(configFile)
286+ require.NoError(t, err)
287+
288+ err = svc.AddRoute(ctx, route)
289+ require.NoError(t, err)
290+
291+ after, err := os.Stat(configFile)
292+ require.NoError(t, err)
293+
294+ assert.Equal(t, before.ModTime(), after.ModTime())
295+ assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
296+ })
297+
263298 t.Run("empty domain", func(t *testing.T) {
264299 v := viper.New()
265300 eventBus := mocks.NewMockEventPublisher(t)
@@ -260,6 +260,41 @@ func TestService_AddRoute(t *testing.T) {
260 assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])260 assert.Equal(t, "newapp:latest", config.Routes["new.example.com"])
261 })261 })
262 262
263+ t.Run("idempotent same domain and image saves once", func(t *testing.T) {
264+ tmpDir := t.TempDir()
265+ configFile := filepath.Join(tmpDir, "gordon.toml")
266+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
267+ require.NoError(t, err)
268+
269+ v := viper.New()
270+ v.SetConfigFile(configFile)
271+ eventBus := mocks.NewMockEventPublisher(t)
272+ svc := NewService(v, eventBus)
273+ ctx := testContext()
274+
275+ _ = svc.Load(ctx)
276+
277+ route := domain.Route{
278+ Domain: "new.example.com",
279+ Image: "newapp:latest",
280+ }
281+
282+ err = svc.AddRoute(ctx, route)
283+ require.NoError(t, err)
284+
285+ before, err := os.Stat(configFile)
286+ require.NoError(t, err)
287+
288+ err = svc.AddRoute(ctx, route)
289+ require.NoError(t, err)
290+
291+ after, err := os.Stat(configFile)
292+ require.NoError(t, err)
293+
294+ assert.Equal(t, before.ModTime(), after.ModTime())
295+ assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
296+ })
297+
263 t.Run("empty domain", func(t *testing.T) {298 t.Run("empty domain", func(t *testing.T) {
264 v := viper.New()299 v := viper.New()
265 eventBus := mocks.NewMockEventPublisher(t)300 eventBus := mocks.NewMockEventPublisher(t)

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