Merge pull request #49 from bnema/feature/cli-improvements
feat(cli): add push, restart, and rollback commands with selector UI
21 files · +999 -42
M docs/cli/attachments.md +6 -3
| @@ -2,6 +2,9 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 8 | ## Requirements | |
| 6 | 9 | ||
| 7 | 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 | 45 | gordon attachments list app.example.com | |
| 43 | 46 | gordon attachments list backend | |
| 44 | 47 | ||
| 45 | - | # Remote mode | |
| 48 | + | # Remote (override) | |
| 46 | 49 | 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 | 94 | # Add cache to a network group (shared by all domains in the group) | |
| 92 | 95 | gordon attachments add backend redis:7-alpine | |
| 93 | 96 | ||
| 94 | - | # Remote | |
| 97 | + | # Remote (override) | |
| 95 | 98 | 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 | 132 | # Remove from network group | |
| 130 | 133 | gordon attachments remove backend redis:7-alpine | |
| 131 | 134 | ||
| 132 | - | # Remote | |
| 135 | + | # Remote (override) | |
| 133 | 136 | gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | |
| 134 | 137 | ``` | |
| 135 | 138 | ||
| @@ -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 | ## Requirements | 8 | ## 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.com | 45 | gordon attachments list app.example.com | ||
| 43 | gordon attachments list backend | 46 | gordon attachments list backend | ||
| 44 | 47 | ||||
| 45 | - | # Remote mode | 48 | + | # Remote (override) |
| 46 | gordon attachments list --remote https://gordon.mydomain.com --token $TOKEN | 49 | 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-alpine | 95 | gordon attachments add backend redis:7-alpine | ||
| 93 | 96 | ||||
| 94 | - | # Remote | 97 | + | # Remote (override) |
| 95 | gordon attachments add app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | 98 | 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 group | 132 | # Remove from network group | ||
| 130 | gordon attachments remove backend redis:7-alpine | 133 | gordon attachments remove backend redis:7-alpine | ||
| 131 | 134 | ||||
| 132 | - | # Remote | 135 | + | # Remote (override) |
| 133 | gordon attachments remove app.example.com postgres:18 --remote https://gordon.mydomain.com --token $TOKEN | 136 | 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 | |||
| 5 | 5 | Commands are organized by where they run: | |
| 6 | 6 | ||
| 7 | 7 | - **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 | |
| 9 | 9 | - **Client Commands** - CLI utilities that don't require a running Gordon server | |
| 10 | 10 | ||
| 11 | 11 | ## Server Commands (local only) | |
| @@ -15,7 +15,7 @@ Commands are organized by where they run: | |||
| 15 | 15 | | `gordon serve` | Start the Gordon server | [serve](./serve.md) | | |
| 16 | 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 | 20 | | Command | Description | Documentation | | |
| 21 | 21 | |---------|-------------|---------------| | |
| @@ -23,6 +23,9 @@ Commands are organized by where they run: | |||
| 23 | 23 | | `gordon attachments` | Manage container attachments | [attachments](./attachments.md) | | |
| 24 | 24 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 25 | 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 | 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 27 | 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 28 | 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| @@ -48,6 +51,18 @@ gordon reload | |||
| 48 | 51 | # Deploy a specific route | |
| 49 | 52 | 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 | 66 | # View logs | |
| 52 | 67 | gordon logs # Gordon process logs | |
| 53 | 68 | gordon logs -f # Follow process logs | |
| @@ -97,7 +112,9 @@ gordon remotes use prod | |||
| 97 | 112 | ||
| 98 | 113 | ### 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 | 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 | ||
| @@ -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 Gordon | 7 | - **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 |
| 9 | - **Client Commands** - CLI utilities that don't require a running Gordon server | 9 | - **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 route | 51 | # Deploy a specific route | ||
| 49 | gordon deploy myapp.example.com | 52 | 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 logs | 66 | # View logs | ||
| 52 | gordon logs # Gordon process logs | 67 | gordon logs # Gordon process logs | ||
| 53 | gordon logs -f # Follow process logs | 68 | gordon logs -f # Follow process logs | ||
| @@ -97,7 +112,9 @@ gordon remotes use prod | |||||
| 97 | 112 | ||||
| 98 | ### Remote Targeting | 113 | ### 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 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 8 | ## gordon routes | |
| 6 | 9 | ||
| 7 | 10 | ### Subcommands | |
| @@ -66,7 +69,7 @@ gordon routes add myapp.example.com myapp:latest | |||
| 66 | 69 | gordon routes add myapp.example.com myapp:latest | |
| 67 | 70 | gordon routes add api.example.com myapi:v2.1.0 | |
| 68 | 71 | ||
| 69 | - | # Remote | |
| 72 | + | # Remote (override) | |
| 70 | 73 | 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 | 103 | # Local | |
| 101 | 104 | gordon routes remove myapp.example.com | |
| 102 | 105 | ||
| 103 | - | # Remote | |
| 106 | + | # Remote (override) | |
| 104 | 107 | 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 | 141 | # Local | |
| 139 | 142 | gordon routes deploy myapp.example.com | |
| 140 | 143 | ||
| 141 | - | # Remote | |
| 144 | + | # Remote (override) | |
| 142 | 145 | gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 143 | 146 | ``` | |
| 144 | 147 | ||
| @@ -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 routes | 8 | ## gordon routes | ||
| 6 | 9 | ||||
| 7 | ### Subcommands | 10 | ### Subcommands | ||
| @@ -66,7 +69,7 @@ gordon routes add myapp.example.com myapp:latest | |||||
| 66 | gordon routes add myapp.example.com myapp:latest | 69 | gordon routes add myapp.example.com myapp:latest | ||
| 67 | gordon routes add api.example.com myapi:v2.1.0 | 70 | gordon routes add api.example.com myapi:v2.1.0 | ||
| 68 | 71 | ||||
| 69 | - | # Remote | 72 | + | # Remote (override) |
| 70 | gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | 73 | 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 | # Local | 103 | # Local | ||
| 101 | gordon routes remove myapp.example.com | 104 | gordon routes remove myapp.example.com | ||
| 102 | 105 | ||||
| 103 | - | # Remote | 106 | + | # Remote (override) |
| 104 | gordon routes remove myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | 107 | 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 | # Local | 141 | # Local | ||
| 139 | gordon routes deploy myapp.example.com | 142 | gordon routes deploy myapp.example.com | ||
| 140 | 143 | ||||
| 141 | - | # Remote | 144 | + | # Remote (override) |
| 142 | gordon routes deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | 145 | 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 @@ | |||
| 2 | 2 | ||
| 3 | 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 | 8 | Storage depends on the secrets backend: | |
| 6 | 9 | - `pass`: secrets are stored in pass under `gordon/env/<domain>/<KEY>` | |
| 7 | 10 | - `sops` or `unsafe`: secrets are stored in domain `.env` files | |
| @@ -45,7 +48,7 @@ gordon secrets list <domain> | |||
| 45 | 48 | # Local | |
| 46 | 49 | gordon secrets list myapp.local | |
| 47 | 50 | ||
| 48 | - | # Remote | |
| 51 | + | # Remote (override) | |
| 49 | 52 | 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 | 95 | gordon secrets set myapp.local DATABASE_URL "postgres://user:pass@postgres:5432/db" | |
| 93 | 96 | gordon secrets set myapp.local API_KEY "your-api-key" | |
| 94 | 97 | ||
| 95 | - | # Remote | |
| 98 | + | # Remote (override) | |
| 96 | 99 | 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 | 129 | # Local | |
| 127 | 130 | gordon secrets remove myapp.local DATABASE_URL | |
| 128 | 131 | ||
| 129 | - | # Remote | |
| 132 | + | # Remote (override) | |
| 130 | 133 | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | |
| 131 | 134 | ``` | |
| 132 | 135 | ||
| @@ -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` files | 10 | - `sops` or `unsafe`: secrets are stored in domain `.env` files | ||
| @@ -45,7 +48,7 @@ gordon secrets list <domain> | |||||
| 45 | # Local | 48 | # Local | ||
| 46 | gordon secrets list myapp.local | 49 | gordon secrets list myapp.local | ||
| 47 | 50 | ||||
| 48 | - | # Remote | 51 | + | # Remote (override) |
| 49 | gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TOKEN | 52 | 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 | - | # Remote | 98 | + | # Remote (override) |
| 96 | gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN | 99 | 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 | # Local | 129 | # Local | ||
| 127 | gordon secrets remove myapp.local DATABASE_URL | 130 | gordon secrets remove myapp.local DATABASE_URL | ||
| 128 | 131 | ||||
| 129 | - | # Remote | 132 | + | # Remote (override) |
| 130 | gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN | 133 | 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] | |||
| 172 | 172 | | `--remote` | Remote Gordon URL | | |
| 173 | 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 | 178 | ### Description | |
| 176 | 179 | ||
| 177 | 180 | **Local mode:** Sends `SIGUSR2` to the Gordon process with the specified domain. | |
| @@ -190,7 +193,7 @@ Both modes trigger: | |||
| 190 | 193 | gordon deploy myapp.example.com | |
| 191 | 194 | gordon deploy api.example.com | |
| 192 | 195 | ||
| 193 | - | # Remote deployment | |
| 196 | + | # Remote deployment (override) | |
| 194 | 197 | 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 | 232 | | `--remote` | | | Remote Gordon URL | | |
| 230 | 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 | 238 | ### Examples | |
| 233 | 239 | ||
| 234 | 240 | ```bash | |
| @@ -243,7 +249,7 @@ gordon logs myapp.local # Last 50 lines from container | |||
| 243 | 249 | gordon logs myapp.local -f # Follow container logs | |
| 244 | 250 | gordon logs myapp.local -n 100 # Last 100 lines from container | |
| 245 | 251 | ||
| 246 | - | # Remote mode | |
| 252 | + | # Remote mode (override) | |
| 247 | 253 | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | |
| 248 | 254 | gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN | |
| 249 | 255 | ``` | |
| @@ -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 | ### Description | 178 | ### 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.com | 193 | gordon deploy myapp.example.com | ||
| 191 | gordon deploy api.example.com | 194 | gordon deploy api.example.com | ||
| 192 | 195 | ||||
| 193 | - | # Remote deployment | 196 | + | # Remote deployment (override) |
| 194 | gordon deploy myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | 197 | 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 | ### Examples | 238 | ### Examples | ||
| 233 | 239 | ||||
| 234 | ```bash | 240 | ```bash | ||
| @@ -243,7 +249,7 @@ gordon logs myapp.local # Last 50 lines from container | |||||
| 243 | gordon logs myapp.local -f # Follow container logs | 249 | gordon logs myapp.local -f # Follow container logs | ||
| 244 | gordon logs myapp.local -n 100 # Last 100 lines from container | 250 | gordon logs myapp.local -n 100 # Last 100 lines from container | ||
| 245 | 251 | ||||
| 246 | - | # Remote mode | 252 | + | # Remote mode (override) |
| 247 | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | 253 | gordon logs --remote https://gordon.mydomain.com --token $TOKEN | ||
| 248 | gordon logs myapp.local --remote https://gordon.mydomain.com --token $TOKEN | 254 | 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. | |||
| 10 | 10 | 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 | 15 | ### Output | |
| 16 | 16 | ||
| @@ -10,7 +10,7 @@ Display server configuration and container status for all routes. | |||||
| 10 | gordon status --remote https://gordon.mydomain.com --token $TOKEN | 10 | 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 | ### Output | 15 | ### Output | ||
| 16 | 16 | ||||
M docs/config/logging.md +3 -0
| @@ -43,6 +43,9 @@ max_age = 28 | |||
| 43 | 43 | | `file.max_backups` | int | `3` | Number of old files to keep | | |
| 44 | 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 | 49 | ### Container Logs | |
| 47 | 50 | ||
| 48 | 51 | | 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 Logs | 49 | ### Container Logs | ||
| 47 | 50 | ||||
| 48 | | Option | Type | Default | Description | | 51 | | Option | Type | Default | Description | | ||
M go.mod +1 -0
| @@ -15,6 +15,7 @@ require ( | |||
| 15 | 15 | github.com/spf13/viper v1.21.0 | |
| 16 | 16 | github.com/stretchr/testify v1.11.1 | |
| 17 | 17 | golang.org/x/crypto v0.47.0 | |
| 18 | + | golang.org/x/mod v0.32.0 | |
| 18 | 19 | golang.org/x/term v0.39.0 | |
| 19 | 20 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | |
| 20 | 21 | ) | |
| @@ -15,6 +15,7 @@ require ( | |||||
| 15 | github.com/spf13/viper v1.21.0 | 15 | github.com/spf13/viper v1.21.0 | ||
| 16 | github.com/stretchr/testify v1.11.1 | 16 | github.com/stretchr/testify v1.11.1 | ||
| 17 | golang.org/x/crypto v0.47.0 | 17 | golang.org/x/crypto v0.47.0 | ||
| 18 | + | golang.org/x/mod v0.32.0 | |||
| 18 | golang.org/x/term v0.39.0 | 19 | golang.org/x/term v0.39.0 | ||
| 19 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 20 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||
| 20 | ) | 21 | ) | ||
M go.sum +2 -0 generated file: collapsed
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 | |||
| 424 | 424 | 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 | 478 | // Logs API | |
| 428 | 479 | ||
| 429 | 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 | 649 | n, err := resp.Body.Read(buf) | |
| 650 | + | if n > 0 { | |
| 651 | + | lineBuffer.Write(buf[:n]) | |
| 652 | + | } | |
| 599 | 653 | if err != nil { | |
| 600 | - | // EOF is expected when stream ends, other errors are ignored | |
| 601 | - | return | |
| 654 | + | return // EOF or context cancellation — clean exit | |
| 602 | 655 | } | |
| 603 | 656 | ||
| 604 | - | lineBuffer.Write(buf[:n]) | |
| 605 | - | ||
| 606 | 657 | // Process complete SSE events | |
| 607 | 658 | for { | |
| 608 | 659 | data := lineBuffer.String() | |
| @@ -424,6 +424,57 @@ func (c *Client) Deploy(ctx context.Context, deployDomain string) (*DeployResult | |||||
| 424 | return &result, nil | 424 | 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 API | 478 | // 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 ignored | 654 | + | return // EOF or context cancellation — clean exit |
| 601 | - | return | |||
| 602 | } | 655 | } | ||
| 603 | 656 | ||||
| 604 | - | lineBuffer.Write(buf[:n]) | |||
| 605 | - | ||||
| 606 | // Process complete SSE events | 657 | // 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/signal" | |
| 10 | + | "syscall" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/spf13/cobra" | |
| 10 | 13 | ||
| @@ -88,6 +91,18 @@ Commands are organized by where they run: | |||
| 88 | 91 | deployCmd.GroupID = groupManage | |
| 89 | 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 | 106 | reloadCmd := newReloadCmd() | |
| 92 | 107 | reloadCmd.GroupID = groupManage | |
| 93 | 108 | rootCmd.AddCommand(reloadCmd) | |
| @@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error { | |||
| 232 | 247 | ||
| 233 | 248 | // runLogsRemote fetches logs from a remote Gordon instance. | |
| 234 | 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 | 253 | if follow { | |
| 238 | 254 | 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 = groupManage | 91 | 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 = groupManage | 107 | 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 { | |||
| 50 | 50 | 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 | 65 | // RequestLogger is a middleware that logs HTTP requests using zerowrap. | |
| 54 | 66 | // It also attaches the logger to the request context for downstream handlers. | |
| 55 | 67 | // The trustedNets parameter controls which proxy headers are trusted for IP extraction. | |
| @@ -50,6 +50,18 @@ func (rw *ResponseWriter) BytesWritten() int { | |||||
| 50 | return rw.bytes | 50 | 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) { | |||
| 218 | 218 | Format: cfg.Logging.Format, | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 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() | |
| 238 | 228 | } | |
| 239 | - | return log, cleanup, nil | |
| 229 | + | logPath = filepath.Join(dataDir, "logs", "gordon.log") | |
| 240 | 230 | } | |
| 241 | 231 | ||
| 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 | |
| 243 | 244 | } | |
| 244 | 245 | ||
| 245 | 246 | // createServices creates all the application services. | |
| @@ -383,10 +384,11 @@ func resolveLogFilePath(cfg Config) string { | |||
| 383 | 384 | if cfg.Logging.File.Path != "" { | |
| 384 | 385 | return cfg.Logging.File.Path | |
| 385 | 386 | } | |
| 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() | |
| 388 | 390 | } | |
| 389 | - | return "" | |
| 391 | + | return filepath.Join(dataDir, "logs", "gordon.log") | |
| 390 | 392 | } | |
| 391 | 393 | ||
| 392 | 394 | // 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.Path | 222 | + | // (Config flag is respected for backward-compatibility) |
| 223 | - | if logPath == "" { | 223 | + | logPath := cfg.Logging.File.Path |
| 224 | - | // Default to {data_dir}/logs/gordon.log | 224 | + | 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, nil | 229 | + | logPath = filepath.Join(dataDir, "logs", "gordon.log") |
| 240 | } | 230 | } | ||
| 241 | 231 | ||||
| 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 | |||
| 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.Path | 385 | 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. | ||