detached 893d8fa

Merge pull request #49 from bnema/feature/cli-improvements

feat(cli): add push, restart, and rollback commands with selector UI
commit
893d8fa5d886dbd160b90095d66e0c799c789a56
author
bnema · · 2026-02-02 18:29 UTC
committer
GitHub · · 2026-02-02 18:29 UTC
merge of
62accd9 + 486766f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.10.0
21 files · +999 -42
M docs/cli/attachments.md +6 -3
@@ -2,6 +2,9 @@
22
33 Manage attachments on local or remote Gordon instances.
44
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
58 ## Requirements
69
710 Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
@@ -42,7 +45,7 @@ gordon attachments list
4245 gordon attachments list app.example.com
4346 gordon attachments list backend
4447
45-# Remote mode
48+# Remote (override)
4649 gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
4750 ```
4851
@@ -91,7 +94,7 @@ gordon attachments add app.example.com postgres:18
9194 # Add cache to a network group (shared by all domains in the group)
9295 gordon attachments add backend redis:7-alpine
9396
94-# Remote
97+# Remote (override)
9598 gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
9699 ```
97100
@@ -129,7 +132,7 @@ gordon attachments remove app.example.com postgres:18
129132 # Remove from network group
130133 gordon attachments remove backend redis:7-alpine
131134
132-# Remote
135+# Remote (override)
133136 gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
134137 ```
135138
@@ -2,6 +2,9 @@
2 2
3 Manage attachments on local or remote Gordon instances.3 Manage attachments on local or remote Gordon instances.
4 4
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
5 ## Requirements8 ## Requirements
6 9
7 Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).10 Attachments require `network_isolation.enabled = true` in your configuration. Without network isolation, containers use Docker's default bridge network which does not provide DNS resolution - your app won't be able to reach attachments by hostname (e.g., `postgres:5432`).
@@ -42,7 +45,7 @@ gordon attachments list
42 gordon attachments list app.example.com45 gordon attachments list app.example.com
43 gordon attachments list backend46 gordon attachments list backend
44 47
45-# Remote mode48+# Remote (override)
46 gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN49 gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN
47 ```50 ```
48 51
@@ -91,7 +94,7 @@ gordon attachments add app.example.com postgres:18
91 # Add cache to a network group (shared by all domains in the group)94 # Add cache to a network group (shared by all domains in the group)
92 gordon attachments add backend redis:7-alpine95 gordon attachments add backend redis:7-alpine
93 96
94-# Remote97+# Remote (override)
95 gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN98 gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
96 ```99 ```
97 100
@@ -129,7 +132,7 @@ gordon attachments remove app.example.com postgres:18
129 # Remove from network group132 # Remove from network group
130 gordon attachments remove backend redis:7-alpine133 gordon attachments remove backend redis:7-alpine
131 134
132-# Remote135+# Remote (override)
133 gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN136 gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN
134 ```137 ```
135 138
M docs/cli/index.md +20 -3
@@ -5,7 +5,7 @@ Gordon provides a command-line interface for server management, deployment, and
55 Commands are organized by where they run:
66
77 - **Server Commands** - Must run on the machine hosting Gordon
8-- **Management Commands** - Work locally or remotely via `--remote` flag
8+- **Management Commands** - Work locally or remotely
99 - **Client Commands** - CLI utilities that don't require a running Gordon server
1010
1111 ## Server Commands (local only)
@@ -15,7 +15,7 @@ Commands are organized by where they run:
1515 | `gordon serve` | Start the Gordon server | [serve](./serve.md) |
1616 | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) |
1717
18-## Management Commands (local or --remote)
18+## Management Commands (local or remote)
1919
2020 | Command | Description | Documentation |
2121 |---------|-------------|---------------|
@@ -23,6 +23,9 @@ Commands are organized by where they run:
2323 | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |
2424 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
2525 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
26+| `gordon restart` | Restart a running container | [restart](./restart.md) |
27+| `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
28+| `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
2629 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
2730 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
2831 | `gordon status` | Show Gordon server status | [status](./status.md) |
@@ -48,6 +51,18 @@ gordon reload
4851 # Deploy a specific route
4952 gordon deploy myapp.example.com
5053
54+# Restart a running container
55+gordon restart myapp.example.com
56+
57+# Push an image and deploy
58+gordon push myapp.example.com --build
59+
60+# Push and deploy without confirmation
61+gordon push myapp.example.com --no-confirm
62+
63+# Roll back to a previous tag
64+gordon rollback myapp.example.com
65+
5166 # View logs
5267 gordon logs # Gordon process logs
5368 gordon logs -f # Follow process logs
@@ -97,7 +112,9 @@ gordon remotes use prod
97112
98113 ### Remote Targeting
99114
100-The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable.
115+The CLI can target remote Gordon instances using client config, an active remote, `--remote`,
116+or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
117+when you want to bypass your saved configuration.
101118
102119 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
103120
@@ -5,7 +5,7 @@ Gordon provides a command-line interface for server management, deployment, and
5 Commands are organized by where they run:5 Commands are organized by where they run:
6 6
7 - **Server Commands** - Must run on the machine hosting Gordon7 - **Server Commands** - Must run on the machine hosting Gordon
8-- **Management Commands** - Work locally or remotely via `--remote` flag8+- **Management Commands** - Work locally or remotely
9 - **Client Commands** - CLI utilities that don't require a running Gordon server9 - **Client Commands** - CLI utilities that don't require a running Gordon server
10 10
11 ## Server Commands (local only)11 ## Server Commands (local only)
@@ -15,7 +15,7 @@ Commands are organized by where they run:
15 | `gordon serve` | Start the Gordon server | [serve](./serve.md) |15 | `gordon serve` | Start the Gordon server | [serve](./serve.md) |
16 | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) |16 | `gordon auth` | Manage Gordon server authentication | [auth](./auth.md) |
17 17
18-## Management Commands (local or --remote)18+## Management Commands (local or remote)
19 19
20 | Command | Description | Documentation |20 | Command | Description | Documentation |
21 |---------|-------------|---------------|21 |---------|-------------|---------------|
@@ -23,6 +23,9 @@ Commands are organized by where they run:
23 | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |23 | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) |
24 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |24 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
25 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |25 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
26+| `gordon restart` | Restart a running container | [restart](./restart.md) |
27+| `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
28+| `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
26 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |29 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
27 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |30 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
28 | `gordon status` | Show Gordon server status | [status](./status.md) |31 | `gordon status` | Show Gordon server status | [status](./status.md) |
@@ -48,6 +51,18 @@ gordon reload
48 # Deploy a specific route51 # Deploy a specific route
49 gordon deploy myapp.example.com52 gordon deploy myapp.example.com
50 53
54+# Restart a running container
55+gordon restart myapp.example.com
56+
57+# Push an image and deploy
58+gordon push myapp.example.com --build
59+
60+# Push and deploy without confirmation
61+gordon push myapp.example.com --no-confirm
62+
63+# Roll back to a previous tag
64+gordon rollback myapp.example.com
65+
51 # View logs66 # View logs
52 gordon logs # Gordon process logs67 gordon logs # Gordon process logs
53 gordon logs -f # Follow process logs68 gordon logs -f # Follow process logs
@@ -97,7 +112,9 @@ gordon remotes use prod
97 112
98 ### Remote Targeting113 ### Remote Targeting
99 114
100-The CLI can target remote Gordon instances using the `--remote` flag or `GORDON_REMOTE` environment variable.115+The CLI can target remote Gordon instances using client config, an active remote, `--remote`,
116+or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
117+when you want to bypass your saved configuration.
101 118
102 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.119 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
103 120
A docs/cli/push.md +70 -0
@@ -0,0 +1,70 @@
1+# Push Command
2+
3+Tag, push, and optionally deploy an image to the Gordon registry.
4+
5+## gordon push
6+
7+### Synopsis
8+
9+```bash
10+gordon push <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to resolve the registry and repository from |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--build` | Build the image first using `docker buildx` |
24+| `--platform` | Target platform for buildx (default: `linux/amd64`) |
25+| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
26+| `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) |
27+| `--no-confirm` | Skip deploy confirmation prompt |
28+| `--no-deploy` | Push only; skip deployment prompt |
29+| `--remote` | Remote Gordon URL |
30+| `--token` | Authentication token for remote |
31+
32+### Description
33+
34+`gordon push` resolves the route image for `<domain>`, tags the image for the
35+Gordon registry, pushes it, and optionally deploys it.
36+
37+- The registry and repository are derived from the route image on the server.
38+- The version tag defaults to the latest git tag. If no tag is found, `latest`
39+ is used.
40+- When `--build` is set, the command uses `docker buildx build --push` and
41+ injects `VERSION=<tag>` plus any `--build-arg` values.
42+- The version tag and `latest` are both pushed (unless the version is `latest`).
43+
44+### Examples
45+
46+```bash
47+# Build, push, and deploy
48+gordon push myapp.example.com --build
49+
50+# Push existing local image, skip deploy
51+gordon push myapp.example.com --tag v1.2.0 --no-deploy
52+
53+# Push and deploy without confirmation
54+gordon push myapp.example.com --no-confirm
55+
56+# Build for ARM and pass build args
57+gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0
58+```
59+
60+### Notes
61+
62+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
63+- `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
64+ install the `docker-buildx-plugin` package.
65+
66+## Related
67+
68+- [CLI Overview](./index.md)
69+- [Routes Command](./routes.md)
70+- [Serve Command](./serve.md)
@@ -0,0 +1,70 @@
1+# Push Command
2+
3+Tag, push, and optionally deploy an image to the Gordon registry.
4+
5+## gordon push
6+
7+### Synopsis
8+
9+```bash
10+gordon push <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to resolve the registry and repository from |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--build` | Build the image first using `docker buildx` |
24+| `--platform` | Target platform for buildx (default: `linux/amd64`) |
25+| `--build-arg` | Additional build args (repeatable, `KEY=VALUE`) |
26+| `--tag` | Override version tag (default: `git describe --tags --abbrev=0`) |
27+| `--no-confirm` | Skip deploy confirmation prompt |
28+| `--no-deploy` | Push only; skip deployment prompt |
29+| `--remote` | Remote Gordon URL |
30+| `--token` | Authentication token for remote |
31+
32+### Description
33+
34+`gordon push` resolves the route image for `<domain>`, tags the image for the
35+Gordon registry, pushes it, and optionally deploys it.
36+
37+- The registry and repository are derived from the route image on the server.
38+- The version tag defaults to the latest git tag. If no tag is found, `latest`
39+ is used.
40+- When `--build` is set, the command uses `docker buildx build --push` and
41+ injects `VERSION=<tag>` plus any `--build-arg` values.
42+- The version tag and `latest` are both pushed (unless the version is `latest`).
43+
44+### Examples
45+
46+```bash
47+# Build, push, and deploy
48+gordon push myapp.example.com --build
49+
50+# Push existing local image, skip deploy
51+gordon push myapp.example.com --tag v1.2.0 --no-deploy
52+
53+# Push and deploy without confirmation
54+gordon push myapp.example.com --no-confirm
55+
56+# Build for ARM and pass build args
57+gordon push myapp.example.com --build --platform linux/arm64 --build-arg CGO_ENABLED=0
58+```
59+
60+### Notes
61+
62+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
63+- `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux,
64+ install the `docker-buildx-plugin` package.
65+
66+## Related
67+
68+- [CLI Overview](./index.md)
69+- [Routes Command](./routes.md)
70+- [Serve Command](./serve.md)
A docs/cli/restart.md +52 -0
@@ -0,0 +1,52 @@
1+# Restart Command
2+
3+Restart a running container for a route.
4+
5+## gordon restart
6+
7+### Synopsis
8+
9+```bash
10+gordon restart <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to restart |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--with-attachments` | Also restart attached services (databases, caches) |
24+| `--remote` | Remote Gordon URL |
25+| `--token` | Authentication token for remote |
26+
27+### Description
28+
29+Restarts the container for the specified route. This is useful after updating
30+environment variables or secrets without performing a full redeploy.
31+
32+When `--with-attachments` is set, Gordon also restarts any attachment containers
33+for the route.
34+
35+### Examples
36+
37+```bash
38+# Restart main container only
39+gordon restart myapp.example.com
40+
41+# Restart with attachments
42+gordon restart myapp.example.com --with-attachments
43+```
44+
45+### Notes
46+
47+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
48+
49+## Related
50+
51+- [CLI Overview](./index.md)
52+- [Secrets Command](./secrets.md)
@@ -0,0 +1,52 @@
1+# Restart Command
2+
3+Restart a running container for a route.
4+
5+## gordon restart
6+
7+### Synopsis
8+
9+```bash
10+gordon restart <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to restart |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--with-attachments` | Also restart attached services (databases, caches) |
24+| `--remote` | Remote Gordon URL |
25+| `--token` | Authentication token for remote |
26+
27+### Description
28+
29+Restarts the container for the specified route. This is useful after updating
30+environment variables or secrets without performing a full redeploy.
31+
32+When `--with-attachments` is set, Gordon also restarts any attachment containers
33+for the route.
34+
35+### Examples
36+
37+```bash
38+# Restart main container only
39+gordon restart myapp.example.com
40+
41+# Restart with attachments
42+gordon restart myapp.example.com --with-attachments
43+```
44+
45+### Notes
46+
47+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
48+
49+## Related
50+
51+- [CLI Overview](./index.md)
52+- [Secrets Command](./secrets.md)
A docs/cli/rollback.md +54 -0
@@ -0,0 +1,54 @@
1+# Rollback Command
2+
3+Roll back a route to a previous image tag.
4+
5+## gordon rollback
6+
7+### Synopsis
8+
9+```bash
10+gordon rollback <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to roll back |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--tag` | Target tag (skips interactive selection) |
24+| `--remote` | Remote Gordon URL |
25+| `--token` | Authentication token for remote |
26+
27+### Description
28+
29+`gordon rollback` lists available tags in the Gordon registry for the route's
30+repository, then deploys the selected version. Semver tags are sorted in
31+descending order first, followed by non-semver tags.
32+
33+If the selected tag matches the current running tag, no action is taken.
34+
35+### Examples
36+
37+```bash
38+# Interactive selection
39+gordon rollback myapp.example.com
40+
41+# Roll back to a specific tag
42+gordon rollback myapp.example.com --tag v1.2.0
43+```
44+
45+### Notes
46+
47+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
48+- Tags are read from the Gordon registry configured on the server.
49+
50+## Related
51+
52+- [CLI Overview](./index.md)
53+- [Push Command](./push.md)
54+- [Routes Command](./routes.md)
@@ -0,0 +1,54 @@
1+# Rollback Command
2+
3+Roll back a route to a previous image tag.
4+
5+## gordon rollback
6+
7+### Synopsis
8+
9+```bash
10+gordon rollback <domain> [options]
11+```
12+
13+### Arguments
14+
15+| Argument | Description |
16+|----------|-------------|
17+| `<domain>` | The route domain to roll back |
18+
19+### Options
20+
21+| Option | Description |
22+|--------|-------------|
23+| `--tag` | Target tag (skips interactive selection) |
24+| `--remote` | Remote Gordon URL |
25+| `--token` | Authentication token for remote |
26+
27+### Description
28+
29+`gordon rollback` lists available tags in the Gordon registry for the route's
30+repository, then deploys the selected version. Semver tags are sorted in
31+descending order first, followed by non-semver tags.
32+
33+If the selected tag matches the current running tag, no action is taken.
34+
35+### Examples
36+
37+```bash
38+# Interactive selection
39+gordon rollback myapp.example.com
40+
41+# Roll back to a specific tag
42+gordon rollback myapp.example.com --tag v1.2.0
43+```
44+
45+### Notes
46+
47+- Remote mode required. See [CLI Overview](./index.md) for targeting options.
48+- Tags are read from the Gordon registry configured on the server.
49+
50+## Related
51+
52+- [CLI Overview](./index.md)
53+- [Push Command](./push.md)
54+- [Routes Command](./routes.md)
M docs/cli/routes.md +6 -3
@@ -2,6 +2,9 @@
22
33 Manage routes on local or remote Gordon instances.
44
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
58 ## gordon routes
69
710 ### Subcommands
@@ -66,7 +69,7 @@ gordon routes add myapp.example.com myapp:latest
6669 gordon routes add myapp.example.com myapp:latest
6770 gordon routes add api.example.com myapi:v2.1.0
6871
69-# Remote
72+# Remote (override)
7073 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
7174 ```
7275
@@ -100,7 +103,7 @@ gordon routes remove myapp.example.com
100103 # Local
101104 gordon routes remove myapp.example.com
102105
103-# Remote
106+# Remote (override)
104107 gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
105108 ```
106109
@@ -138,7 +141,7 @@ Triggers a fresh image pull and container redeployment for the specified route.
138141 # Local
139142 gordon routes deploy myapp.example.com
140143
141-# Remote
144+# Remote (override)
142145 gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
143146 ```
144147
@@ -2,6 +2,9 @@
2 2
3 Manage routes on local or remote Gordon instances.3 Manage routes on local or remote Gordon instances.
4 4
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
5 ## gordon routes8 ## gordon routes
6 9
7 ### Subcommands10 ### Subcommands
@@ -66,7 +69,7 @@ gordon routes add myapp.example.com myapp:latest
66 gordon routes add myapp.example.com myapp:latest69 gordon routes add myapp.example.com myapp:latest
67 gordon routes add api.example.com myapi:v2.1.070 gordon routes add api.example.com myapi:v2.1.0
68 71
69-# Remote72+# Remote (override)
70 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN73 gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN
71 ```74 ```
72 75
@@ -100,7 +103,7 @@ gordon routes remove myapp.example.com
100 # Local103 # Local
101 gordon routes remove myapp.example.com104 gordon routes remove myapp.example.com
102 105
103-# Remote106+# Remote (override)
104 gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN107 gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
105 ```108 ```
106 109
@@ -138,7 +141,7 @@ Triggers a fresh image pull and container redeployment for the specified route.
138 # Local141 # Local
139 gordon routes deploy myapp.example.com142 gordon routes deploy myapp.example.com
140 143
141-# Remote144+# Remote (override)
142 gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN145 gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
143 ```146 ```
144 147
M docs/cli/secrets.md +6 -3
@@ -2,6 +2,9 @@
22
33 Manage secrets on local or remote Gordon instances.
44
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
58 Storage depends on the secrets backend:
69 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
710 - `sops` or `unsafe`: secrets are stored in domain `.env` files
@@ -45,7 +48,7 @@ gordon secrets list <domain>
4548 # Local
4649 gordon secrets list myapp.local
4750
48-# Remote
51+# Remote (override)
4952 gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
5053 ```
5154
@@ -92,7 +95,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
9295 gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"
9396 gordon secrets set myapp.local API_KEY "your-api-key"
9497
95-# Remote
98+# Remote (override)
9699 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
97100 ```
98101
@@ -126,7 +129,7 @@ gordon secrets remove <domain> <key>
126129 # Local
127130 gordon secrets remove myapp.local DATABASE_URL
128131
129-# Remote
132+# Remote (override)
130133 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
131134 ```
132135
@@ -2,6 +2,9 @@
2 2
3 Manage secrets on local or remote Gordon instances.3 Manage secrets on local or remote Gordon instances.
4 4
5+Remote targeting uses client config or an active remote by default.
6+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
7+
5 Storage depends on the secrets backend:8 Storage depends on the secrets backend:
6 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`9 - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>`
7 - `sops` or `unsafe`: secrets are stored in domain `.env` files10 - `sops` or `unsafe`: secrets are stored in domain `.env` files
@@ -45,7 +48,7 @@ gordon secrets list <domain>
45 # Local48 # Local
46 gordon secrets list myapp.local49 gordon secrets list myapp.local
47 50
48-# Remote51+# Remote (override)
49 gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN52 gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN
50 ```53 ```
51 54
@@ -92,7 +95,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
92 gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"95 gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db"
93 gordon secrets set myapp.local API_KEY "your-api-key"96 gordon secrets set myapp.local API_KEY "your-api-key"
94 97
95-# Remote98+# Remote (override)
96 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN99 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
97 ```100 ```
98 101
@@ -126,7 +129,7 @@ gordon secrets remove <domain> <key>
126 # Local129 # Local
127 gordon secrets remove myapp.local DATABASE_URL130 gordon secrets remove myapp.local DATABASE_URL
128 131
129-# Remote132+# Remote (override)
130 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN133 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
131 ```134 ```
132 135
M docs/cli/serve.md +8 -2
@@ -172,6 +172,9 @@ gordon deploy <domain> [options]
172172 | `--remote` | Remote Gordon URL |
173173 | `--token` | Authentication token for remote |
174174
175+Remote targeting uses client config or an active remote by default.
176+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177+
175178 ### Description
176179
177180 **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
@@ -190,7 +193,7 @@ Both modes trigger:
190193 gordon deploy myapp.example.com
191194 gordon deploy api.example.com
192195
193-# Remote deployment
196+# Remote deployment (override)
194197 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
195198 ```
196199
@@ -229,6 +232,9 @@ gordon logs [domain] [options]
229232 | `--remote` | | | Remote Gordon URL |
230233 | `--token` | | | Authentication token for remote |
231234
235+Remote targeting uses client config or an active remote by default.
236+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237+
232238 ### Examples
233239
234240 ```bash
@@ -243,7 +249,7 @@ gordon logs myapp.local # Last 50 lines from container
243249 gordon logs myapp.local -f # Follow container logs
244250 gordon logs myapp.local -n 100 # Last 100 lines from container
245251
246-# Remote mode
252+# Remote mode (override)
247253 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
248254 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
249255 ```
@@ -172,6 +172,9 @@ gordon deploy <domain> [options]
172 | `--remote` | Remote Gordon URL |172 | `--remote` | Remote Gordon URL |
173 | `--token` | Authentication token for remote |173 | `--token` | Authentication token for remote |
174 174
175+Remote targeting uses client config or an active remote by default.
176+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
177+
175 ### Description178 ### Description
176 179
177 **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.180 **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain.
@@ -190,7 +193,7 @@ Both modes trigger:
190 gordon deploy myapp.example.com193 gordon deploy myapp.example.com
191 gordon deploy api.example.com194 gordon deploy api.example.com
192 195
193-# Remote deployment196+# Remote deployment (override)
194 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN197 gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
195 ```198 ```
196 199
@@ -229,6 +232,9 @@ gordon logs [domain] [options]
229 | `--remote` | | | Remote Gordon URL |232 | `--remote` | | | Remote Gordon URL |
230 | `--token` | | | Authentication token for remote |233 | `--token` | | | Authentication token for remote |
231 234
235+Remote targeting uses client config or an active remote by default.
236+Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
237+
232 ### Examples238 ### Examples
233 239
234 ```bash240 ```bash
@@ -243,7 +249,7 @@ gordon logs myapp.local # Last 50 lines from container
243 gordon logs myapp.local -f # Follow container logs249 gordon logs myapp.local -f # Follow container logs
244 gordon logs myapp.local -n 100 # Last 100 lines from container250 gordon logs myapp.local -n 100 # Last 100 lines from container
245 251
246-# Remote mode252+# Remote mode (override)
247 gordon logs --remote https://gordon.mydomain.com --token $TOKEN253 gordon logs --remote https://gordon.mydomain.com --token $TOKEN
248 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN254 gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN
249 ```255 ```
M docs/cli/status.md +1 -1
@@ -10,7 +10,7 @@ Display server configuration and container status for all routes.
1010 gordon status --remote https://gordon.mydomain.com --token $TOKEN
1111 ```
1212
13-> **Note:** The status command requires remote mode. Use `--remote` flag or set `GORDON_REMOTE` environment variable.
13+> **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options.
1414
1515 ### Output
1616
@@ -10,7 +10,7 @@ Display server configuration and container status for all routes.
10 gordon status --remote https://gordon.mydomain.com --token $TOKEN10 gordon status --remote https://gordon.mydomain.com --token $TOKEN
11 ```11 ```
12 12
13-> **Note:** The status command requires remote mode. Use `--remote` flag or set `GORDON_REMOTE` environment variable.13+> **Note:** The status command requires remote mode. See [CLI Overview](./index.md) for targeting options.
14 14
15 ### Output15 ### Output
16 16
M docs/config/logging.md +3 -0
@@ -43,6 +43,9 @@ max_age = 28
4343 | `file.max_backups` | int | `3` | Number of old files to keep |
4444 | `file.max_age` | int | `28` | Days to keep old files |
4545
46+The Admin API and `gordon logs` read from the process log file. Keep
47+`logging.file.enabled` set to `true` if you need process log streaming.
48+
4649 ### Container Logs
4750
4851 | Option | Type | Default | Description |
@@ -43,6 +43,9 @@ max_age = 28
43 | `file.max_backups` | int | `3` | Number of old files to keep |43 | `file.max_backups` | int | `3` | Number of old files to keep |
44 | `file.max_age` | int | `28` | Days to keep old files |44 | `file.max_age` | int | `28` | Days to keep old files |
45 45
46+The Admin API and `gordon logs` read from the process log file. Keep
47+`logging.file.enabled` set to `true` if you need process log streaming.
48+
46 ### Container Logs49 ### Container Logs
47 50
48 | Option | Type | Default | Description |51 | Option | Type | Default | Description |
M go.mod +1 -0
@@ -15,6 +15,7 @@ require (
1515 github.com/spf13/viper v1.21.0
1616 github.com/stretchr/testify v1.11.1
1717 golang.org/x/crypto v0.47.0
18+ golang.org/x/mod v0.32.0
1819 golang.org/x/term v0.39.0
1920 gopkg.in/natefinch/lumberjack.v2 v2.2.1
2021 )
@@ -15,6 +15,7 @@ require (
15 github.com/spf13/viper v1.21.015 github.com/spf13/viper v1.21.0
16 github.com/stretchr/testify v1.11.116 github.com/stretchr/testify v1.11.1
17 golang.org/x/crypto v0.47.017 golang.org/x/crypto v0.47.0
18+ golang.org/x/mod v0.32.0
18 golang.org/x/term v0.39.019 golang.org/x/term v0.39.0
19 gopkg.in/natefinch/lumberjack.v2 v2.2.120 gopkg.in/natefinch/lumberjack.v2 v2.2.1
20 )21 )
M go.sum +2 -0
@@ -180,6 +180,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
180180 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
181181 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
182182 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
183+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
184+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
183185 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
184186 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
185187 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -180,6 +180,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
180 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=180 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
181 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=181 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
182 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=182 golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
183+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
184+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
183 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=185 golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
184 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=186 golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
185 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=187 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
A internal/adapters/in/cli/push.go +264 -0
@@ -0,0 +1,264 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "regexp"
9+ "strings"
10+
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16+ "github.com/bnema/gordon/pkg/validation"
17+)
18+
19+func newPushCmd() *cobra.Command {
20+ var (
21+ noDeploy bool
22+ noConfirm bool
23+ build bool
24+ platform string
25+ tag string
26+ buildArgs []string
27+ )
28+
29+ cmd := &cobra.Command{
30+ Use: "push <domain>",
31+ Short: "Tag, push, and optionally deploy an image",
32+ Long: `Tags a local image for the Gordon registry and pushes it.
33+Uses git tags for versioning. Optionally triggers deployment after push.
34+
35+With --build, builds the image first using docker buildx.
36+
37+Examples:
38+ gordon push myapp.example.com --remote ...
39+ gordon push myapp.example.com --build --remote ...
40+ gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ...
41+ gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,
42+ Args: cobra.ExactArgs(1),
43+ RunE: func(cmd *cobra.Command, args []string) error {
44+ return runPush(cmd.Context(), args[0], tag, build, platform, buildArgs, noDeploy, noConfirm)
45+ },
46+ }
47+
48+ cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
49+ cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
50+ cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
51+ cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
52+ cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
53+ cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
54+
55+ return cmd
56+}
57+
58+func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, buildArgs []string, noDeploy bool, noConfirm bool) error {
59+ client, isRemote := GetRemoteClient()
60+ if !isRemote {
61+ return fmt.Errorf("push requires remote mode")
62+ }
63+
64+ route, err := client.GetRoute(ctx, pushDomain)
65+ if err != nil {
66+ return fmt.Errorf("failed to get route: %w", err)
67+ }
68+
69+ registry, imageName, _ := parseImageRef(route.Image)
70+ if registry == "" || imageName == "" {
71+ return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
72+ }
73+
74+ version := determineVersion(ctx, tag)
75+ if version != "latest" {
76+ if err := validation.ValidateReference(version); err != nil {
77+ return fmt.Errorf("invalid version tag %q: %w", version, err)
78+ }
79+ }
80+
81+ for _, ba := range buildArgs {
82+ if err := validateBuildArg(ba); err != nil {
83+ return err
84+ }
85+ }
86+
87+ versionRef := fmt.Sprintf("%s/%s:%s", registry, imageName, version)
88+ latestRef := fmt.Sprintf("%s/%s:latest", registry, imageName)
89+
90+ fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
91+ if version != "latest" {
92+ fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
93+ }
94+
95+ if build {
96+ if err := buildAndPush(ctx, version, platform, buildArgs, versionRef, latestRef); err != nil {
97+ return err
98+ }
99+ } else {
100+ if err := tagAndPush(ctx, registry, imageName, version, versionRef, latestRef); err != nil {
101+ return err
102+ }
103+ }
104+
105+ fmt.Println(styles.RenderSuccess("Push complete"))
106+
107+ if !noDeploy {
108+ return deployAfterPush(ctx, client, pushDomain, noConfirm)
109+ }
110+
111+ return nil
112+}
113+
114+func determineVersion(ctx context.Context, tag string) string {
115+ version := tag
116+ if version == "" {
117+ version = getGitVersion(ctx)
118+ }
119+ if version == "" {
120+ version = "latest"
121+ }
122+ return version
123+}
124+
125+func buildAndPush(ctx context.Context, version, platform string, buildArgs []string, versionRef, latestRef string) error {
126+ if _, err := os.Stat("Dockerfile"); os.IsNotExist(err) {
127+ return fmt.Errorf("no Dockerfile found in current directory")
128+ }
129+
130+ fmt.Println("\nBuilding and pushing...")
131+ args := []string{
132+ "buildx", "build",
133+ "--platform", platform,
134+ "-t", latestRef,
135+ "--build-arg", "VERSION=" + version,
136+ }
137+ if version != "latest" {
138+ args = append(args, "-t", versionRef)
139+ }
140+ for _, ba := range buildArgs {
141+ args = append(args, "--build-arg", ba)
142+ }
143+ args = append(args, "--push", ".")
144+
145+ dockerCmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
146+ dockerCmd.Stdout = os.Stdout
147+ dockerCmd.Stderr = os.Stderr
148+ if err := dockerCmd.Run(); err != nil {
149+ return fmt.Errorf("docker buildx failed: %w", err)
150+ }
151+ return nil
152+}
153+
154+func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error {
155+ localImage := fmt.Sprintf("%s/%s", registry, imageName)
156+
157+ fmt.Println("\nChecking local image...")
158+ if !dockerImageExists(ctx, localImage) {
159+ return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
160+ }
161+
162+ fmt.Println("Tagging...")
163+ if err := dockerTag(ctx, localImage, versionRef); err != nil {
164+ return fmt.Errorf("failed to tag %s: %w", versionRef, err)
165+ }
166+ if version != "latest" {
167+ if err := dockerTag(ctx, localImage, latestRef); err != nil {
168+ return fmt.Errorf("failed to tag %s: %w", latestRef, err)
169+ }
170+ }
171+
172+ fmt.Println("Pushing...")
173+ if err := dockerPush(ctx, versionRef); err != nil {
174+ return fmt.Errorf("failed to push %s: %w", versionRef, err)
175+ }
176+ if version != "latest" {
177+ if err := dockerPush(ctx, latestRef); err != nil {
178+ return fmt.Errorf("failed to push %s: %w", latestRef, err)
179+ }
180+ }
181+ return nil
182+}
183+
184+func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error {
185+ if !noConfirm {
186+ confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
187+ if err != nil {
188+ return err
189+ }
190+ if !confirmed {
191+ return nil
192+ }
193+ }
194+
195+ result, err := client.Deploy(ctx, pushDomain)
196+ if err != nil {
197+ return fmt.Errorf("failed to deploy: %w", err)
198+ }
199+ containerID := result.ContainerID
200+ if len(containerID) > 12 {
201+ containerID = containerID[:12]
202+ }
203+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
204+ return nil
205+}
206+
207+// parseImageRef splits "registry/name:tag" into components.
208+func parseImageRef(image string) (registry, name, tag string) {
209+ // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
210+ parts := strings.SplitN(image, "/", 2)
211+ if len(parts) != 2 {
212+ return "", "", ""
213+ }
214+ registry = parts[0]
215+ nameTag := parts[1]
216+ if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
217+ name = nameTag[:idx]
218+ tag = nameTag[idx+1:]
219+ } else {
220+ name = nameTag
221+ tag = "latest"
222+ }
223+ return
224+}
225+
226+// getGitVersion returns the latest git tag, or empty string if unavailable.
227+func getGitVersion(ctx context.Context) string {
228+ out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0").Output()
229+ if err != nil {
230+ return ""
231+ }
232+ return strings.TrimSpace(string(out))
233+}
234+
235+func dockerTag(ctx context.Context, src, dst string) error {
236+ cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
237+ cmd.Stdout = os.Stdout
238+ cmd.Stderr = os.Stderr
239+ return cmd.Run()
240+}
241+
242+func dockerPush(ctx context.Context, ref string) error {
243+ cmd := exec.CommandContext(ctx, "docker", "push", ref)
244+ cmd.Stdout = os.Stdout
245+ cmd.Stderr = os.Stderr
246+ return cmd.Run()
247+}
248+
249+func dockerImageExists(ctx context.Context, image string) bool {
250+ cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) // #nosec G204
251+ cmd.Stdout = nil
252+ cmd.Stderr = nil
253+ return cmd.Run() == nil
254+}
255+
256+// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
257+var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
258+
259+func validateBuildArg(arg string) error {
260+ if !buildArgPattern.MatchString(arg) {
261+ return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
262+ }
263+ return nil
264+}
@@ -0,0 +1,264 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+ "regexp"
9+ "strings"
10+
11+ "github.com/spf13/cobra"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16+ "github.com/bnema/gordon/pkg/validation"
17+)
18+
19+func newPushCmd() *cobra.Command {
20+ var (
21+ noDeploy bool
22+ noConfirm bool
23+ build bool
24+ platform string
25+ tag string
26+ buildArgs []string
27+ )
28+
29+ cmd := &cobra.Command{
30+ Use: "push <domain>",
31+ Short: "Tag, push, and optionally deploy an image",
32+ Long: `Tags a local image for the Gordon registry and pushes it.
33+Uses git tags for versioning. Optionally triggers deployment after push.
34+
35+With --build, builds the image first using docker buildx.
36+
37+Examples:
38+ gordon push myapp.example.com --remote ...
39+ gordon push myapp.example.com --build --remote ...
40+ gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ...
41+ gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`,
42+ Args: cobra.ExactArgs(1),
43+ RunE: func(cmd *cobra.Command, args []string) error {
44+ return runPush(cmd.Context(), args[0], tag, build, platform, buildArgs, noDeploy, noConfirm)
45+ },
46+ }
47+
48+ cmd.Flags().BoolVar(&noDeploy, "no-deploy", false, "Push only, don't trigger deploy")
49+ cmd.Flags().BoolVar(&noConfirm, "no-confirm", false, "Skip deploy confirmation prompt")
50+ cmd.Flags().BoolVar(&build, "build", false, "Build the image first using docker buildx")
51+ cmd.Flags().StringVar(&platform, "platform", "linux/amd64", "Target platform (used with --build)")
52+ cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)")
53+ cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)")
54+
55+ return cmd
56+}
57+
58+func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, buildArgs []string, noDeploy bool, noConfirm bool) error {
59+ client, isRemote := GetRemoteClient()
60+ if !isRemote {
61+ return fmt.Errorf("push requires remote mode")
62+ }
63+
64+ route, err := client.GetRoute(ctx, pushDomain)
65+ if err != nil {
66+ return fmt.Errorf("failed to get route: %w", err)
67+ }
68+
69+ registry, imageName, _ := parseImageRef(route.Image)
70+ if registry == "" || imageName == "" {
71+ return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image)
72+ }
73+
74+ version := determineVersion(ctx, tag)
75+ if version != "latest" {
76+ if err := validation.ValidateReference(version); err != nil {
77+ return fmt.Errorf("invalid version tag %q: %w", version, err)
78+ }
79+ }
80+
81+ for _, ba := range buildArgs {
82+ if err := validateBuildArg(ba); err != nil {
83+ return err
84+ }
85+ }
86+
87+ versionRef := fmt.Sprintf("%s/%s:%s", registry, imageName, version)
88+ latestRef := fmt.Sprintf("%s/%s:latest", registry, imageName)
89+
90+ fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef))
91+ if version != "latest" {
92+ fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef))
93+ }
94+
95+ if build {
96+ if err := buildAndPush(ctx, version, platform, buildArgs, versionRef, latestRef); err != nil {
97+ return err
98+ }
99+ } else {
100+ if err := tagAndPush(ctx, registry, imageName, version, versionRef, latestRef); err != nil {
101+ return err
102+ }
103+ }
104+
105+ fmt.Println(styles.RenderSuccess("Push complete"))
106+
107+ if !noDeploy {
108+ return deployAfterPush(ctx, client, pushDomain, noConfirm)
109+ }
110+
111+ return nil
112+}
113+
114+func determineVersion(ctx context.Context, tag string) string {
115+ version := tag
116+ if version == "" {
117+ version = getGitVersion(ctx)
118+ }
119+ if version == "" {
120+ version = "latest"
121+ }
122+ return version
123+}
124+
125+func buildAndPush(ctx context.Context, version, platform string, buildArgs []string, versionRef, latestRef string) error {
126+ if _, err := os.Stat("Dockerfile"); os.IsNotExist(err) {
127+ return fmt.Errorf("no Dockerfile found in current directory")
128+ }
129+
130+ fmt.Println("\nBuilding and pushing...")
131+ args := []string{
132+ "buildx", "build",
133+ "--platform", platform,
134+ "-t", latestRef,
135+ "--build-arg", "VERSION=" + version,
136+ }
137+ if version != "latest" {
138+ args = append(args, "-t", versionRef)
139+ }
140+ for _, ba := range buildArgs {
141+ args = append(args, "--build-arg", ba)
142+ }
143+ args = append(args, "--push", ".")
144+
145+ dockerCmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204
146+ dockerCmd.Stdout = os.Stdout
147+ dockerCmd.Stderr = os.Stderr
148+ if err := dockerCmd.Run(); err != nil {
149+ return fmt.Errorf("docker buildx failed: %w", err)
150+ }
151+ return nil
152+}
153+
154+func tagAndPush(ctx context.Context, registry, imageName, version, versionRef, latestRef string) error {
155+ localImage := fmt.Sprintf("%s/%s", registry, imageName)
156+
157+ fmt.Println("\nChecking local image...")
158+ if !dockerImageExists(ctx, localImage) {
159+ return fmt.Errorf("local image %s not found; build and tag it before pushing", localImage)
160+ }
161+
162+ fmt.Println("Tagging...")
163+ if err := dockerTag(ctx, localImage, versionRef); err != nil {
164+ return fmt.Errorf("failed to tag %s: %w", versionRef, err)
165+ }
166+ if version != "latest" {
167+ if err := dockerTag(ctx, localImage, latestRef); err != nil {
168+ return fmt.Errorf("failed to tag %s: %w", latestRef, err)
169+ }
170+ }
171+
172+ fmt.Println("Pushing...")
173+ if err := dockerPush(ctx, versionRef); err != nil {
174+ return fmt.Errorf("failed to push %s: %w", versionRef, err)
175+ }
176+ if version != "latest" {
177+ if err := dockerPush(ctx, latestRef); err != nil {
178+ return fmt.Errorf("failed to push %s: %w", latestRef, err)
179+ }
180+ }
181+ return nil
182+}
183+
184+func deployAfterPush(ctx context.Context, client *remote.Client, pushDomain string, noConfirm bool) error {
185+ if !noConfirm {
186+ confirmed, err := components.RunConfirm("Deploy now?", components.WithDefaultYes())
187+ if err != nil {
188+ return err
189+ }
190+ if !confirmed {
191+ return nil
192+ }
193+ }
194+
195+ result, err := client.Deploy(ctx, pushDomain)
196+ if err != nil {
197+ return fmt.Errorf("failed to deploy: %w", err)
198+ }
199+ containerID := result.ContainerID
200+ if len(containerID) > 12 {
201+ containerID = containerID[:12]
202+ }
203+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deployed %s (container: %s)", pushDomain, containerID)))
204+ return nil
205+}
206+
207+// parseImageRef splits "registry/name:tag" into components.
208+func parseImageRef(image string) (registry, name, tag string) {
209+ // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
210+ parts := strings.SplitN(image, "/", 2)
211+ if len(parts) != 2 {
212+ return "", "", ""
213+ }
214+ registry = parts[0]
215+ nameTag := parts[1]
216+ if idx := strings.LastIndex(nameTag, ":"); idx != -1 {
217+ name = nameTag[:idx]
218+ tag = nameTag[idx+1:]
219+ } else {
220+ name = nameTag
221+ tag = "latest"
222+ }
223+ return
224+}
225+
226+// getGitVersion returns the latest git tag, or empty string if unavailable.
227+func getGitVersion(ctx context.Context) string {
228+ out, err := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0").Output()
229+ if err != nil {
230+ return ""
231+ }
232+ return strings.TrimSpace(string(out))
233+}
234+
235+func dockerTag(ctx context.Context, src, dst string) error {
236+ cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
237+ cmd.Stdout = os.Stdout
238+ cmd.Stderr = os.Stderr
239+ return cmd.Run()
240+}
241+
242+func dockerPush(ctx context.Context, ref string) error {
243+ cmd := exec.CommandContext(ctx, "docker", "push", ref)
244+ cmd.Stdout = os.Stdout
245+ cmd.Stderr = os.Stderr
246+ return cmd.Run()
247+}
248+
249+func dockerImageExists(ctx context.Context, image string) bool {
250+ cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image) // #nosec G204
251+ cmd.Stdout = nil
252+ cmd.Stderr = nil
253+ return cmd.Run() == nil
254+}
255+
256+// buildArgPattern matches KEY=VALUE where KEY starts with a letter or underscore.
257+var buildArgPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*=.*$`)
258+
259+func validateBuildArg(arg string) error {
260+ if !buildArgPattern.MatchString(arg) {
261+ return fmt.Errorf("invalid build arg %q: must match KEY=VALUE where KEY is [a-zA-Z_][a-zA-Z0-9_]*", arg)
262+ }
263+ return nil
264+}
M internal/adapters/in/cli/remote/client.go +55 -4
@@ -424,6 +424,57 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
424424 return &result, nil
425425 }
426426
427+// Restart API
428+
429+// RestartResult contains the result of a restart.
430+type RestartResult struct {
431+ Status string `json:"status"`
432+ Domain string `json:"domain"`
433+}
434+
435+// Restart triggers a container restart for the specified domain.
436+func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
437+ if restartDomain == "" {
438+ return nil, fmt.Errorf("domain cannot be empty")
439+ }
440+ path := "/restart/" + url.PathEscape(restartDomain)
441+ if withAttachments {
442+ path += "?attachments=true"
443+ }
444+ resp, err := c.request(ctx, http.MethodPost, path, nil)
445+ if err != nil {
446+ return nil, err
447+ }
448+
449+ var result RestartResult
450+ if err := parseResponse(resp, &result); err != nil {
451+ return nil, err
452+ }
453+
454+ return &result, nil
455+}
456+
457+// Tags API
458+
459+// ListTags returns available tags for a repository.
460+func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
461+ if repository == "" {
462+ return nil, fmt.Errorf("repository cannot be empty")
463+ }
464+ resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
465+ if err != nil {
466+ return nil, err
467+ }
468+ var result struct {
469+ Repository string `json:"repository"`
470+ Tags []string `json:"tags"`
471+ }
472+ if err := parseResponse(resp, &result); err != nil {
473+ return nil, err
474+ }
475+ return result.Tags, nil
476+}
477+
427478 // Logs API
428479
429480 // GetProcessLogs returns Gordon process logs.
@@ -596,13 +647,13 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
596647 }
597648
598649 n, err := resp.Body.Read(buf)
650+ if n > 0 {
651+ lineBuffer.Write(buf[:n])
652+ }
599653 if err != nil {
600- // EOF is expected when stream ends, other errors are ignored
601- return
654+ return // EOF or context cancellation — clean exit
602655 }
603656
604- lineBuffer.Write(buf[:n])
605-
606657 // Process complete SSE events
607658 for {
608659 data := lineBuffer.String()
@@ -424,6 +424,57 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult
424 return &result, nil424 return &result, nil
425 }425 }
426 426
427+// Restart API
428+
429+// RestartResult contains the result of a restart.
430+type RestartResult struct {
431+ Status string `json:"status"`
432+ Domain string `json:"domain"`
433+}
434+
435+// Restart triggers a container restart for the specified domain.
436+func (c *Client) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*RestartResult, error) {
437+ if restartDomain == "" {
438+ return nil, fmt.Errorf("domain cannot be empty")
439+ }
440+ path := "/restart/" + url.PathEscape(restartDomain)
441+ if withAttachments {
442+ path += "?attachments=true"
443+ }
444+ resp, err := c.request(ctx, http.MethodPost, path, nil)
445+ if err != nil {
446+ return nil, err
447+ }
448+
449+ var result RestartResult
450+ if err := parseResponse(resp, &result); err != nil {
451+ return nil, err
452+ }
453+
454+ return &result, nil
455+}
456+
457+// Tags API
458+
459+// ListTags returns available tags for a repository.
460+func (c *Client) ListTags(ctx context.Context, repository string) ([]string, error) {
461+ if repository == "" {
462+ return nil, fmt.Errorf("repository cannot be empty")
463+ }
464+ resp, err := c.request(ctx, http.MethodGet, "/tags/"+url.PathEscape(repository), nil)
465+ if err != nil {
466+ return nil, err
467+ }
468+ var result struct {
469+ Repository string `json:"repository"`
470+ Tags []string `json:"tags"`
471+ }
472+ if err := parseResponse(resp, &result); err != nil {
473+ return nil, err
474+ }
475+ return result.Tags, nil
476+}
477+
427 // Logs API478 // Logs API
428 479
429 // GetProcessLogs returns Gordon process logs.480 // GetProcessLogs returns Gordon process logs.
@@ -596,13 +647,13 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
596 }647 }
597 648
598 n, err := resp.Body.Read(buf)649 n, err := resp.Body.Read(buf)
650+ if n > 0 {
651+ lineBuffer.Write(buf[:n])
652+ }
599 if err != nil {653 if err != nil {
600- // EOF is expected when stream ends, other errors are ignored654+ return // EOF or context cancellation — clean exit
601- return
602 }655 }
603 656
604- lineBuffer.Write(buf[:n])
605-
606 // Process complete SSE events657 // Process complete SSE events
607 for {658 for {
608 data := lineBuffer.String()659 data := lineBuffer.String()
A internal/adapters/in/cli/restart.go +47 -0
@@ -0,0 +1,47 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/cobra"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
9+)
10+
11+func newRestartCmd() *cobra.Command {
12+ var withAttachments bool
13+
14+ cmd := &cobra.Command{
15+ Use: "restart <domain>",
16+ Short: "Restart a running container",
17+ Long: `Restarts the container for the specified route domain.
18+Useful after changing environment variables with 'gordon secrets set'.
19+
20+Use --with-attachments to also restart attached services (databases, caches).
21+
22+Examples:
23+ gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
24+ gordon restart myapp.example.com --with-attachments --remote ...`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ ctx := cmd.Context()
28+ restartDomain := args[0]
29+
30+ client, isRemote := GetRemoteClient()
31+ if !isRemote {
32+ return fmt.Errorf("restart requires remote mode")
33+ }
34+
35+ result, err := client.Restart(ctx, restartDomain, withAttachments)
36+ if err != nil {
37+ return fmt.Errorf("failed to restart: %w", err)
38+ }
39+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))
40+ return nil
41+ },
42+ }
43+
44+ cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)")
45+
46+ return cmd
47+}
@@ -0,0 +1,47 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "github.com/spf13/cobra"
7+
8+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
9+)
10+
11+func newRestartCmd() *cobra.Command {
12+ var withAttachments bool
13+
14+ cmd := &cobra.Command{
15+ Use: "restart <domain>",
16+ Short: "Restart a running container",
17+ Long: `Restarts the container for the specified route domain.
18+Useful after changing environment variables with 'gordon secrets set'.
19+
20+Use --with-attachments to also restart attached services (databases, caches).
21+
22+Examples:
23+ gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN
24+ gordon restart myapp.example.com --with-attachments --remote ...`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ ctx := cmd.Context()
28+ restartDomain := args[0]
29+
30+ client, isRemote := GetRemoteClient()
31+ if !isRemote {
32+ return fmt.Errorf("restart requires remote mode")
33+ }
34+
35+ result, err := client.Restart(ctx, restartDomain, withAttachments)
36+ if err != nil {
37+ return fmt.Errorf("failed to restart: %w", err)
38+ }
39+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))
40+ return nil
41+ },
42+ }
43+
44+ cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)")
45+
46+ return cmd
47+}
A internal/adapters/in/cli/rollback.go +184 -0
@@ -0,0 +1,184 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sort"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+ "golang.org/x/mod/semver"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16+ "github.com/bnema/gordon/internal/domain"
17+)
18+
19+func newRollbackCmd() *cobra.Command {
20+ var targetTag string
21+
22+ cmd := &cobra.Command{
23+ Use: "rollback <domain>",
24+ Short: "Roll back to a previous image version",
25+ Long: `Lists available image tags for a domain and deploys the selected version.
26+Tags are read from the Gordon registry.
27+
28+Examples:
29+ gordon rollback myapp.example.com --remote ...
30+ gordon rollback myapp.example.com --tag v1.0.0 --remote ...`,
31+ Args: cobra.ExactArgs(1),
32+ RunE: func(cmd *cobra.Command, args []string) error {
33+ return runRollback(cmd.Context(), args[0], targetTag)
34+ },
35+ }
36+
37+ cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
38+
39+ return cmd
40+}
41+
42+func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
43+ client, isRemote := GetRemoteClient()
44+ if !isRemote {
45+ return fmt.Errorf("rollback requires remote mode")
46+ }
47+
48+ route, err := client.GetRoute(ctx, rollbackDomain)
49+ if err != nil {
50+ return fmt.Errorf("failed to get route: %w", err)
51+ }
52+
53+ _, imageName, currentTag := parseImageRef(route.Image)
54+ if imageName == "" {
55+ return fmt.Errorf("cannot parse image name from route: %s", route.Image)
56+ }
57+
58+ tags, err := fetchAndSortTags(ctx, client, imageName)
59+ if err != nil {
60+ return err
61+ }
62+
63+ selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)
64+ if err != nil || selectedTag == "" {
65+ return err
66+ }
67+
68+ if selectedTag == currentTag {
69+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
70+ return nil
71+ }
72+
73+ return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)
74+}
75+
76+func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) {
77+ tags, err := client.ListTags(ctx, imageName)
78+ if err != nil {
79+ return nil, fmt.Errorf("failed to list tags: %w", err)
80+ }
81+ if len(tags) == 0 {
82+ return nil, fmt.Errorf("no tags found for %s", imageName)
83+ }
84+
85+ return sortSemverTags(tags), nil
86+}
87+
88+// sortSemverTags normalizes, classifies, and sorts tags.
89+// Semver tags (with or without 'v' prefix) are sorted in descending order first,
90+// followed by non-semver tags in descending lexicographic order.
91+func sortSemverTags(tags []string) []string {
92+ var semverTags, otherTags []string
93+ for _, tag := range tags {
94+ sv := tag
95+ if !strings.HasPrefix(sv, "v") {
96+ sv = "v" + sv
97+ }
98+ if semver.IsValid(sv) {
99+ semverTags = append(semverTags, tag)
100+ } else {
101+ otherTags = append(otherTags, tag)
102+ }
103+ }
104+
105+ // Sort semver tags in descending order (latest first)
106+ sort.Slice(semverTags, func(i, j int) bool {
107+ si, sj := semverTags[i], semverTags[j]
108+ if !strings.HasPrefix(si, "v") {
109+ si = "v" + si
110+ }
111+ if !strings.HasPrefix(sj, "v") {
112+ sj = "v" + sj
113+ }
114+ return semver.Compare(si, sj) > 0
115+ })
116+
117+ // Sort non-semver tags in descending lexicographic order
118+ sort.Slice(otherTags, func(i, j int) bool {
119+ return otherTags[i] > otherTags[j]
120+ })
121+
122+ // Combine: semver tags first, then other tags
123+ return append(semverTags, otherTags...)
124+}
125+
126+func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {
127+ if targetTag != "" {
128+ if !validateTagExists(targetTag, tags) {
129+ return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
130+ }
131+ return targetTag, nil
132+ }
133+
134+ selectedTag, err := components.RunSelector(
135+ fmt.Sprintf("Select version for %s:", rollbackDomain),
136+ tags,
137+ currentTag,
138+ )
139+ if err != nil {
140+ return "", err
141+ }
142+ if selectedTag == "" {
143+ fmt.Println("Cancelled.")
144+ }
145+ return selectedTag, nil
146+}
147+
148+func validateTagExists(targetTag string, tags []string) bool {
149+ for _, t := range tags {
150+ if t == targetTag {
151+ return true
152+ }
153+ }
154+ return false
155+}
156+
157+func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
158+ registry, _, _ := parseImageRef(route.Image)
159+ oldImage := route.Image
160+ route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161+
162+ fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163+
164+ if err := client.UpdateRoute(ctx, *route); err != nil {
165+ return fmt.Errorf("failed to update route: %w", err)
166+ }
167+
168+ result, err := client.Deploy(ctx, rollbackDomain)
169+ if err != nil {
170+ // Attempt to revert route to previous image
171+ route.Image = oldImage
172+ if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {
173+ fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174+ return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175+ }
176+ return fmt.Errorf("failed to deploy (route reverted): %w", err)
177+ }
178+ containerID := result.ContainerID
179+ if len(containerID) > 12 {
180+ containerID = containerID[:12]
181+ }
182+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
183+ return nil
184+}
@@ -0,0 +1,184 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sort"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+ "golang.org/x/mod/semver"
12+
13+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
14+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
16+ "github.com/bnema/gordon/internal/domain"
17+)
18+
19+func newRollbackCmd() *cobra.Command {
20+ var targetTag string
21+
22+ cmd := &cobra.Command{
23+ Use: "rollback <domain>",
24+ Short: "Roll back to a previous image version",
25+ Long: `Lists available image tags for a domain and deploys the selected version.
26+Tags are read from the Gordon registry.
27+
28+Examples:
29+ gordon rollback myapp.example.com --remote ...
30+ gordon rollback myapp.example.com --tag v1.0.0 --remote ...`,
31+ Args: cobra.ExactArgs(1),
32+ RunE: func(cmd *cobra.Command, args []string) error {
33+ return runRollback(cmd.Context(), args[0], targetTag)
34+ },
35+ }
36+
37+ cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
38+
39+ return cmd
40+}
41+
42+func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
43+ client, isRemote := GetRemoteClient()
44+ if !isRemote {
45+ return fmt.Errorf("rollback requires remote mode")
46+ }
47+
48+ route, err := client.GetRoute(ctx, rollbackDomain)
49+ if err != nil {
50+ return fmt.Errorf("failed to get route: %w", err)
51+ }
52+
53+ _, imageName, currentTag := parseImageRef(route.Image)
54+ if imageName == "" {
55+ return fmt.Errorf("cannot parse image name from route: %s", route.Image)
56+ }
57+
58+ tags, err := fetchAndSortTags(ctx, client, imageName)
59+ if err != nil {
60+ return err
61+ }
62+
63+ selectedTag, err := selectTag(targetTag, tags, currentTag, rollbackDomain)
64+ if err != nil || selectedTag == "" {
65+ return err
66+ }
67+
68+ if selectedTag == currentTag {
69+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Already running %s", selectedTag)))
70+ return nil
71+ }
72+
73+ return deploySelectedTag(ctx, client, route, rollbackDomain, imageName, selectedTag)
74+}
75+
76+func fetchAndSortTags(ctx context.Context, client *remote.Client, imageName string) ([]string, error) {
77+ tags, err := client.ListTags(ctx, imageName)
78+ if err != nil {
79+ return nil, fmt.Errorf("failed to list tags: %w", err)
80+ }
81+ if len(tags) == 0 {
82+ return nil, fmt.Errorf("no tags found for %s", imageName)
83+ }
84+
85+ return sortSemverTags(tags), nil
86+}
87+
88+// sortSemverTags normalizes, classifies, and sorts tags.
89+// Semver tags (with or without 'v' prefix) are sorted in descending order first,
90+// followed by non-semver tags in descending lexicographic order.
91+func sortSemverTags(tags []string) []string {
92+ var semverTags, otherTags []string
93+ for _, tag := range tags {
94+ sv := tag
95+ if !strings.HasPrefix(sv, "v") {
96+ sv = "v" + sv
97+ }
98+ if semver.IsValid(sv) {
99+ semverTags = append(semverTags, tag)
100+ } else {
101+ otherTags = append(otherTags, tag)
102+ }
103+ }
104+
105+ // Sort semver tags in descending order (latest first)
106+ sort.Slice(semverTags, func(i, j int) bool {
107+ si, sj := semverTags[i], semverTags[j]
108+ if !strings.HasPrefix(si, "v") {
109+ si = "v" + si
110+ }
111+ if !strings.HasPrefix(sj, "v") {
112+ sj = "v" + sj
113+ }
114+ return semver.Compare(si, sj) > 0
115+ })
116+
117+ // Sort non-semver tags in descending lexicographic order
118+ sort.Slice(otherTags, func(i, j int) bool {
119+ return otherTags[i] > otherTags[j]
120+ })
121+
122+ // Combine: semver tags first, then other tags
123+ return append(semverTags, otherTags...)
124+}
125+
126+func selectTag(targetTag string, tags []string, currentTag, rollbackDomain string) (string, error) {
127+ if targetTag != "" {
128+ if !validateTagExists(targetTag, tags) {
129+ return "", fmt.Errorf("tag %s not found. Available: %s", targetTag, strings.Join(tags, ", "))
130+ }
131+ return targetTag, nil
132+ }
133+
134+ selectedTag, err := components.RunSelector(
135+ fmt.Sprintf("Select version for %s:", rollbackDomain),
136+ tags,
137+ currentTag,
138+ )
139+ if err != nil {
140+ return "", err
141+ }
142+ if selectedTag == "" {
143+ fmt.Println("Cancelled.")
144+ }
145+ return selectedTag, nil
146+}
147+
148+func validateTagExists(targetTag string, tags []string) bool {
149+ for _, t := range tags {
150+ if t == targetTag {
151+ return true
152+ }
153+ }
154+ return false
155+}
156+
157+func deploySelectedTag(ctx context.Context, client *remote.Client, route *domain.Route, rollbackDomain, imageName, selectedTag string) error {
158+ registry, _, _ := parseImageRef(route.Image)
159+ oldImage := route.Image
160+ route.Image = fmt.Sprintf("%s/%s:%s", registry, imageName, selectedTag)
161+
162+ fmt.Printf("Rolling back to %s...\n", styles.Theme.Bold.Render(selectedTag))
163+
164+ if err := client.UpdateRoute(ctx, *route); err != nil {
165+ return fmt.Errorf("failed to update route: %w", err)
166+ }
167+
168+ result, err := client.Deploy(ctx, rollbackDomain)
169+ if err != nil {
170+ // Attempt to revert route to previous image
171+ route.Image = oldImage
172+ if revertErr := client.UpdateRoute(ctx, *route); revertErr != nil {
173+ fmt.Fprintf(os.Stderr, "WARNING: deploy failed and could not revert route: %v\n", revertErr)
174+ return fmt.Errorf("failed to deploy; revert failed: %v; deploy error: %w", revertErr, err)
175+ }
176+ return fmt.Errorf("failed to deploy (route reverted): %w", err)
177+ }
178+ containerID := result.ContainerID
179+ if len(containerID) > 12 {
180+ containerID = containerID[:12]
181+ }
182+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Rolled back %s to %s (container: %s)", rollbackDomain, selectedTag, containerID)))
183+ return nil
184+}
A internal/adapters/in/cli/rollback_test.go +53 -0
@@ -0,0 +1,53 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSortSemverTags(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ tags []string
13+ wantTags []string
14+ }{
15+ {
16+ name: "v-prefixed tags sorted descending",
17+ tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"},
18+ wantTags: []string{"v2.0.0", "v1.5.0", "v1.0.0"},
19+ },
20+ {
21+ name: "non-v-prefixed tags sorted as semver",
22+ tags: []string{"1.0.0", "2.0.0", "1.5.0"},
23+ wantTags: []string{"2.0.0", "1.5.0", "1.0.0"},
24+ },
25+ {
26+ name: "mixed v and non-v prefixed",
27+ tags: []string{"v1.0.0", "2.0.0", "1.5.0"},
28+ wantTags: []string{"2.0.0", "1.5.0", "v1.0.0"}, // all recognized as semver, sorted desc
29+ },
30+ {
31+ name: "pre-release tags",
32+ tags: []string{"v1.0.0-rc1", "v1.0.0", "1.0.0-beta"},
33+ wantTags: []string{"v1.0.0", "v1.0.0-rc1", "1.0.0-beta"}, // release > pre-release
34+ },
35+ {
36+ name: "non-semver tags appended after",
37+ tags: []string{"latest", "v1.0.0", "dev"},
38+ wantTags: []string{"v1.0.0", "latest", "dev"},
39+ },
40+ }
41+
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ gotTags := sortSemverTags(tt.tags)
45+ assert.Equal(t, tt.wantTags, gotTags)
46+ })
47+ }
48+}
49+
50+func TestValidateTagExists(t *testing.T) {
51+ assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
52+ assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
53+}
@@ -0,0 +1,53 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSortSemverTags(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ tags []string
13+ wantTags []string
14+ }{
15+ {
16+ name: "v-prefixed tags sorted descending",
17+ tags: []string{"v1.0.0", "v2.0.0", "v1.5.0"},
18+ wantTags: []string{"v2.0.0", "v1.5.0", "v1.0.0"},
19+ },
20+ {
21+ name: "non-v-prefixed tags sorted as semver",
22+ tags: []string{"1.0.0", "2.0.0", "1.5.0"},
23+ wantTags: []string{"2.0.0", "1.5.0", "1.0.0"},
24+ },
25+ {
26+ name: "mixed v and non-v prefixed",
27+ tags: []string{"v1.0.0", "2.0.0", "1.5.0"},
28+ wantTags: []string{"2.0.0", "1.5.0", "v1.0.0"}, // all recognized as semver, sorted desc
29+ },
30+ {
31+ name: "pre-release tags",
32+ tags: []string{"v1.0.0-rc1", "v1.0.0", "1.0.0-beta"},
33+ wantTags: []string{"v1.0.0", "v1.0.0-rc1", "1.0.0-beta"}, // release > pre-release
34+ },
35+ {
36+ name: "non-semver tags appended after",
37+ tags: []string{"latest", "v1.0.0", "dev"},
38+ wantTags: []string{"v1.0.0", "latest", "dev"},
39+ },
40+ }
41+
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ gotTags := sortSemverTags(tt.tags)
45+ assert.Equal(t, tt.wantTags, gotTags)
46+ })
47+ }
48+}
49+
50+func TestValidateTagExists(t *testing.T) {
51+ assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
52+ assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
53+}
M internal/adapters/in/cli/root.go +17 -1
@@ -5,6 +5,9 @@ package cli
55 import (
66 "context"
77 "fmt"
8+ "os"
9+ "os/signal"
10+ "syscall"
811
912 "github.com/spf13/cobra"
1013
@@ -88,6 +91,18 @@ Commands are organized by where they run:
8891 deployCmd.GroupID = groupManage
8992 rootCmd.AddCommand(deployCmd)
9093
94+ restartCmd := newRestartCmd()
95+ restartCmd.GroupID = groupManage
96+ rootCmd.AddCommand(restartCmd)
97+
98+ pushCmd := newPushCmd()
99+ pushCmd.GroupID = groupManage
100+ rootCmd.AddCommand(pushCmd)
101+
102+ rollbackCmd := newRollbackCmd()
103+ rollbackCmd.GroupID = groupManage
104+ rootCmd.AddCommand(rollbackCmd)
105+
91106 reloadCmd := newReloadCmd()
92107 reloadCmd.GroupID = groupManage
93108 rootCmd.AddCommand(reloadCmd)
@@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
232247
233248 // runLogsRemote fetches logs from a remote Gordon instance.
234249 func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
235- ctx := context.Background()
250+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
251+ defer cancel()
236252
237253 if follow {
238254 return streamLogsRemote(ctx, client, logDomain, lines)
@@ -5,6 +5,9 @@ package cli
5 import (5 import (
6 "context"6 "context"
7 "fmt"7 "fmt"
8+ "os"
9+ "os/signal"
10+ "syscall"
8 11
9 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
10 13
@@ -88,6 +91,18 @@ Commands are organized by where they run:
88 deployCmd.GroupID = groupManage91 deployCmd.GroupID = groupManage
89 rootCmd.AddCommand(deployCmd)92 rootCmd.AddCommand(deployCmd)
90 93
94+ restartCmd := newRestartCmd()
95+ restartCmd.GroupID = groupManage
96+ rootCmd.AddCommand(restartCmd)
97+
98+ pushCmd := newPushCmd()
99+ pushCmd.GroupID = groupManage
100+ rootCmd.AddCommand(pushCmd)
101+
102+ rollbackCmd := newRollbackCmd()
103+ rollbackCmd.GroupID = groupManage
104+ rootCmd.AddCommand(rollbackCmd)
105+
91 reloadCmd := newReloadCmd()106 reloadCmd := newReloadCmd()
92 reloadCmd.GroupID = groupManage107 reloadCmd.GroupID = groupManage
93 rootCmd.AddCommand(reloadCmd)108 rootCmd.AddCommand(reloadCmd)
@@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
232 247
233 // runLogsRemote fetches logs from a remote Gordon instance.248 // runLogsRemote fetches logs from a remote Gordon instance.
234 func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {249 func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error {
235- ctx := context.Background()250+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
251+ defer cancel()
236 252
237 if follow {253 if follow {
238 return streamLogsRemote(ctx, client, logDomain, lines)254 return streamLogsRemote(ctx, client, logDomain, lines)
A internal/adapters/in/cli/ui/components/selector.go +114 -0
@@ -0,0 +1,114 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+)
12+
13+// SelectorModel is a list selection dialog.
14+type SelectorModel struct {
15+ title string
16+ items []string
17+ current string // highlighted item (e.g., current tag)
18+ cursor int
19+ selected string
20+ cancelled bool
21+}
22+
23+// NewSelector creates a new selector dialog.
24+func NewSelector(title string, items []string, current string) SelectorModel {
25+ return SelectorModel{
26+ title: title,
27+ items: items,
28+ current: current,
29+ }
30+}
31+
32+func (m SelectorModel) Init() tea.Cmd { return nil }
33+
34+func (m SelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
35+ switch msg := msg.(type) {
36+ case tea.KeyMsg:
37+ if len(m.items) == 0 {
38+ switch msg.String() {
39+ case "esc", "ctrl+c", "q":
40+ m.cancelled = true
41+ return m, tea.Quit
42+ }
43+ return m, nil
44+ }
45+ switch msg.String() {
46+ case "up", "k":
47+ if m.cursor > 0 {
48+ m.cursor--
49+ }
50+ case "down", "j":
51+ if m.cursor < len(m.items)-1 {
52+ m.cursor++
53+ }
54+ case "enter":
55+ m.selected = m.items[m.cursor]
56+ return m, tea.Quit
57+ case "esc", "ctrl+c", "q":
58+ m.cancelled = true
59+ return m, tea.Quit
60+ }
61+ }
62+ return m, nil
63+}
64+
65+func (m SelectorModel) View() string {
66+ var b strings.Builder
67+
68+ b.WriteString(styles.Theme.Bold.Render(m.title))
69+ b.WriteString("\n\n")
70+
71+ for i, item := range m.items {
72+ cursor := " "
73+ if i == m.cursor {
74+ cursor = "> "
75+ }
76+
77+ line := item
78+ if item == m.current {
79+ line = item + " " + lipgloss.NewStyle().Foreground(styles.ColorPrimary).Render("(current)")
80+ }
81+
82+ if i == m.cursor {
83+ b.WriteString(styles.Theme.Highlight.Render(cursor + line))
84+ } else {
85+ b.WriteString(fmt.Sprintf("%s%s", cursor, line))
86+ }
87+ b.WriteString("\n")
88+ }
89+
90+ b.WriteString("\n")
91+ b.WriteString(styles.RenderKeyHelp("↑/↓", "navigate") + " " +
92+ styles.RenderKeyHelp("enter", "select") + " " +
93+ styles.RenderKeyHelp("esc", "cancel"))
94+
95+ return b.String()
96+}
97+
98+func (m SelectorModel) Selected() string { return m.selected }
99+func (m SelectorModel) Cancelled() bool { return m.cancelled }
100+
101+// RunSelector runs a selector dialog and returns the selected item.
102+func RunSelector(title string, items []string, current string) (string, error) {
103+ m := NewSelector(title, items, current)
104+ p := tea.NewProgram(m)
105+ finalModel, err := p.Run()
106+ if err != nil {
107+ return "", fmt.Errorf("error running selector: %w", err)
108+ }
109+ result := finalModel.(SelectorModel)
110+ if result.Cancelled() {
111+ return "", nil
112+ }
113+ return result.Selected(), nil
114+}
@@ -0,0 +1,114 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+)
12+
13+// SelectorModel is a list selection dialog.
14+type SelectorModel struct {
15+ title string
16+ items []string
17+ current string // highlighted item (e.g., current tag)
18+ cursor int
19+ selected string
20+ cancelled bool
21+}
22+
23+// NewSelector creates a new selector dialog.
24+func NewSelector(title string, items []string, current string) SelectorModel {
25+ return SelectorModel{
26+ title: title,
27+ items: items,
28+ current: current,
29+ }
30+}
31+
32+func (m SelectorModel) Init() tea.Cmd { return nil }
33+
34+func (m SelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
35+ switch msg := msg.(type) {
36+ case tea.KeyMsg:
37+ if len(m.items) == 0 {
38+ switch msg.String() {
39+ case "esc", "ctrl+c", "q":
40+ m.cancelled = true
41+ return m, tea.Quit
42+ }
43+ return m, nil
44+ }
45+ switch msg.String() {
46+ case "up", "k":
47+ if m.cursor > 0 {
48+ m.cursor--
49+ }
50+ case "down", "j":
51+ if m.cursor < len(m.items)-1 {
52+ m.cursor++
53+ }
54+ case "enter":
55+ m.selected = m.items[m.cursor]
56+ return m, tea.Quit
57+ case "esc", "ctrl+c", "q":
58+ m.cancelled = true
59+ return m, tea.Quit
60+ }
61+ }
62+ return m, nil
63+}
64+
65+func (m SelectorModel) View() string {
66+ var b strings.Builder
67+
68+ b.WriteString(styles.Theme.Bold.Render(m.title))
69+ b.WriteString("\n\n")
70+
71+ for i, item := range m.items {
72+ cursor := " "
73+ if i == m.cursor {
74+ cursor = "> "
75+ }
76+
77+ line := item
78+ if item == m.current {
79+ line = item + " " + lipgloss.NewStyle().Foreground(styles.ColorPrimary).Render("(current)")
80+ }
81+
82+ if i == m.cursor {
83+ b.WriteString(styles.Theme.Highlight.Render(cursor + line))
84+ } else {
85+ b.WriteString(fmt.Sprintf("%s%s", cursor, line))
86+ }
87+ b.WriteString("\n")
88+ }
89+
90+ b.WriteString("\n")
91+ b.WriteString(styles.RenderKeyHelp("↑/↓", "navigate") + " " +
92+ styles.RenderKeyHelp("enter", "select") + " " +
93+ styles.RenderKeyHelp("esc", "cancel"))
94+
95+ return b.String()
96+}
97+
98+func (m SelectorModel) Selected() string { return m.selected }
99+func (m SelectorModel) Cancelled() bool { return m.cancelled }
100+
101+// RunSelector runs a selector dialog and returns the selected item.
102+func RunSelector(title string, items []string, current string) (string, error) {
103+ m := NewSelector(title, items, current)
104+ p := tea.NewProgram(m)
105+ finalModel, err := p.Run()
106+ if err != nil {
107+ return "", fmt.Errorf("error running selector: %w", err)
108+ }
109+ result := finalModel.(SelectorModel)
110+ if result.Cancelled() {
111+ return "", nil
112+ }
113+ return result.Selected(), nil
114+}
M internal/adapters/in/http/middleware/logging.go +12 -0
@@ -50,6 +50,18 @@ func (rw *ResponseWriter) BytesWritten() int {
5050 return rw.bytes
5151 }
5252
53+// Flush implements http.Flusher by delegating to the underlying ResponseWriter.
54+func (rw *ResponseWriter) Flush() {
55+ if f, ok := rw.ResponseWriter.(http.Flusher); ok {
56+ f.Flush()
57+ }
58+}
59+
60+// Unwrap returns the underlying ResponseWriter.
61+func (rw *ResponseWriter) Unwrap() http.ResponseWriter {
62+ return rw.ResponseWriter
63+}
64+
5365 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
5466 // It also attaches the logger to the request context for downstream handlers.
5567 // The trustedNets parameter controls which proxy headers are trusted for IP extraction.
@@ -50,6 +50,18 @@ func (rw *ResponseWriter) BytesWritten() int {
50 return rw.bytes50 return rw.bytes
51 }51 }
52 52
53+// Flush implements http.Flusher by delegating to the underlying ResponseWriter.
54+func (rw *ResponseWriter) Flush() {
55+ if f, ok := rw.ResponseWriter.(http.Flusher); ok {
56+ f.Flush()
57+ }
58+}
59+
60+// Unwrap returns the underlying ResponseWriter.
61+func (rw *ResponseWriter) Unwrap() http.ResponseWriter {
62+ return rw.ResponseWriter
63+}
64+
53 // RequestLogger is a middleware that logs HTTP requests using zerowrap.65 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
54 // It also attaches the logger to the request context for downstream handlers.66 // It also attaches the logger to the request context for downstream handlers.
55 // The trustedNets parameter controls which proxy headers are trusted for IP extraction.67 // The trustedNets parameter controls which proxy headers are trusted for IP extraction.
M internal/app/run.go +24 -22
@@ -218,28 +218,29 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
218218 Format: cfg.Logging.Format,
219219 }
220220
221- if cfg.Logging.File.Enabled {
222- logPath := cfg.Logging.File.Path
223- if logPath == "" {
224- // Default to {data_dir}/logs/gordon.log
225- logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
226- }
227-
228- log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
229- Enabled: true,
230- Path: logPath,
231- MaxSize: cfg.Logging.File.MaxSize,
232- MaxBackups: cfg.Logging.File.MaxBackups,
233- MaxAge: cfg.Logging.File.MaxAge,
234- Compress: true,
235- })
236- if err != nil {
237- return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
221+ // Always enable file logging so admin API can read process logs
222+ // (Config flag is respected for backward-compatibility)
223+ logPath := cfg.Logging.File.Path
224+ if logPath == "" {
225+ dataDir := cfg.Server.DataDir
226+ if dataDir == "" {
227+ dataDir = DefaultDataDir()
238228 }
239- return log, cleanup, nil
229+ logPath = filepath.Join(dataDir, "logs", "gordon.log")
240230 }
241231
242- return zerowrap.New(logConfig), nil, nil
232+ log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
233+ Enabled: cfg.Logging.File.Enabled,
234+ Path: logPath,
235+ MaxSize: cfg.Logging.File.MaxSize,
236+ MaxBackups: cfg.Logging.File.MaxBackups,
237+ MaxAge: cfg.Logging.File.MaxAge,
238+ Compress: true,
239+ })
240+ if err != nil {
241+ return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
242+ }
243+ return log, cleanup, nil
243244 }
244245
245246 // createServices creates all the application services.
@@ -383,10 +384,11 @@ func resolveLogFilePath(cfg Config) string {
383384 if cfg.Logging.File.Path != "" {
384385 return cfg.Logging.File.Path
385386 }
386- if cfg.Logging.File.Enabled {
387- return filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")
387+ dataDir := cfg.Server.DataDir
388+ if dataDir == "" {
389+ dataDir = DefaultDataDir()
388390 }
389- return ""
391+ return filepath.Join(dataDir, "logs", "gordon.log")
390392 }
391393
392394 // createOutputAdapters creates the Docker runtime and event bus.
@@ -218,28 +218,29 @@ func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
218 Format: cfg.Logging.Format,218 Format: cfg.Logging.Format,
219 }219 }
220 220
221- if cfg.Logging.File.Enabled {221+ // Always enable file logging so admin API can read process logs
222- logPath := cfg.Logging.File.Path222+ // (Config flag is respected for backward-compatibility)
223- if logPath == "" {223+ logPath := cfg.Logging.File.Path
224- // Default to {data_dir}/logs/gordon.log224+ if logPath == "" {
225- logPath = filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")225+ dataDir := cfg.Server.DataDir
226- }226+ if dataDir == "" {
227-227+ dataDir = DefaultDataDir()
228- log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
229- Enabled: true,
230- Path: logPath,
231- MaxSize: cfg.Logging.File.MaxSize,
232- MaxBackups: cfg.Logging.File.MaxBackups,
233- MaxAge: cfg.Logging.File.MaxAge,
234- Compress: true,
235- })
236- if err != nil {
237- return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
238 }228 }
239- return log, cleanup, nil229+ logPath = filepath.Join(dataDir, "logs", "gordon.log")
240 }230 }
241 231
242- return zerowrap.New(logConfig), nil, nil232+ log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
233+ Enabled: cfg.Logging.File.Enabled,
234+ Path: logPath,
235+ MaxSize: cfg.Logging.File.MaxSize,
236+ MaxBackups: cfg.Logging.File.MaxBackups,
237+ MaxAge: cfg.Logging.File.MaxAge,
238+ Compress: true,
239+ })
240+ if err != nil {
241+ return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
242+ }
243+ return log, cleanup, nil
243 }244 }
244 245
245 // createServices creates all the application services.246 // createServices creates all the application services.
@@ -383,10 +384,11 @@ func resolveLogFilePath(cfg Config) string {
383 if cfg.Logging.File.Path != "" {384 if cfg.Logging.File.Path != "" {
384 return cfg.Logging.File.Path385 return cfg.Logging.File.Path
385 }386 }
386- if cfg.Logging.File.Enabled {387+ dataDir := cfg.Server.DataDir
387- return filepath.Join(cfg.Server.DataDir, "logs", "gordon.log")388+ if dataDir == "" {
389+ dataDir = DefaultDataDir()
388 }390 }
389- return ""391+ return filepath.Join(dataDir, "logs", "gordon.log")
390 }392 }
391 393
392 // createOutputAdapters creates the Docker runtime and event bus.394 // createOutputAdapters creates the Docker runtime and event bus.

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