Merge pull request #99 from bnema/issue-98-bootstrap-json
feat: CLI bootstrap command, idempotent routes, and --json list output
37 files · +1892 -219
M docs/cli/attachments.md +28 -0
| @@ -40,6 +40,7 @@ List all configured attachments. | |||
| 40 | 40 | ```bash | |
| 41 | 41 | # List all attachments | |
| 42 | 42 | gordon attachments list | |
| 43 | + | gordon attachments list --json | |
| 43 | 44 | ||
| 44 | 45 | # List attachments for a specific domain or group | |
| 45 | 46 | gordon attachments list app.example.com | |
| @@ -59,6 +60,33 @@ api.example.com postgres:18 | |||
| 59 | 60 | backend (group) rabbitmq:3-management | |
| 60 | 61 | ``` | |
| 61 | 62 | ||
| 63 | + | ### JSON Output | |
| 64 | + | ||
| 65 | + | ```bash | |
| 66 | + | gordon attachments list --json | |
| 67 | + | ``` | |
| 68 | + | ||
| 69 | + | ```json | |
| 70 | + | [ | |
| 71 | + | { | |
| 72 | + | "target": "app.example.com", | |
| 73 | + | "attachments": ["postgres:18", "redis:7-alpine"] | |
| 74 | + | }, | |
| 75 | + | { | |
| 76 | + | "target": "backend", | |
| 77 | + | "attachments": ["rabbitmq:3-management"] | |
| 78 | + | } | |
| 79 | + | ] | |
| 80 | + | ``` | |
| 81 | + | ||
| 82 | + | ### Options | |
| 83 | + | ||
| 84 | + | | Option | Description | | |
| 85 | + | |--------|-------------| | |
| 86 | + | | `--json` | Output attachments as JSON | | |
| 87 | + | | `--remote` | Remote Gordon URL | | |
| 88 | + | | `--token` | Authentication token for remote | | |
| 89 | + | ||
| 62 | 90 | --- | |
| 63 | 91 | ||
| 64 | 92 | ## gordon attachments add | |
| @@ -40,6 +40,7 @@ List all configured attachments. | |||||
| 40 | ```bash | 40 | ```bash | ||
| 41 | # List all attachments | 41 | # List all attachments | ||
| 42 | gordon attachments list | 42 | gordon attachments list | ||
| 43 | + | gordon attachments list --json | |||
| 43 | 44 | ||||
| 44 | # List attachments for a specific domain or group | 45 | # List attachments for a specific domain or group | ||
| 45 | gordon attachments list app.example.com | 46 | gordon attachments list app.example.com | ||
| @@ -59,6 +60,33 @@ api.example.com postgres:18 | |||||
| 59 | backend (group) rabbitmq:3-management | 60 | backend (group) rabbitmq:3-management | ||
| 60 | ``` | 61 | ``` | ||
| 61 | 62 | ||||
| 63 | + | ### JSON Output | |||
| 64 | + | ||||
| 65 | + | ```bash | |||
| 66 | + | gordon attachments list --json | |||
| 67 | + | ``` | |||
| 68 | + | ||||
| 69 | + | ```json | |||
| 70 | + | [ | |||
| 71 | + | { | |||
| 72 | + | "target": "app.example.com", | |||
| 73 | + | "attachments": ["postgres:18", "redis:7-alpine"] | |||
| 74 | + | }, | |||
| 75 | + | { | |||
| 76 | + | "target": "backend", | |||
| 77 | + | "attachments": ["rabbitmq:3-management"] | |||
| 78 | + | } | |||
| 79 | + | ] | |||
| 80 | + | ``` | |||
| 81 | + | ||||
| 82 | + | ### Options | |||
| 83 | + | ||||
| 84 | + | | Option | Description | | |||
| 85 | + | |--------|-------------| | |||
| 86 | + | | `--json` | Output attachments as JSON | | |||
| 87 | + | | `--remote` | Remote Gordon URL | | |||
| 88 | + | | `--token` | Authentication token for remote | | |||
| 89 | + | ||||
| 62 | --- | 90 | --- | ||
| 63 | 91 | ||||
| 64 | ## gordon attachments add | 92 | ## gordon attachments add | ||
M docs/cli/backup.md +21 -0
| @@ -29,6 +29,7 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co | |||
| 29 | 29 | ```bash | |
| 30 | 30 | # List all backups | |
| 31 | 31 | gordon backups list | |
| 32 | + | gordon backups list --json | |
| 32 | 33 | ||
| 33 | 34 | # List backups for one domain | |
| 34 | 35 | gordon backups list app.example.com | |
| @@ -46,6 +47,26 @@ gordon backups detect app.example.com | |||
| 46 | 47 | gordon backups status | |
| 47 | 48 | ``` | |
| 48 | 49 | ||
| 50 | + | ## JSON Output | |
| 51 | + | ||
| 52 | + | `gordon backups list` supports `--json` for machine-readable output. | |
| 53 | + | ||
| 54 | + | ```bash | |
| 55 | + | gordon backups list --json | |
| 56 | + | ``` | |
| 57 | + | ||
| 58 | + | ```json | |
| 59 | + | [ | |
| 60 | + | { | |
| 61 | + | "domain": "app.example.com", | |
| 62 | + | "db": "postgres", | |
| 63 | + | "status": "completed", | |
| 64 | + | "started_at": "2026-03-13T09:45:00Z", | |
| 65 | + | "backup_id": "backup_20260313_094500" | |
| 66 | + | } | |
| 67 | + | ] | |
| 68 | + | ``` | |
| 69 | + | ||
| 49 | 70 | ## Output Columns | |
| 50 | 71 | ||
| 51 | 72 | ### list | |
| @@ -29,6 +29,7 @@ In normal usage, configure remotes once (`gordon remotes ...`) and run backup co | |||||
| 29 | ```bash | 29 | ```bash | ||
| 30 | # List all backups | 30 | # List all backups | ||
| 31 | gordon backups list | 31 | gordon backups list | ||
| 32 | + | gordon backups list --json | |||
| 32 | 33 | ||||
| 33 | # List backups for one domain | 34 | # List backups for one domain | ||
| 34 | gordon backups list app.example.com | 35 | gordon backups list app.example.com | ||
| @@ -46,6 +47,26 @@ gordon backups detect app.example.com | |||||
| 46 | gordon backups status | 47 | gordon backups status | ||
| 47 | ``` | 48 | ``` | ||
| 48 | 49 | ||||
| 50 | + | ## JSON Output | |||
| 51 | + | ||||
| 52 | + | `gordon backups list` supports `--json` for machine-readable output. | |||
| 53 | + | ||||
| 54 | + | ```bash | |||
| 55 | + | gordon backups list --json | |||
| 56 | + | ``` | |||
| 57 | + | ||||
| 58 | + | ```json | |||
| 59 | + | [ | |||
| 60 | + | { | |||
| 61 | + | "domain": "app.example.com", | |||
| 62 | + | "db": "postgres", | |||
| 63 | + | "status": "completed", | |||
| 64 | + | "started_at": "2026-03-13T09:45:00Z", | |||
| 65 | + | "backup_id": "backup_20260313_094500" | |||
| 66 | + | } | |||
| 67 | + | ] | |||
| 68 | + | ``` | |||
| 69 | + | ||||
| 49 | ## Output Columns | 70 | ## Output Columns | ||
| 50 | 71 | ||||
| 51 | ### list | 72 | ### list | ||
A docs/cli/bootstrap.md +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | # Bootstrap Command | |
| 2 | + | ||
| 3 | + | Create or update route configuration, attachments, and secrets in one command. | |
| 4 | + | ||
| 5 | + | ## gordon bootstrap | |
| 6 | + | ||
| 7 | + | ### Synopsis | |
| 8 | + | ||
| 9 | + | ```bash | |
| 10 | + | gordon bootstrap <domain> <image> [options] | |
| 11 | + | ``` | |
| 12 | + | ||
| 13 | + | ### Arguments | |
| 14 | + | ||
| 15 | + | | Argument | Description | | |
| 16 | + | |----------|-------------| | |
| 17 | + | | `<domain>` | The route domain to create or update | | |
| 18 | + | | `<image>` | The image to assign to the route | | |
| 19 | + | ||
| 20 | + | ### Options | |
| 21 | + | ||
| 22 | + | | Option | Description | | |
| 23 | + | |--------|-------------| | |
| 24 | + | | `--attachment` | Add an attachment to the route (repeatable) | | |
| 25 | + | | `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) | | |
| 26 | + | | `--remote` | Remote Gordon URL | | |
| 27 | + | | `--token` | Authentication token for remote | | |
| 28 | + | ||
| 29 | + | ### Description | |
| 30 | + | ||
| 31 | + | `gordon bootstrap` is the recommended first-step setup workflow. | |
| 32 | + | ||
| 33 | + | - Creates the route when it does not exist. | |
| 34 | + | - Updates the route when it already exists. | |
| 35 | + | - Can attach services and set environment variables as part of the same command. | |
| 36 | + | - Does not push or deploy the image. | |
| 37 | + | ||
| 38 | + | Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image. | |
| 39 | + | ||
| 40 | + | ### Examples | |
| 41 | + | ||
| 42 | + | ```bash | |
| 43 | + | # First-time route setup | |
| 44 | + | gordon bootstrap app.example.com myapp:latest | |
| 45 | + | ||
| 46 | + | # Then push and deploy the image | |
| 47 | + | gordon push app.example.com --build --no-confirm | |
| 48 | + | ||
| 49 | + | # First-time route setup with a database attachment and environment variable | |
| 50 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 51 | + | ||
| 52 | + | # Multiple attachments and environment variables | |
| 53 | + | gordon bootstrap app.example.com myapp:latest \ | |
| 54 | + | --attachment postgres:18 \ | |
| 55 | + | --attachment redis:7-alpine \ | |
| 56 | + | --env APP_ENV=production \ | |
| 57 | + | --env LOG_LEVEL=info | |
| 58 | + | ||
| 59 | + | # Remote target | |
| 60 | + | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |
| 61 | + | ``` | |
| 62 | + | ||
| 63 | + | ### Notes | |
| 64 | + | ||
| 65 | + | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | |
| 66 | + | - Run `gordon push` after bootstrap to upload and deploy the image. | |
| 67 | + | ||
| 68 | + | ## Related | |
| 69 | + | ||
| 70 | + | - [CLI Overview](./index.md) | |
| 71 | + | - [Routes Commands](./routes.md) | |
| 72 | + | - [Push Command](./push.md) | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | # Bootstrap Command | |||
| 2 | + | ||||
| 3 | + | Create or update route configuration, attachments, and secrets in one command. | |||
| 4 | + | ||||
| 5 | + | ## gordon bootstrap | |||
| 6 | + | ||||
| 7 | + | ### Synopsis | |||
| 8 | + | ||||
| 9 | + | ```bash | |||
| 10 | + | gordon bootstrap <domain> <image> [options] | |||
| 11 | + | ``` | |||
| 12 | + | ||||
| 13 | + | ### Arguments | |||
| 14 | + | ||||
| 15 | + | | Argument | Description | | |||
| 16 | + | |----------|-------------| | |||
| 17 | + | | `<domain>` | The route domain to create or update | | |||
| 18 | + | | `<image>` | The image to assign to the route | | |||
| 19 | + | ||||
| 20 | + | ### Options | |||
| 21 | + | ||||
| 22 | + | | Option | Description | | |||
| 23 | + | |--------|-------------| | |||
| 24 | + | | `--attachment` | Add an attachment to the route (repeatable) | | |||
| 25 | + | | `--env` | Set an environment variable for the route (repeatable, `KEY=VALUE`) | | |||
| 26 | + | | `--remote` | Remote Gordon URL | | |||
| 27 | + | | `--token` | Authentication token for remote | | |||
| 28 | + | ||||
| 29 | + | ### Description | |||
| 30 | + | ||||
| 31 | + | `gordon bootstrap` is the recommended first-step setup workflow. | |||
| 32 | + | ||||
| 33 | + | - Creates the route when it does not exist. | |||
| 34 | + | - Updates the route when it already exists. | |||
| 35 | + | - Can attach services and set environment variables as part of the same command. | |||
| 36 | + | - Does not push or deploy the image. | |||
| 37 | + | ||||
| 38 | + | Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. Run `gordon push` separately after bootstrap to upload and deploy an image. | |||
| 39 | + | ||||
| 40 | + | ### Examples | |||
| 41 | + | ||||
| 42 | + | ```bash | |||
| 43 | + | # First-time route setup | |||
| 44 | + | gordon bootstrap app.example.com myapp:latest | |||
| 45 | + | ||||
| 46 | + | # Then push and deploy the image | |||
| 47 | + | gordon push app.example.com --build --no-confirm | |||
| 48 | + | ||||
| 49 | + | # First-time route setup with a database attachment and environment variable | |||
| 50 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |||
| 51 | + | ||||
| 52 | + | # Multiple attachments and environment variables | |||
| 53 | + | gordon bootstrap app.example.com myapp:latest \ | |||
| 54 | + | --attachment postgres:18 \ | |||
| 55 | + | --attachment redis:7-alpine \ | |||
| 56 | + | --env APP_ENV=production \ | |||
| 57 | + | --env LOG_LEVEL=info | |||
| 58 | + | ||||
| 59 | + | # Remote target | |||
| 60 | + | gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |||
| 61 | + | ``` | |||
| 62 | + | ||||
| 63 | + | ### Notes | |||
| 64 | + | ||||
| 65 | + | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. | |||
| 66 | + | - Run `gordon push` after bootstrap to upload and deploy the image. | |||
| 67 | + | ||||
| 68 | + | ## Related | |||
| 69 | + | ||||
| 70 | + | - [CLI Overview](./index.md) | |||
| 71 | + | - [Routes Commands](./routes.md) | |||
| 72 | + | - [Push Command](./push.md) | |||
M docs/cli/images.md +26 -0
| @@ -19,11 +19,37 @@ Subcommands: | |||
| 19 | 19 | ||
| 20 | 20 | ```bash | |
| 21 | 21 | gordon images list | |
| 22 | + | gordon images list --json | |
| 22 | 23 | ``` | |
| 23 | 24 | ||
| 24 | 25 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | |
| 25 | 26 | Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`. | |
| 26 | 27 | ||
| 28 | + | Flags: | |
| 29 | + | ||
| 30 | + | | Flag | Description | | |
| 31 | + | |------|-------------| | |
| 32 | + | | `--json` | Output images as JSON | | |
| 33 | + | ||
| 34 | + | ### JSON Output | |
| 35 | + | ||
| 36 | + | ```bash | |
| 37 | + | gordon images list --json | |
| 38 | + | ``` | |
| 39 | + | ||
| 40 | + | ```json | |
| 41 | + | [ | |
| 42 | + | { | |
| 43 | + | "repository": "myapp", | |
| 44 | + | "tag": "latest", | |
| 45 | + | "size": "148MB", | |
| 46 | + | "created_at": "2026-03-13T10:15:00Z", | |
| 47 | + | "image_id": "sha256:abc123def456", | |
| 48 | + | "dangling": false | |
| 49 | + | } | |
| 50 | + | ] | |
| 51 | + | ``` | |
| 52 | + | ||
| 27 | 53 | ## gordon images prune | |
| 28 | 54 | ||
| 29 | 55 | ```bash | |
| @@ -19,11 +19,37 @@ Subcommands: | |||||
| 19 | 19 | ||||
| 20 | ```bash | 20 | ```bash | ||
| 21 | gordon images list | 21 | gordon images list | ||
| 22 | + | gordon images list --json | |||
| 22 | ``` | 23 | ``` | ||
| 23 | 24 | ||||
| 24 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | 25 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | ||
| 25 | Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`. | 26 | Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`. | ||
| 26 | 27 | ||||
| 28 | + | Flags: | |||
| 29 | + | ||||
| 30 | + | | Flag | Description | | |||
| 31 | + | |------|-------------| | |||
| 32 | + | | `--json` | Output images as JSON | | |||
| 33 | + | ||||
| 34 | + | ### JSON Output | |||
| 35 | + | ||||
| 36 | + | ```bash | |||
| 37 | + | gordon images list --json | |||
| 38 | + | ``` | |||
| 39 | + | ||||
| 40 | + | ```json | |||
| 41 | + | [ | |||
| 42 | + | { | |||
| 43 | + | "repository": "myapp", | |||
| 44 | + | "tag": "latest", | |||
| 45 | + | "size": "148MB", | |||
| 46 | + | "created_at": "2026-03-13T10:15:00Z", | |||
| 47 | + | "image_id": "sha256:abc123def456", | |||
| 48 | + | "dangling": false | |||
| 49 | + | } | |||
| 50 | + | ] | |||
| 51 | + | ``` | |||
| 52 | + | ||||
| 27 | ## gordon images prune | 53 | ## gordon images prune | ||
| 28 | 54 | ||||
| 29 | ```bash | 55 | ```bash | ||
M docs/cli/index.md +9 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Gordon provides a command-line interface for server management, deployment, and authentication. | |
| 4 | 4 | ||
| 5 | + | Most `list` commands also support `--json` for machine-readable output. | |
| 6 | + | ||
| 5 | 7 | Commands are organized by where they run: | |
| 6 | 8 | ||
| 7 | 9 | - **Server Commands** - Must run on the machine hosting Gordon | |
| @@ -26,6 +28,7 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 26 | 28 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 27 | 29 | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | |
| 28 | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | |
| 31 | + | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | |
| 29 | 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | |
| 30 | 33 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | |
| 31 | 34 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| @@ -58,6 +61,12 @@ gordon deploy myapp.example.com | |||
| 58 | 61 | # Restart a running container | |
| 59 | 62 | gordon restart myapp.example.com | |
| 60 | 63 | ||
| 64 | + | # First-time route setup | |
| 65 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 66 | + | ||
| 67 | + | # Then push and deploy | |
| 68 | + | gordon push app.example.com --build --no-confirm | |
| 69 | + | ||
| 61 | 70 | # Push an image and deploy | |
| 62 | 71 | gordon push myapp.example.com --build | |
| 63 | 72 | ||
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | Gordon provides a command-line interface for server management, deployment, and authentication. | 3 | Gordon provides a command-line interface for server management, deployment, and authentication. | ||
| 4 | 4 | ||||
| 5 | + | Most `list` commands also support `--json` for machine-readable output. | |||
| 6 | + | ||||
| 5 | Commands are organized by where they run: | 7 | Commands are organized by where they run: | ||
| 6 | 8 | ||||
| 7 | - **Server Commands** - Must run on the machine hosting Gordon | 9 | - **Server Commands** - Must run on the machine hosting Gordon | ||
| @@ -26,6 +28,7 @@ Management commands run locally through in-process services by default. Add `--r | |||||
| 26 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | 28 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | ||
| 27 | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | 29 | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | ||
| 28 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | ||
| 31 | + | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | |||
| 29 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | ||
| 30 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | 33 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | ||
| 31 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | 34 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | ||
| @@ -58,6 +61,12 @@ gordon deploy myapp.example.com | |||||
| 58 | # Restart a running container | 61 | # Restart a running container | ||
| 59 | gordon restart myapp.example.com | 62 | gordon restart myapp.example.com | ||
| 60 | 63 | ||||
| 64 | + | # First-time route setup | |||
| 65 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |||
| 66 | + | ||||
| 67 | + | # Then push and deploy | |||
| 68 | + | gordon push app.example.com --build --no-confirm | |||
| 69 | + | ||||
| 61 | # Push an image and deploy | 70 | # Push an image and deploy | ||
| 62 | gordon push myapp.example.com --build | 71 | gordon push myapp.example.com --build | ||
| 63 | 72 | ||||
M docs/cli/push.md +2 -0
| @@ -35,6 +35,7 @@ gordon push <domain> [options] | |||
| 35 | 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | |
| 36 | 36 | Gordon registry, pushes it, and optionally deploys it. | |
| 37 | 37 | ||
| 38 | + | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | |
| 38 | 39 | - The registry and repository are derived from the route image on the server. | |
| 39 | 40 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| 40 | 41 | then falls back to `git describe --tags --dirty` (for example | |
| @@ -66,6 +67,7 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||
| 66 | 67 | ### Notes | |
| 67 | 68 | ||
| 68 | 69 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | |
| 70 | + | - `gordon push` still requires the route to already exist so it can resolve the target image. | |
| 69 | 71 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | |
| 70 | 72 | install the `docker-buildx-plugin` package. | |
| 71 | 73 | ||
| @@ -35,6 +35,7 @@ gordon push <domain> [options] | |||||
| 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | 35 | `gordon push` resolves the route image for `<domain>`, tags the image for the | ||
| 36 | Gordon registry, pushes it, and optionally deploys it. | 36 | Gordon registry, pushes it, and optionally deploys it. | ||
| 37 | 37 | ||||
| 38 | + | - For first deploys, run `gordon bootstrap` first to create or update the route, attachments, and secrets, then run `gordon push` to upload and deploy the image. | |||
| 38 | - The registry and repository are derived from the route image on the server. | 39 | - The registry and repository are derived from the route image on the server. | ||
| 39 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | 40 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | ||
| 40 | then falls back to `git describe --tags --dirty` (for example | 41 | then falls back to `git describe --tags --dirty` (for example | ||
| @@ -66,6 +67,7 @@ gordon push myapp.example.com --build -f docker/app/Dockerfile | |||||
| 66 | ### Notes | 67 | ### Notes | ||
| 67 | 68 | ||||
| 68 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | 69 | - Remote mode required. See [CLI Overview](./index.md) for targeting options. | ||
| 70 | + | - `gordon push` still requires the route to already exist so it can resolve the target image. | |||
| 69 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | 71 | - `--build` requires Docker with Buildx. Docker Desktop includes it; on Linux, | ||
| 70 | install the `docker-buildx-plugin` package. | 72 | install the `docker-buildx-plugin` package. | ||
| 71 | 73 | ||||
M docs/cli/routes.md +43 -2
| @@ -12,7 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||
| 12 | 12 | | Subcommand | Description | | |
| 13 | 13 | |------------|-------------| | |
| 14 | 14 | | `list` | List all routes | | |
| 15 | - | | `add` | Add a new route | | |
| 15 | + | | `add` | Create or update a route | | |
| 16 | 16 | | `remove` | Remove a route | | |
| 17 | 17 | | `deploy` | Deploy a specific route | | |
| 18 | 18 | ||
| @@ -24,6 +24,7 @@ List all configured routes. | |||
| 24 | 24 | ||
| 25 | 25 | ```bash | |
| 26 | 26 | gordon routes list | |
| 27 | + | gordon routes list --json | |
| 27 | 28 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | |
| 28 | 29 | ``` | |
| 29 | 30 | ||
| @@ -37,17 +38,49 @@ api.example.com myapi:v2.1.0 running | |||
| 37 | 38 | admin.example.com admin-panel:latest stopped | |
| 38 | 39 | ``` | |
| 39 | 40 | ||
| 41 | + | ### JSON Output | |
| 42 | + | ||
| 43 | + | ```bash | |
| 44 | + | gordon routes list --json | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | ```json | |
| 48 | + | [ | |
| 49 | + | { | |
| 50 | + | "domain": "app.example.com", | |
| 51 | + | "image": "myapp:latest", | |
| 52 | + | "status": "running" | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | "domain": "api.example.com", | |
| 56 | + | "image": "myapi:v2.1.0", | |
| 57 | + | "status": "running" | |
| 58 | + | } | |
| 59 | + | ] | |
| 60 | + | ``` | |
| 61 | + | ||
| 62 | + | ### Options | |
| 63 | + | ||
| 64 | + | | Option | Description | | |
| 65 | + | |--------|-------------| | |
| 66 | + | | `--json` | Output routes as JSON | | |
| 67 | + | | `--remote` | Remote Gordon URL | | |
| 68 | + | | `--token` | Authentication token for remote | | |
| 69 | + | ||
| 40 | 70 | --- | |
| 41 | 71 | ||
| 42 | 72 | ## gordon routes add | |
| 43 | 73 | ||
| 44 | - | Add a new route. | |
| 74 | + | Create a new route or update an existing route. | |
| 45 | 75 | ||
| 46 | 76 | ```bash | |
| 47 | 77 | gordon routes add <domain> <image> | |
| 48 | 78 | gordon routes add myapp.example.com myapp:latest | |
| 49 | 79 | ``` | |
| 50 | 80 | ||
| 81 | + | If the route already exists, Gordon updates it to the new image instead of failing. | |
| 82 | + | The image does not need to be pushed to the Gordon registry before you add the route. | |
| 83 | + | ||
| 51 | 84 | ### Arguments | |
| 52 | 85 | ||
| 53 | 86 | | Argument | Description | | |
| @@ -69,10 +102,18 @@ gordon routes add myapp.example.com myapp:latest | |||
| 69 | 102 | gordon routes add myapp.example.com myapp:latest | |
| 70 | 103 | gordon routes add api.example.com myapi:v2.1.0 | |
| 71 | 104 | ||
| 105 | + | # Update an existing route | |
| 106 | + | gordon routes add myapp.example.com myapp:v2 | |
| 107 | + | ||
| 72 | 108 | # Remote (override) | |
| 73 | 109 | gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | |
| 74 | 110 | ``` | |
| 75 | 111 | ||
| 112 | + | ### Notes | |
| 113 | + | ||
| 114 | + | - `gordon routes add` is idempotent: it creates the route when missing and updates it when present. | |
| 115 | + | - You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image. | |
| 116 | + | ||
| 76 | 117 | --- | |
| 77 | 118 | ||
| 78 | 119 | ## gordon routes remove | |
| @@ -12,7 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |||||
| 12 | | Subcommand | Description | | 12 | | Subcommand | Description | | ||
| 13 | |------------|-------------| | 13 | |------------|-------------| | ||
| 14 | | `list` | List all routes | | 14 | | `list` | List all routes | | ||
| 15 | - | | `add` | Add a new route | | 15 | + | | `add` | Create or update a route | |
| 16 | | `remove` | Remove a route | | 16 | | `remove` | Remove a route | | ||
| 17 | | `deploy` | Deploy a specific route | | 17 | | `deploy` | Deploy a specific route | | ||
| 18 | 18 | ||||
| @@ -24,6 +24,7 @@ List all configured routes. | |||||
| 24 | 24 | ||||
| 25 | ```bash | 25 | ```bash | ||
| 26 | gordon routes list | 26 | gordon routes list | ||
| 27 | + | gordon routes list --json | |||
| 27 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | 28 | gordon routes list --remote https://gordon.mydomain.com --token $TOKEN | ||
| 28 | ``` | 29 | ``` | ||
| 29 | 30 | ||||
| @@ -37,17 +38,49 @@ api.example.com myapi:v2.1.0 running | |||||
| 37 | admin.example.com admin-panel:latest stopped | 38 | admin.example.com admin-panel:latest stopped | ||
| 38 | ``` | 39 | ``` | ||
| 39 | 40 | ||||
| 41 | + | ### JSON Output | |||
| 42 | + | ||||
| 43 | + | ```bash | |||
| 44 | + | gordon routes list --json | |||
| 45 | + | ``` | |||
| 46 | + | ||||
| 47 | + | ```json | |||
| 48 | + | [ | |||
| 49 | + | { | |||
| 50 | + | "domain": "app.example.com", | |||
| 51 | + | "image": "myapp:latest", | |||
| 52 | + | "status": "running" | |||
| 53 | + | }, | |||
| 54 | + | { | |||
| 55 | + | "domain": "api.example.com", | |||
| 56 | + | "image": "myapi:v2.1.0", | |||
| 57 | + | "status": "running" | |||
| 58 | + | } | |||
| 59 | + | ] | |||
| 60 | + | ``` | |||
| 61 | + | ||||
| 62 | + | ### Options | |||
| 63 | + | ||||
| 64 | + | | Option | Description | | |||
| 65 | + | |--------|-------------| | |||
| 66 | + | | `--json` | Output routes as JSON | | |||
| 67 | + | | `--remote` | Remote Gordon URL | | |||
| 68 | + | | `--token` | Authentication token for remote | | |||
| 69 | + | ||||
| 40 | --- | 70 | --- | ||
| 41 | 71 | ||||
| 42 | ## gordon routes add | 72 | ## gordon routes add | ||
| 43 | 73 | ||||
| 44 | - | Add a new route. | 74 | + | Create a new route or update an existing route. |
| 45 | 75 | ||||
| 46 | ```bash | 76 | ```bash | ||
| 47 | gordon routes add <domain> <image> | 77 | gordon routes add <domain> <image> | ||
| 48 | gordon routes add myapp.example.com myapp:latest | 78 | gordon routes add myapp.example.com myapp:latest | ||
| 49 | ``` | 79 | ``` | ||
| 50 | 80 | ||||
| 81 | + | If the route already exists, Gordon updates it to the new image instead of failing. | |||
| 82 | + | The image does not need to be pushed to the Gordon registry before you add the route. | |||
| 83 | + | ||||
| 51 | ### Arguments | 84 | ### Arguments | ||
| 52 | 85 | ||||
| 53 | | Argument | Description | | 86 | | Argument | Description | | ||
| @@ -69,10 +102,18 @@ gordon routes add myapp.example.com myapp:latest | |||||
| 69 | gordon routes add myapp.example.com myapp:latest | 102 | gordon routes add myapp.example.com myapp:latest | ||
| 70 | gordon routes add api.example.com myapi:v2.1.0 | 103 | gordon routes add api.example.com myapi:v2.1.0 | ||
| 71 | 104 | ||||
| 105 | + | # Update an existing route | |||
| 106 | + | gordon routes add myapp.example.com myapp:v2 | |||
| 107 | + | ||||
| 72 | # Remote (override) | 108 | # Remote (override) | ||
| 73 | gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | 109 | gordon routes add myapp.example.com myapp:latest --remote https://gordon.mydomain.com --token $TOKEN | ||
| 74 | ``` | 110 | ``` | ||
| 75 | 111 | ||||
| 112 | + | ### Notes | |||
| 113 | + | ||||
| 114 | + | - `gordon routes add` is idempotent: it creates the route when missing and updates it when present. | |||
| 115 | + | - You can add the route before the image is pushed. Deploy happens when the image is later available or when you deploy an available image. | |||
| 116 | + | ||||
| 76 | --- | 117 | --- | ||
| 77 | 118 | ||||
| 78 | ## gordon routes remove | 119 | ## gordon routes remove | ||
M docs/cli/secrets.md +26 -0
| @@ -39,6 +39,7 @@ gordon secrets list <domain> | |||
| 39 | 39 | ||
| 40 | 40 | | Option | Description | | |
| 41 | 41 | |--------|-------------| | |
| 42 | + | | `--json` | Output secrets as JSON | | |
| 42 | 43 | | `--remote` | Remote Gordon URL | | |
| 43 | 44 | | `--token` | Authentication token for remote | | |
| 44 | 45 | ||
| @@ -67,6 +68,31 @@ API_KEY **** | |||
| 67 | 68 | └─ REDIS_PASSWORD **** | |
| 68 | 69 | ``` | |
| 69 | 70 | ||
| 71 | + | ### JSON Output | |
| 72 | + | ||
| 73 | + | ```bash | |
| 74 | + | gordon secrets list app.mydomain.com --json | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | ```json | |
| 78 | + | { | |
| 79 | + | "domain": "app.mydomain.com", | |
| 80 | + | "secrets": { | |
| 81 | + | "DATABASE_URL": "****", | |
| 82 | + | "API_KEY": "****" | |
| 83 | + | }, | |
| 84 | + | "attachments": { | |
| 85 | + | "postgres": { | |
| 86 | + | "POSTGRES_USER": "****", | |
| 87 | + | "POSTGRES_PASSWORD": "****" | |
| 88 | + | }, | |
| 89 | + | "redis": { | |
| 90 | + | "REDIS_PASSWORD": "****" | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ``` | |
| 95 | + | ||
| 70 | 96 | --- | |
| 71 | 97 | ||
| 72 | 98 | ## gordon secrets set | |
| @@ -39,6 +39,7 @@ gordon secrets list <domain> | |||||
| 39 | 39 | ||||
| 40 | | Option | Description | | 40 | | Option | Description | | ||
| 41 | |--------|-------------| | 41 | |--------|-------------| | ||
| 42 | + | | `--json` | Output secrets as JSON | | |||
| 42 | | `--remote` | Remote Gordon URL | | 43 | | `--remote` | Remote Gordon URL | | ||
| 43 | | `--token` | Authentication token for remote | | 44 | | `--token` | Authentication token for remote | | ||
| 44 | 45 | ||||
| @@ -67,6 +68,31 @@ API_KEY **** | |||||
| 67 | └─ REDIS_PASSWORD **** | 68 | └─ REDIS_PASSWORD **** | ||
| 68 | ``` | 69 | ``` | ||
| 69 | 70 | ||||
| 71 | + | ### JSON Output | |||
| 72 | + | ||||
| 73 | + | ```bash | |||
| 74 | + | gordon secrets list app.mydomain.com --json | |||
| 75 | + | ``` | |||
| 76 | + | ||||
| 77 | + | ```json | |||
| 78 | + | { | |||
| 79 | + | "domain": "app.mydomain.com", | |||
| 80 | + | "secrets": { | |||
| 81 | + | "DATABASE_URL": "****", | |||
| 82 | + | "API_KEY": "****" | |||
| 83 | + | }, | |||
| 84 | + | "attachments": { | |||
| 85 | + | "postgres": { | |||
| 86 | + | "POSTGRES_USER": "****", | |||
| 87 | + | "POSTGRES_PASSWORD": "****" | |||
| 88 | + | }, | |||
| 89 | + | "redis": { | |||
| 90 | + | "REDIS_PASSWORD": "****" | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 70 | --- | 96 | --- | ||
| 71 | 97 | ||||
| 72 | ## gordon secrets set | 98 | ## gordon secrets set | ||
M docs/getting-started.md +19 -9
| @@ -143,19 +143,29 @@ On your local machine: | |||
| 143 | 143 | gordon remotes add prod https://gordon.mydomain.com --token <your-token> | |
| 144 | 144 | gordon remotes use prod | |
| 145 | 145 | ||
| 146 | - | # Build, push, and deploy through Gordon | |
| 147 | - | gordon push myapp --build --no-confirm | |
| 146 | + | # Recommended first-time setup | |
| 147 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 148 | + | ||
| 149 | + | # Then build, push, and deploy | |
| 150 | + | gordon push app.example.com --build --no-confirm | |
| 148 | 151 | ``` | |
| 149 | 152 | ||
| 150 | 153 | What this command does: | |
| 151 | 154 | ||
| 152 | - | - `gordon push myapp` resolves the route for `myapp`, chooses a version tag, | |
| 153 | - | pushes image tags to Gordon's registry, and triggers deploy. | |
| 154 | - | - `--build` runs `docker buildx build` first, injecting `VERSION` from the tag | |
| 155 | - | into the build and then pushing both version and `latest` tags. This requires | |
| 156 | - | Docker with Buildx; a Podman-only setup is not supported for `--build`. | |
| 157 | - | - `--no-confirm` skips the interactive "Deploy now?" prompt so the deploy runs | |
| 158 | - | immediately. | |
| 155 | + | - `gordon bootstrap` creates or updates the route, applies requested attachments, | |
| 156 | + | and stores environment variables. | |
| 157 | + | - This is the recommended path for first deploys because it does not require the | |
| 158 | + | route to exist ahead of time. | |
| 159 | + | - Run `gordon push` separately after bootstrap to build, upload, and deploy the image. | |
| 160 | + | ||
| 161 | + | If the route already exists and you only need to push a new image version, use: | |
| 162 | + | ||
| 163 | + | ```bash | |
| 164 | + | gordon push app.example.com --build --no-confirm | |
| 165 | + | ``` | |
| 166 | + | ||
| 167 | + | `gordon push` still requires the route to already exist so it can resolve the | |
| 168 | + | target image. | |
| 159 | 169 | ||
| 160 | 170 | Your app is now live at `https://app.mydomain.com`! | |
| 161 | 171 | ||
| @@ -143,19 +143,29 @@ On your local machine: | |||||
| 143 | gordon remotes add prod https://gordon.mydomain.com --token <your-token> | 143 | gordon remotes add prod https://gordon.mydomain.com --token <your-token> | ||
| 144 | gordon remotes use prod | 144 | gordon remotes use prod | ||
| 145 | 145 | ||||
| 146 | - | # Build, push, and deploy through Gordon | 146 | + | # Recommended first-time setup |
| 147 | - | gordon push myapp --build --no-confirm | 147 | + | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production |
| 148 | + | ||||
| 149 | + | # Then build, push, and deploy | |||
| 150 | + | gordon push app.example.com --build --no-confirm | |||
| 148 | ``` | 151 | ``` | ||
| 149 | 152 | ||||
| 150 | What this command does: | 153 | What this command does: | ||
| 151 | 154 | ||||
| 152 | - | - `gordon push myapp` resolves the route for `myapp`, chooses a version tag, | 155 | + | - `gordon bootstrap` creates or updates the route, applies requested attachments, |
| 153 | - | pushes image tags to Gordon's registry, and triggers deploy. | 156 | + | and stores environment variables. |
| 154 | - | - `--build` runs `docker buildx build` first, injecting `VERSION` from the tag | 157 | + | - This is the recommended path for first deploys because it does not require the |
| 155 | - | into the build and then pushing both version and `latest` tags. This requires | 158 | + | route to exist ahead of time. |
| 156 | - | Docker with Buildx; a Podman-only setup is not supported for `--build`. | 159 | + | - Run `gordon push` separately after bootstrap to build, upload, and deploy the image. |
| 157 | - | - `--no-confirm` skips the interactive "Deploy now?" prompt so the deploy runs | 160 | + | |
| 158 | - | immediately. | 161 | + | If the route already exists and you only need to push a new image version, use: |
| 162 | + | ||||
| 163 | + | ```bash | |||
| 164 | + | gordon push app.example.com --build --no-confirm | |||
| 165 | + | ``` | |||
| 166 | + | ||||
| 167 | + | `gordon push` still requires the route to already exist so it can resolve the | |||
| 168 | + | target image. | |||
| 159 | 169 | ||||
| 160 | Your app is now live at `https://app.mydomain.com`! | 170 | Your app is now live at `https://app.mydomain.com`! | ||
| 161 | 171 | ||||
A internal/adapters/dto/admin_bootstrap.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // BootstrapRequest represents a bootstrap operation request. | |
| 4 | + | type BootstrapRequest struct { | |
| 5 | + | Domain string `json:"domain"` | |
| 6 | + | Image string `json:"image"` | |
| 7 | + | Attachments []string `json:"attachments,omitempty"` | |
| 8 | + | Env map[string]string `json:"env,omitempty"` | |
| 9 | + | AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"` | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // BootstrapStep represents the result of one bootstrap step. | |
| 13 | + | type BootstrapStep struct { | |
| 14 | + | Name string `json:"name"` | |
| 15 | + | Status string `json:"status"` // "created", "configured", "updated", "noop", "failed" | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // BootstrapResponse represents the result of a bootstrap operation. | |
| 19 | + | type BootstrapResponse struct { | |
| 20 | + | Domain string `json:"domain"` | |
| 21 | + | Image string `json:"image"` | |
| 22 | + | Steps []BootstrapStep `json:"steps"` | |
| 23 | + | Warnings []string `json:"warnings,omitempty"` | |
| 24 | + | Next string `json:"next"` | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // BootstrapRequest represents a bootstrap operation request. | |||
| 4 | + | type BootstrapRequest struct { | |||
| 5 | + | Domain string `json:"domain"` | |||
| 6 | + | Image string `json:"image"` | |||
| 7 | + | Attachments []string `json:"attachments,omitempty"` | |||
| 8 | + | Env map[string]string `json:"env,omitempty"` | |||
| 9 | + | AttachmentEnv map[string]map[string]string `json:"attachment_env,omitempty"` | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // BootstrapStep represents the result of one bootstrap step. | |||
| 13 | + | type BootstrapStep struct { | |||
| 14 | + | Name string `json:"name"` | |||
| 15 | + | Status string `json:"status"` // "created", "configured", "updated", "noop", "failed" | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // BootstrapResponse represents the result of a bootstrap operation. | |||
| 19 | + | type BootstrapResponse struct { | |||
| 20 | + | Domain string `json:"domain"` | |||
| 21 | + | Image string `json:"image"` | |||
| 22 | + | Steps []BootstrapStep `json:"steps"` | |||
| 23 | + | Warnings []string `json:"warnings,omitempty"` | |||
| 24 | + | Next string `json:"next"` | |||
| 25 | + | } | |||
M internal/adapters/in/cli/attachments.go +28 -5
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "sort" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -37,7 +38,9 @@ the local Gordon configuration.`, | |||
| 37 | 38 | ||
| 38 | 39 | // newAttachmentsListCmd creates the attachments list command. | |
| 39 | 40 | func newAttachmentsListCmd() *cobra.Command { | |
| 40 | - | return &cobra.Command{ | |
| 41 | + | var jsonOut bool | |
| 42 | + | ||
| 43 | + | cmd := &cobra.Command{ | |
| 41 | 44 | Use: "list [domain-or-group]", | |
| 42 | 45 | Short: "List configured attachments", | |
| 43 | 46 | Long: `List all configured attachments, or attachments for a specific domain/group. | |
| @@ -56,15 +59,19 @@ Examples: | |||
| 56 | 59 | ||
| 57 | 60 | client, isRemote := GetRemoteClient() | |
| 58 | 61 | if isRemote { | |
| 59 | - | return runAttachmentsListRemote(ctx, client, target) | |
| 62 | + | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) | |
| 60 | 63 | } | |
| 61 | - | return runAttachmentsListLocal(ctx, configPath, target) | |
| 64 | + | return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) | |
| 62 | 65 | }, | |
| 63 | 66 | } | |
| 67 | + | ||
| 68 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 69 | + | ||
| 70 | + | return cmd | |
| 64 | 71 | } | |
| 65 | 72 | ||
| 66 | 73 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | |
| 67 | - | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error { | |
| 74 | + | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { | |
| 68 | 75 | if target != "" { | |
| 69 | 76 | // List for specific target | |
| 70 | 77 | images, err := client.GetAttachmentsConfig(ctx, target) | |
| @@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 77 | 84 | return nil | |
| 78 | 85 | } | |
| 79 | 86 | ||
| 87 | + | if jsonOut { | |
| 88 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 89 | + | } | |
| 90 | + | ||
| 80 | 91 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | |
| 81 | 92 | fmt.Println() | |
| 82 | 93 | for _, img := range images { | |
| @@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 96 | 107 | return nil | |
| 97 | 108 | } | |
| 98 | 109 | ||
| 110 | + | if jsonOut { | |
| 111 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 112 | + | } | |
| 113 | + | ||
| 99 | 114 | // Sort targets for consistent output | |
| 100 | 115 | targets := make([]string, 0, len(attachments)) | |
| 101 | 116 | for t := range attachments { | |
| @@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||
| 118 | 133 | } | |
| 119 | 134 | ||
| 120 | 135 | // runAttachmentsListLocal lists attachments from local configuration. | |
| 121 | - | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error { | |
| 136 | + | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { | |
| 122 | 137 | local, err := GetLocalServices(cfgPath) | |
| 123 | 138 | if err != nil { | |
| 124 | 139 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| @@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||
| 136 | 151 | return nil | |
| 137 | 152 | } | |
| 138 | 153 | ||
| 154 | + | if jsonOut { | |
| 155 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |
| 156 | + | } | |
| 157 | + | ||
| 139 | 158 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | |
| 140 | 159 | fmt.Println() | |
| 141 | 160 | for _, img := range images { | |
| @@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||
| 152 | 171 | return nil | |
| 153 | 172 | } | |
| 154 | 173 | ||
| 174 | + | if jsonOut { | |
| 175 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |
| 176 | + | } | |
| 177 | + | ||
| 155 | 178 | // Sort targets for consistent output | |
| 156 | 179 | targets := make([]string, 0, len(attachments)) | |
| 157 | 180 | for t := range attachments { | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "sort" | 7 | "sort" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -37,7 +38,9 @@ the local Gordon configuration.`, | |||||
| 37 | 38 | ||||
| 38 | // newAttachmentsListCmd creates the attachments list command. | 39 | // newAttachmentsListCmd creates the attachments list command. | ||
| 39 | func newAttachmentsListCmd() *cobra.Command { | 40 | func newAttachmentsListCmd() *cobra.Command { | ||
| 40 | - | return &cobra.Command{ | 41 | + | var jsonOut bool |
| 42 | + | ||||
| 43 | + | cmd := &cobra.Command{ | |||
| 41 | Use: "list [domain-or-group]", | 44 | Use: "list [domain-or-group]", | ||
| 42 | Short: "List configured attachments", | 45 | Short: "List configured attachments", | ||
| 43 | Long: `List all configured attachments, or attachments for a specific domain/group. | 46 | Long: `List all configured attachments, or attachments for a specific domain/group. | ||
| @@ -56,15 +59,19 @@ Examples: | |||||
| 56 | 59 | ||||
| 57 | client, isRemote := GetRemoteClient() | 60 | client, isRemote := GetRemoteClient() | ||
| 58 | if isRemote { | 61 | if isRemote { | ||
| 59 | - | return runAttachmentsListRemote(ctx, client, target) | 62 | + | return runAttachmentsListRemote(ctx, client, target, jsonOut, cmd.OutOrStdout()) |
| 60 | } | 63 | } | ||
| 61 | - | return runAttachmentsListLocal(ctx, configPath, target) | 64 | + | return runAttachmentsListLocal(ctx, configPath, target, jsonOut, cmd.OutOrStdout()) |
| 62 | }, | 65 | }, | ||
| 63 | } | 66 | } | ||
| 67 | + | ||||
| 68 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 69 | + | ||||
| 70 | + | return cmd | |||
| 64 | } | 71 | } | ||
| 65 | 72 | ||||
| 66 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | 73 | // runAttachmentsListRemote lists attachments from a remote Gordon instance. | ||
| 67 | - | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string) error { | 74 | + | func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target string, jsonOut bool, out io.Writer) error { |
| 68 | if target != "" { | 75 | if target != "" { | ||
| 69 | // List for specific target | 76 | // List for specific target | ||
| 70 | images, err := client.GetAttachmentsConfig(ctx, target) | 77 | images, err := client.GetAttachmentsConfig(ctx, target) | ||
| @@ -77,6 +84,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 77 | return nil | 84 | return nil | ||
| 78 | } | 85 | } | ||
| 79 | 86 | ||||
| 87 | + | if jsonOut { | |||
| 88 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 80 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | 91 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s", target))) | ||
| 81 | fmt.Println() | 92 | fmt.Println() | ||
| 82 | for _, img := range images { | 93 | for _, img := range images { | ||
| @@ -96,6 +107,10 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 96 | return nil | 107 | return nil | ||
| 97 | } | 108 | } | ||
| 98 | 109 | ||||
| 110 | + | if jsonOut { | |||
| 111 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 99 | // Sort targets for consistent output | 114 | // Sort targets for consistent output | ||
| 100 | targets := make([]string, 0, len(attachments)) | 115 | targets := make([]string, 0, len(attachments)) | ||
| 101 | for t := range attachments { | 116 | for t := range attachments { | ||
| @@ -118,7 +133,7 @@ func runAttachmentsListRemote(ctx context.Context, client *remote.Client, target | |||||
| 118 | } | 133 | } | ||
| 119 | 134 | ||||
| 120 | // runAttachmentsListLocal lists attachments from local configuration. | 135 | // runAttachmentsListLocal lists attachments from local configuration. | ||
| 121 | - | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) error { | 136 | + | func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string, jsonOut bool, out io.Writer) error { |
| 122 | local, err := GetLocalServices(cfgPath) | 137 | local, err := GetLocalServices(cfgPath) | ||
| 123 | if err != nil { | 138 | if err != nil { | ||
| 124 | return fmt.Errorf("failed to initialize local services: %w", err) | 139 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| @@ -136,6 +151,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||||
| 136 | return nil | 151 | return nil | ||
| 137 | } | 152 | } | ||
| 138 | 153 | ||||
| 154 | + | if jsonOut { | |||
| 155 | + | return writeJSON(out, map[string]any{"target": target, "images": images}) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 139 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | 158 | fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Attachments for %s (local)", target))) | ||
| 140 | fmt.Println() | 159 | fmt.Println() | ||
| 141 | for _, img := range images { | 160 | for _, img := range images { | ||
| @@ -152,6 +171,10 @@ func runAttachmentsListLocal(ctx context.Context, cfgPath string, target string) | |||||
| 152 | return nil | 171 | return nil | ||
| 153 | } | 172 | } | ||
| 154 | 173 | ||||
| 174 | + | if jsonOut { | |||
| 175 | + | return writeJSON(out, map[string]any{"attachments": attachments}) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 155 | // Sort targets for consistent output | 178 | // Sort targets for consistent output | ||
| 156 | targets := make([]string, 0, len(attachments)) | 179 | targets := make([]string, 0, len(attachments)) | ||
| 157 | for t := range attachments { | 180 | for t := range attachments { | ||
M internal/adapters/in/cli/auth.go +30 -3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bufio" | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||
| 130 | 131 | ||
| 131 | 132 | // newTokenListCmd creates the token list command. | |
| 132 | 133 | func newTokenListCmd() *cobra.Command { | |
| 133 | - | var configPath string | |
| 134 | + | var ( | |
| 135 | + | configPath string | |
| 136 | + | jsonOut bool | |
| 137 | + | ) | |
| 134 | 138 | ||
| 135 | 139 | cmd := &cobra.Command{ | |
| 136 | 140 | Use: "list", | |
| 137 | 141 | Short: "List all authentication tokens", | |
| 138 | 142 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | |
| 139 | 143 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 140 | - | return runTokenList(configPath) | |
| 144 | + | return runTokenList(configPath, cmd.OutOrStdout(), jsonOut) | |
| 141 | 145 | }, | |
| 142 | 146 | } | |
| 143 | 147 | ||
| 144 | 148 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 149 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 145 | 150 | ||
| 146 | 151 | return cmd | |
| 147 | 152 | } | |
| @@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||
| 514 | 519 | } | |
| 515 | 520 | ||
| 516 | 521 | // runTokenList lists all stored tokens. | |
| 517 | - | func runTokenList(configPath string) error { | |
| 522 | + | func runTokenList(configPath string, out io.Writer, jsonOut bool) error { | |
| 518 | 523 | cfg, err := loadAuthConfig(configPath) | |
| 519 | 524 | if err != nil { | |
| 520 | 525 | return err | |
| @@ -533,10 +538,32 @@ func runTokenList(configPath string) error { | |||
| 533 | 538 | } | |
| 534 | 539 | ||
| 535 | 540 | if len(tokens) == 0 { | |
| 541 | + | if jsonOut { | |
| 542 | + | return writeJSON(out, []map[string]any{}) | |
| 543 | + | } | |
| 536 | 544 | fmt.Println("No tokens found.") | |
| 537 | 545 | return nil | |
| 538 | 546 | } | |
| 539 | 547 | ||
| 548 | + | if jsonOut { | |
| 549 | + | payload := make([]map[string]any, 0, len(tokens)) | |
| 550 | + | for _, t := range tokens { | |
| 551 | + | var expiresAt *time.Time | |
| 552 | + | if !t.ExpiresAt.IsZero() { | |
| 553 | + | expiresAt = &t.ExpiresAt | |
| 554 | + | } | |
| 555 | + | payload = append(payload, map[string]any{ | |
| 556 | + | "id": t.ID, | |
| 557 | + | "description": t.Subject, | |
| 558 | + | "scopes": t.Scopes, | |
| 559 | + | "created_at": t.IssuedAt, | |
| 560 | + | "expires_at": expiresAt, | |
| 561 | + | "revoked": t.Revoked, | |
| 562 | + | }) | |
| 563 | + | } | |
| 564 | + | return writeJSON(out, payload) | |
| 565 | + | } | |
| 566 | + | ||
| 540 | 567 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | |
| 541 | 568 | fmt.Println(strings.Repeat("-", 90)) | |
| 542 | 569 | ||
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -130,18 +131,22 @@ Use --expiry=0 for a token that never expires (useful for CI).`, | |||||
| 130 | 131 | ||||
| 131 | // newTokenListCmd creates the token list command. | 132 | // newTokenListCmd creates the token list command. | ||
| 132 | func newTokenListCmd() *cobra.Command { | 133 | func newTokenListCmd() *cobra.Command { | ||
| 133 | - | var configPath string | 134 | + | var ( |
| 135 | + | configPath string | |||
| 136 | + | jsonOut bool | |||
| 137 | + | ) | |||
| 134 | 138 | ||||
| 135 | cmd := &cobra.Command{ | 139 | cmd := &cobra.Command{ | ||
| 136 | Use: "list", | 140 | Use: "list", | ||
| 137 | Short: "List all authentication tokens", | 141 | Short: "List all authentication tokens", | ||
| 138 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | 142 | Long: `List all stored authentication tokens with their subjects and expiry information.`, | ||
| 139 | RunE: func(cmd *cobra.Command, args []string) error { | 143 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 140 | - | return runTokenList(configPath) | 144 | + | return runTokenList(configPath, cmd.OutOrStdout(), jsonOut) |
| 141 | }, | 145 | }, | ||
| 142 | } | 146 | } | ||
| 143 | 147 | ||||
| 144 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | 148 | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | ||
| 149 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 145 | 150 | ||||
| 146 | return cmd | 151 | return cmd | ||
| 147 | } | 152 | } | ||
| @@ -514,7 +519,7 @@ func runTokenGenerate(subject, scopesStr, expiryStr, configPath string) error { | |||||
| 514 | } | 519 | } | ||
| 515 | 520 | ||||
| 516 | // runTokenList lists all stored tokens. | 521 | // runTokenList lists all stored tokens. | ||
| 517 | - | func runTokenList(configPath string) error { | 522 | + | func runTokenList(configPath string, out io.Writer, jsonOut bool) error { |
| 518 | cfg, err := loadAuthConfig(configPath) | 523 | cfg, err := loadAuthConfig(configPath) | ||
| 519 | if err != nil { | 524 | if err != nil { | ||
| 520 | return err | 525 | return err | ||
| @@ -533,10 +538,32 @@ func runTokenList(configPath string) error { | |||||
| 533 | } | 538 | } | ||
| 534 | 539 | ||||
| 535 | if len(tokens) == 0 { | 540 | if len(tokens) == 0 { | ||
| 541 | + | if jsonOut { | |||
| 542 | + | return writeJSON(out, []map[string]any{}) | |||
| 543 | + | } | |||
| 536 | fmt.Println("No tokens found.") | 544 | fmt.Println("No tokens found.") | ||
| 537 | return nil | 545 | return nil | ||
| 538 | } | 546 | } | ||
| 539 | 547 | ||||
| 548 | + | if jsonOut { | |||
| 549 | + | payload := make([]map[string]any, 0, len(tokens)) | |||
| 550 | + | for _, t := range tokens { | |||
| 551 | + | var expiresAt *time.Time | |||
| 552 | + | if !t.ExpiresAt.IsZero() { | |||
| 553 | + | expiresAt = &t.ExpiresAt | |||
| 554 | + | } | |||
| 555 | + | payload = append(payload, map[string]any{ | |||
| 556 | + | "id": t.ID, | |||
| 557 | + | "description": t.Subject, | |||
| 558 | + | "scopes": t.Scopes, | |||
| 559 | + | "created_at": t.IssuedAt, | |||
| 560 | + | "expires_at": expiresAt, | |||
| 561 | + | "revoked": t.Revoked, | |||
| 562 | + | }) | |||
| 563 | + | } | |||
| 564 | + | return writeJSON(out, payload) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 540 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | 567 | fmt.Printf("%-36s %-20s %-20s %-10s\n", "ID", "Subject", "Expires", "Revoked") | ||
| 541 | fmt.Println(strings.Repeat("-", 90)) | 568 | fmt.Println(strings.Repeat("-", 90)) | ||
| 542 | 569 | ||||
M internal/adapters/in/cli/backup.go +38 -24
| @@ -2,10 +2,12 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "io" | |
| 5 | 6 | "os" | |
| 6 | 7 | "text/tabwriter" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 9 | 11 | "github.com/spf13/cobra" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -30,9 +32,12 @@ instance when --remote targeting is configured.`, | |||
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | func newBackupListCmd() *cobra.Command { | |
| 33 | - | return &cobra.Command{ | |
| 35 | + | var jsonOut bool | |
| 36 | + | ||
| 37 | + | cmd := &cobra.Command{ | |
| 34 | 38 | Use: "list [domain]", | |
| 35 | 39 | Short: "List backups", | |
| 40 | + | Long: cliRenderMuted("List backups for all domains or a specific domain."), | |
| 36 | 41 | Args: cobra.MaximumNArgs(1), | |
| 37 | 42 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 38 | 43 | handle, err := resolveControlPlane(configPath) | |
| @@ -51,32 +56,41 @@ func newBackupListCmd() *cobra.Command { | |||
| 51 | 56 | return fmt.Errorf("failed to list backups: %w", err) | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | - | if len(jobs) == 0 { | |
| 55 | - | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | |
| 56 | - | return err | |
| 57 | - | } | |
| 58 | - | return nil | |
| 59 | - | } | |
| 60 | - | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |
| 61 | - | return err | |
| 62 | - | } | |
| 63 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 64 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 65 | - | return err | |
| 66 | - | } | |
| 59 | + | return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) | |
| 60 | + | }, | |
| 61 | + | } | |
| 67 | 62 | ||
| 68 | - | for _, job := range jobs { | |
| 69 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |
| 70 | - | return err | |
| 71 | - | } | |
| 72 | - | } | |
| 73 | - | if err := w.Flush(); err != nil { | |
| 74 | - | return err | |
| 75 | - | } | |
| 63 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 76 | 64 | ||
| 77 | - | return nil | |
| 78 | - | }, | |
| 65 | + | return cmd | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |
| 69 | + | if len(jobs) == 0 { | |
| 70 | + | if jsonOut { | |
| 71 | + | return writeJSON(out, []dto.BackupJob{}) | |
| 72 | + | } | |
| 73 | + | return cliWriteLine(out, cliRenderMuted("No backups found")) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | if jsonOut { | |
| 77 | + | return writeJSON(out, jobs) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 84 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 85 | + | return err | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | for _, job := range jobs { | |
| 89 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |
| 90 | + | return err | |
| 91 | + | } | |
| 79 | 92 | } | |
| 93 | + | return w.Flush() | |
| 80 | 94 | } | |
| 81 | 95 | ||
| 82 | 96 | func newBackupRunCmd() *cobra.Command { | |
| @@ -2,10 +2,12 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "io" | |||
| 5 | "os" | 6 | "os" | ||
| 6 | "text/tabwriter" | 7 | "text/tabwriter" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 9 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -30,9 +32,12 @@ instance when --remote targeting is configured.`, | |||||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | func newBackupListCmd() *cobra.Command { | 34 | func newBackupListCmd() *cobra.Command { | ||
| 33 | - | return &cobra.Command{ | 35 | + | var jsonOut bool |
| 36 | + | ||||
| 37 | + | cmd := &cobra.Command{ | |||
| 34 | Use: "list [domain]", | 38 | Use: "list [domain]", | ||
| 35 | Short: "List backups", | 39 | Short: "List backups", | ||
| 40 | + | Long: cliRenderMuted("List backups for all domains or a specific domain."), | |||
| 36 | Args: cobra.MaximumNArgs(1), | 41 | Args: cobra.MaximumNArgs(1), | ||
| 37 | RunE: func(cmd *cobra.Command, args []string) error { | 42 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 38 | handle, err := resolveControlPlane(configPath) | 43 | handle, err := resolveControlPlane(configPath) | ||
| @@ -51,32 +56,41 @@ func newBackupListCmd() *cobra.Command { | |||||
| 51 | return fmt.Errorf("failed to list backups: %w", err) | 56 | return fmt.Errorf("failed to list backups: %w", err) | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | - | if len(jobs) == 0 { | 59 | + | return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut) |
| 55 | - | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | 60 | + | }, |
| 56 | - | return err | 61 | + | } |
| 57 | - | } | |||
| 58 | - | return nil | |||
| 59 | - | } | |||
| 60 | - | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |||
| 61 | - | return err | |||
| 62 | - | } | |||
| 63 | - | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |||
| 64 | - | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |||
| 65 | - | return err | |||
| 66 | - | } | |||
| 67 | 62 | ||||
| 68 | - | for _, job := range jobs { | 63 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") |
| 69 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |||
| 70 | - | return err | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | if err := w.Flush(); err != nil { | |||
| 74 | - | return err | |||
| 75 | - | } | |||
| 76 | 64 | ||||
| 77 | - | return nil | 65 | + | return cmd |
| 78 | - | }, | 66 | + | } |
| 67 | + | ||||
| 68 | + | func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |||
| 69 | + | if len(jobs) == 0 { | |||
| 70 | + | if jsonOut { | |||
| 71 | + | return writeJSON(out, []dto.BackupJob{}) | |||
| 72 | + | } | |||
| 73 | + | return cliWriteLine(out, cliRenderMuted("No backups found")) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | if jsonOut { | |||
| 77 | + | return writeJSON(out, jobs) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 84 | + | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |||
| 85 | + | return err | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | for _, job := range jobs { | |||
| 89 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil { | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 79 | } | 92 | } | ||
| 93 | + | return w.Flush() | |||
| 80 | } | 94 | } | ||
| 81 | 95 | ||||
| 82 | func newBackupRunCmd() *cobra.Command { | 96 | func newBackupRunCmd() *cobra.Command { | ||
A internal/adapters/in/cli/bootstrap.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sort" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/spf13/cobra" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func newBootstrapCmd() *cobra.Command { | |
| 14 | + | var attachments []string | |
| 15 | + | var envPairs []string | |
| 16 | + | var attachmentEnvPairs []string | |
| 17 | + | var configPath string | |
| 18 | + | ||
| 19 | + | cmd := &cobra.Command{ | |
| 20 | + | Use: "bootstrap <domain> <image>", | |
| 21 | + | Short: "Create a route, attachments, and secrets together", | |
| 22 | + | Args: cobra.ExactArgs(2), | |
| 23 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 24 | + | ctx := cmd.Context() | |
| 25 | + | req := dto.BootstrapRequest{ | |
| 26 | + | Domain: args[0], | |
| 27 | + | Image: args[1], | |
| 28 | + | Attachments: append([]string(nil), attachments...), | |
| 29 | + | Env: map[string]string{}, | |
| 30 | + | AttachmentEnv: map[string]map[string]string{}, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for _, pair := range envPairs { | |
| 34 | + | parts := strings.SplitN(pair, "=", 2) | |
| 35 | + | if len(parts) != 2 { | |
| 36 | + | return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |
| 37 | + | } | |
| 38 | + | req.Env[parts[0]] = parts[1] | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, pair := range attachmentEnvPairs { | |
| 42 | + | serviceAndKV := strings.SplitN(pair, ":", 2) | |
| 43 | + | if len(serviceAndKV) != 2 { | |
| 44 | + | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 45 | + | } | |
| 46 | + | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |
| 47 | + | if len(keyValue) != 2 { | |
| 48 | + | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |
| 49 | + | } | |
| 50 | + | service := serviceAndKV[0] | |
| 51 | + | if req.AttachmentEnv[service] == nil { | |
| 52 | + | req.AttachmentEnv[service] = map[string]string{} | |
| 53 | + | } | |
| 54 | + | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | if len(req.Env) == 0 { | |
| 58 | + | req.Env = nil | |
| 59 | + | } | |
| 60 | + | if len(req.AttachmentEnv) == 0 { | |
| 61 | + | req.AttachmentEnv = nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | handle, err := resolveControlPlane(configPath) | |
| 65 | + | if err != nil { | |
| 66 | + | return err | |
| 67 | + | } | |
| 68 | + | defer handle.close() | |
| 69 | + | ||
| 70 | + | resp, err := handle.plane.Bootstrap(ctx, req) | |
| 71 | + | if resp != nil { | |
| 72 | + | if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil { | |
| 73 | + | return writeErr | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | if err != nil { | |
| 77 | + | return fmt.Errorf("failed to bootstrap: %w", err) | |
| 78 | + | } | |
| 79 | + | out := cmd.OutOrStdout() | |
| 80 | + | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image)) | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |
| 88 | + | cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)") | |
| 89 | + | cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)") | |
| 90 | + | cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)") | |
| 91 | + | ||
| 92 | + | return cmd | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error { | |
| 96 | + | out := cmd.OutOrStdout() | |
| 97 | + | if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil { | |
| 101 | + | return err | |
| 102 | + | } | |
| 103 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil { | |
| 104 | + | return err | |
| 105 | + | } | |
| 106 | + | for _, step := range resp.Steps { | |
| 107 | + | if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | if len(resp.Warnings) > 0 { | |
| 112 | + | sorted := append([]string(nil), resp.Warnings...) | |
| 113 | + | sort.Strings(sorted) | |
| 114 | + | for _, warning := range sorted { | |
| 115 | + | if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil { | |
| 116 | + | return err | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sort" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/spf13/cobra" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func newBootstrapCmd() *cobra.Command { | |||
| 14 | + | var attachments []string | |||
| 15 | + | var envPairs []string | |||
| 16 | + | var attachmentEnvPairs []string | |||
| 17 | + | var configPath string | |||
| 18 | + | ||||
| 19 | + | cmd := &cobra.Command{ | |||
| 20 | + | Use: "bootstrap <domain> <image>", | |||
| 21 | + | Short: "Create a route, attachments, and secrets together", | |||
| 22 | + | Args: cobra.ExactArgs(2), | |||
| 23 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 24 | + | ctx := cmd.Context() | |||
| 25 | + | req := dto.BootstrapRequest{ | |||
| 26 | + | Domain: args[0], | |||
| 27 | + | Image: args[1], | |||
| 28 | + | Attachments: append([]string(nil), attachments...), | |||
| 29 | + | Env: map[string]string{}, | |||
| 30 | + | AttachmentEnv: map[string]map[string]string{}, | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | for _, pair := range envPairs { | |||
| 34 | + | parts := strings.SplitN(pair, "=", 2) | |||
| 35 | + | if len(parts) != 2 { | |||
| 36 | + | return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair) | |||
| 37 | + | } | |||
| 38 | + | req.Env[parts[0]] = parts[1] | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, pair := range attachmentEnvPairs { | |||
| 42 | + | serviceAndKV := strings.SplitN(pair, ":", 2) | |||
| 43 | + | if len(serviceAndKV) != 2 { | |||
| 44 | + | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 45 | + | } | |||
| 46 | + | keyValue := strings.SplitN(serviceAndKV[1], "=", 2) | |||
| 47 | + | if len(keyValue) != 2 { | |||
| 48 | + | return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair) | |||
| 49 | + | } | |||
| 50 | + | service := serviceAndKV[0] | |||
| 51 | + | if req.AttachmentEnv[service] == nil { | |||
| 52 | + | req.AttachmentEnv[service] = map[string]string{} | |||
| 53 | + | } | |||
| 54 | + | req.AttachmentEnv[service][keyValue[0]] = keyValue[1] | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | if len(req.Env) == 0 { | |||
| 58 | + | req.Env = nil | |||
| 59 | + | } | |||
| 60 | + | if len(req.AttachmentEnv) == 0 { | |||
| 61 | + | req.AttachmentEnv = nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | handle, err := resolveControlPlane(configPath) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return err | |||
| 67 | + | } | |||
| 68 | + | defer handle.close() | |||
| 69 | + | ||||
| 70 | + | resp, err := handle.plane.Bootstrap(ctx, req) | |||
| 71 | + | if resp != nil { | |||
| 72 | + | if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil { | |||
| 73 | + | return writeErr | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | if err != nil { | |||
| 77 | + | return fmt.Errorf("failed to bootstrap: %w", err) | |||
| 78 | + | } | |||
| 79 | + | out := cmd.OutOrStdout() | |||
| 80 | + | if err := cliWriteLine(out, cliRenderSuccess("Bootstrap complete")); err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | return cliWriteLine(out, fmt.Sprintf("Next: gordon push %s --build", req.Image)) | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file") | |||
| 88 | + | cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)") | |||
| 89 | + | cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)") | |||
| 90 | + | cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)") | |||
| 91 | + | ||||
| 92 | + | return cmd | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error { | |||
| 96 | + | out := cmd.OutOrStdout() | |||
| 97 | + | if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil { | |||
| 101 | + | return err | |||
| 102 | + | } | |||
| 103 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil { | |||
| 104 | + | return err | |||
| 105 | + | } | |||
| 106 | + | for _, step := range resp.Steps { | |||
| 107 | + | if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil { | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | if len(resp.Warnings) > 0 { | |||
| 112 | + | sorted := append([]string(nil), resp.Warnings...) | |||
| 113 | + | sort.Strings(sorted) | |||
| 114 | + | for _, warning := range sorted { | |||
| 115 | + | if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil { | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | } | |||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -20,6 +20,7 @@ type ControlPlane interface { | |||
| 20 | 20 | AddRoute(ctx context.Context, route domain.Route) error | |
| 21 | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 22 | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | |
| 23 | + | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |
| 23 | 24 | ||
| 24 | 25 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 25 | 26 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| @@ -20,6 +20,7 @@ type ControlPlane interface { | |||||
| 20 | AddRoute(ctx context.Context, route domain.Route) error | 20 | AddRoute(ctx context.Context, route domain.Route) error | ||
| 21 | UpdateRoute(ctx context.Context, route domain.Route) error | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | ||
| 22 | RemoveRoute(ctx context.Context, routeDomain string) error | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | ||
| 23 | + | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |||
| 23 | 24 | ||||
| 24 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | 25 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | ||
| 25 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | 26 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | ||
M internal/adapters/in/cli/controlplane_local.go +88 -0
| @@ -2,6 +2,7 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -122,6 +123,93 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) | |||
| 122 | 123 | return l.configSvc.RemoveRoute(ctx, routeDomain) | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 127 | + | resp := &dto.BootstrapResponse{ | |
| 128 | + | Domain: req.Domain, | |
| 129 | + | Image: req.Image, | |
| 130 | + | Next: fmt.Sprintf("push %s to trigger deployment", req.Image), | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | if l.configSvc == nil { | |
| 134 | + | return resp, fmt.Errorf("local config service unavailable") | |
| 135 | + | } | |
| 136 | + | needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0 | |
| 137 | + | if needsSecrets && l.secretSvc == nil { | |
| 138 | + | return resp, fmt.Errorf("local secret service unavailable") | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | addStep := func(name, status string) { | |
| 142 | + | resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |
| 146 | + | switch err { | |
| 147 | + | case nil: | |
| 148 | + | addStep("route", "configured") | |
| 149 | + | default: | |
| 150 | + | addStep("route", "failed") | |
| 151 | + | return resp, err | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | if err := l.bootstrapAttachments(ctx, req, addStep); err != nil { | |
| 155 | + | return resp, err | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | if err := l.bootstrapSecrets(ctx, req, addStep); err != nil { | |
| 159 | + | return resp, err | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil { | |
| 163 | + | return resp, err | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | return resp, nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 170 | + | for _, attachment := range req.Attachments { | |
| 171 | + | err := l.configSvc.AddAttachment(ctx, req.Domain, attachment) | |
| 172 | + | if err == nil { | |
| 173 | + | addStep("attachment:"+attachment, "created") | |
| 174 | + | continue | |
| 175 | + | } | |
| 176 | + | if errors.Is(err, domain.ErrAttachmentExists) { | |
| 177 | + | addStep("attachment:"+attachment, "noop") | |
| 178 | + | continue | |
| 179 | + | } | |
| 180 | + | addStep("attachment:"+attachment, "failed") | |
| 181 | + | return err | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | return nil | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 188 | + | if len(req.Env) == 0 { | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil { | |
| 193 | + | addStep("env", "failed") | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | addStep("env", "updated") | |
| 198 | + | return nil | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |
| 202 | + | for service, env := range req.AttachmentEnv { | |
| 203 | + | if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil { | |
| 204 | + | addStep("attachment_env:"+service, "failed") | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | addStep("attachment_env:"+service, "updated") | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | ||
| 125 | 213 | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 126 | 214 | if l.secretSvc == nil { | |
| 127 | 215 | return nil, fmt.Errorf("local secret service unavailable") | |
| @@ -2,6 +2,7 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "time" | 7 | "time" | ||
| 7 | 8 | ||||
| @@ -122,6 +123,93 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) | |||||
| 122 | return l.configSvc.RemoveRoute(ctx, routeDomain) | 123 | return l.configSvc.RemoveRoute(ctx, routeDomain) | ||
| 123 | } | 124 | } | ||
| 124 | 125 | ||||
| 126 | + | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 127 | + | resp := &dto.BootstrapResponse{ | |||
| 128 | + | Domain: req.Domain, | |||
| 129 | + | Image: req.Image, | |||
| 130 | + | Next: fmt.Sprintf("push %s to trigger deployment", req.Image), | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | if l.configSvc == nil { | |||
| 134 | + | return resp, fmt.Errorf("local config service unavailable") | |||
| 135 | + | } | |||
| 136 | + | needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0 | |||
| 137 | + | if needsSecrets && l.secretSvc == nil { | |||
| 138 | + | return resp, fmt.Errorf("local secret service unavailable") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | addStep := func(name, status string) { | |||
| 142 | + | resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |||
| 146 | + | switch err { | |||
| 147 | + | case nil: | |||
| 148 | + | addStep("route", "configured") | |||
| 149 | + | default: | |||
| 150 | + | addStep("route", "failed") | |||
| 151 | + | return resp, err | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | if err := l.bootstrapAttachments(ctx, req, addStep); err != nil { | |||
| 155 | + | return resp, err | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | if err := l.bootstrapSecrets(ctx, req, addStep); err != nil { | |||
| 159 | + | return resp, err | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil { | |||
| 163 | + | return resp, err | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | return resp, nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |||
| 170 | + | for _, attachment := range req.Attachments { | |||
| 171 | + | err := l.configSvc.AddAttachment(ctx, req.Domain, attachment) | |||
| 172 | + | if err == nil { | |||
| 173 | + | addStep("attachment:"+attachment, "created") | |||
| 174 | + | continue | |||
| 175 | + | } | |||
| 176 | + | if errors.Is(err, domain.ErrAttachmentExists) { | |||
| 177 | + | addStep("attachment:"+attachment, "noop") | |||
| 178 | + | continue | |||
| 179 | + | } | |||
| 180 | + | addStep("attachment:"+attachment, "failed") | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | return nil | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |||
| 188 | + | if len(req.Env) == 0 { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil { | |||
| 193 | + | addStep("env", "failed") | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | addStep("env", "updated") | |||
| 198 | + | return nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error { | |||
| 202 | + | for service, env := range req.AttachmentEnv { | |||
| 203 | + | if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil { | |||
| 204 | + | addStep("attachment_env:"+service, "failed") | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | addStep("attachment_env:"+service, "updated") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | return nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 125 | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | 213 | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | ||
| 126 | if l.secretSvc == nil { | 214 | if l.secretSvc == nil { | ||
| 127 | return nil, fmt.Errorf("local secret service unavailable") | 215 | return nil, fmt.Errorf("local secret service unavailable") | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -44,6 +44,10 @@ func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string | |||
| 44 | 44 | return r.client.RemoveRoute(ctx, routeDomain) | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 48 | + | return r.client.Bootstrap(ctx, req) | |
| 49 | + | } | |
| 50 | + | ||
| 47 | 51 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 48 | 52 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 49 | 53 | } | |
| @@ -44,6 +44,10 @@ func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string | |||||
| 44 | return r.client.RemoveRoute(ctx, routeDomain) | 44 | return r.client.RemoveRoute(ctx, routeDomain) | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 48 | + | return r.client.Bootstrap(ctx, req) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 47 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | 51 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | ||
| 48 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | 52 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | ||
| 49 | } | 53 | } | ||
M internal/adapters/in/cli/images.go +13 -3
| @@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`, | |||
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | 83 | func newImagesListCmd() *cobra.Command { | |
| 84 | - | return &cobra.Command{ | |
| 84 | + | var jsonOut bool | |
| 85 | + | ||
| 86 | + | cmd := &cobra.Command{ | |
| 85 | 87 | Use: "list", | |
| 86 | 88 | Short: "List runtime images and registry tags", | |
| 87 | 89 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| @@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command { | |||
| 90 | 92 | return fmt.Errorf("images commands require a configured remote target") | |
| 91 | 93 | } | |
| 92 | 94 | ||
| 93 | - | return runImagesList(cmd.Context(), client, cmd.OutOrStdout()) | |
| 95 | + | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) | |
| 94 | 96 | }, | |
| 95 | 97 | } | |
| 98 | + | ||
| 99 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 100 | + | ||
| 101 | + | return cmd | |
| 96 | 102 | } | |
| 97 | 103 | ||
| 98 | 104 | func newImagesPruneCmd() *cobra.Command { | |
| @@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru | |||
| 191 | 197 | } | |
| 192 | 198 | } | |
| 193 | 199 | ||
| 194 | - | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |
| 200 | + | func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error { | |
| 195 | 201 | images, err := client.ListImages(ctx) | |
| 196 | 202 | if err != nil { | |
| 197 | 203 | return fmt.Errorf("failed to list images: %w", err) | |
| 198 | 204 | } | |
| 199 | 205 | ||
| 206 | + | if jsonOut { | |
| 207 | + | return writeJSON(out, images) | |
| 208 | + | } | |
| 209 | + | ||
| 200 | 210 | if len(images) == 0 { | |
| 201 | 211 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | |
| 202 | 212 | return err | |
| @@ -81,7 +81,9 @@ These commands currently require remote mode with a configured target.`, | |||||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | func newImagesListCmd() *cobra.Command { | 83 | func newImagesListCmd() *cobra.Command { | ||
| 84 | - | return &cobra.Command{ | 84 | + | var jsonOut bool |
| 85 | + | ||||
| 86 | + | cmd := &cobra.Command{ | |||
| 85 | Use: "list", | 87 | Use: "list", | ||
| 86 | Short: "List runtime images and registry tags", | 88 | Short: "List runtime images and registry tags", | ||
| 87 | RunE: func(cmd *cobra.Command, _ []string) error { | 89 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| @@ -90,9 +92,13 @@ func newImagesListCmd() *cobra.Command { | |||||
| 90 | return fmt.Errorf("images commands require a configured remote target") | 92 | return fmt.Errorf("images commands require a configured remote target") | ||
| 91 | } | 93 | } | ||
| 92 | 94 | ||||
| 93 | - | return runImagesList(cmd.Context(), client, cmd.OutOrStdout()) | 95 | + | return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut) |
| 94 | }, | 96 | }, | ||
| 95 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 100 | + | ||||
| 101 | + | return cmd | |||
| 96 | } | 102 | } | ||
| 97 | 103 | ||||
| 98 | func newImagesPruneCmd() *cobra.Command { | 104 | func newImagesPruneCmd() *cobra.Command { | ||
| @@ -191,12 +197,16 @@ func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePru | |||||
| 191 | } | 197 | } | ||
| 192 | } | 198 | } | ||
| 193 | 199 | ||||
| 194 | - | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | 200 | + | func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error { |
| 195 | images, err := client.ListImages(ctx) | 201 | images, err := client.ListImages(ctx) | ||
| 196 | if err != nil { | 202 | if err != nil { | ||
| 197 | return fmt.Errorf("failed to list images: %w", err) | 203 | return fmt.Errorf("failed to list images: %w", err) | ||
| 198 | } | 204 | } | ||
| 199 | 205 | ||||
| 206 | + | if jsonOut { | |||
| 207 | + | return writeJSON(out, images) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 200 | if len(images) == 0 { | 210 | if len(images) == 0 { | ||
| 201 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | 211 | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | ||
| 202 | return err | 212 | return err | ||
M internal/adapters/in/cli/images_test.go +5 -5
| @@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | var out bytes.Buffer | |
| 61 | - | err := runImagesList(context.Background(), client, &out) | |
| 61 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 62 | 62 | require.NoError(t, err) | |
| 63 | 63 | ||
| 64 | 64 | text := out.String() | |
| @@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | var out bytes.Buffer | |
| 81 | - | err := runImagesList(context.Background(), client, &out) | |
| 81 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 82 | 82 | require.NoError(t, err) | |
| 83 | 83 | ||
| 84 | 84 | text := stripANSI(out.String()) | |
| @@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) | |||
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | var out bytes.Buffer | |
| 106 | - | err := runImagesList(context.Background(), client, &out) | |
| 106 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 107 | 107 | require.NoError(t, err) | |
| 108 | 108 | ||
| 109 | 109 | text := stripANSI(out.String()) | |
| @@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | 147 | var out bytes.Buffer | |
| 148 | - | err := runImagesList(context.Background(), client, &out) | |
| 148 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 149 | 149 | require.NoError(t, err) | |
| 150 | 150 | ||
| 151 | 151 | text := stripANSI(out.String()) | |
| @@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||
| 177 | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | |
| 178 | 178 | ||
| 179 | 179 | var out bytes.Buffer | |
| 180 | - | err := runImagesList(context.Background(), client, &out) | |
| 180 | + | err := runImagesList(context.Background(), client, &out, false) | |
| 181 | 181 | require.NoError(t, err) | |
| 182 | 182 | ||
| 183 | 183 | text := out.String() | |
| @@ -58,7 +58,7 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | var out bytes.Buffer | 60 | var out bytes.Buffer | ||
| 61 | - | err := runImagesList(context.Background(), client, &out) | 61 | + | err := runImagesList(context.Background(), client, &out, false) |
| 62 | require.NoError(t, err) | 62 | require.NoError(t, err) | ||
| 63 | 63 | ||||
| 64 | text := out.String() | 64 | text := out.String() | ||
| @@ -78,7 +78,7 @@ func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | var out bytes.Buffer | 80 | var out bytes.Buffer | ||
| 81 | - | err := runImagesList(context.Background(), client, &out) | 81 | + | err := runImagesList(context.Background(), client, &out, false) |
| 82 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| 83 | 83 | ||||
| 84 | text := stripANSI(out.String()) | 84 | text := stripANSI(out.String()) | ||
| @@ -103,7 +103,7 @@ func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) | |||||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| 105 | var out bytes.Buffer | 105 | var out bytes.Buffer | ||
| 106 | - | err := runImagesList(context.Background(), client, &out) | 106 | + | err := runImagesList(context.Background(), client, &out, false) |
| 107 | require.NoError(t, err) | 107 | require.NoError(t, err) | ||
| 108 | 108 | ||||
| 109 | text := stripANSI(out.String()) | 109 | text := stripANSI(out.String()) | ||
| @@ -145,7 +145,7 @@ func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) | |||||
| 145 | } | 145 | } | ||
| 146 | 146 | ||||
| 147 | var out bytes.Buffer | 147 | var out bytes.Buffer | ||
| 148 | - | err := runImagesList(context.Background(), client, &out) | 148 | + | err := runImagesList(context.Background(), client, &out, false) |
| 149 | require.NoError(t, err) | 149 | require.NoError(t, err) | ||
| 150 | 150 | ||||
| 151 | text := stripANSI(out.String()) | 151 | text := stripANSI(out.String()) | ||
| @@ -177,7 +177,7 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||||
| 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | ||
| 178 | 178 | ||||
| 179 | var out bytes.Buffer | 179 | var out bytes.Buffer | ||
| 180 | - | err := runImagesList(context.Background(), client, &out) | 180 | + | err := runImagesList(context.Background(), client, &out, false) |
| 181 | require.NoError(t, err) | 181 | require.NoError(t, err) | ||
| 182 | 182 | ||||
| 183 | text := out.String() | 183 | text := out.String() | ||
A internal/adapters/in/cli/json.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "io" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // writeJSON encodes v as indented JSON to the given writer. | |
| 9 | + | func writeJSON(out io.Writer, v any) error { | |
| 10 | + | enc := json.NewEncoder(out) | |
| 11 | + | enc.SetIndent("", " ") | |
| 12 | + | return enc.Encode(v) | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "io" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // writeJSON encodes v as indented JSON to the given writer. | |||
| 9 | + | func writeJSON(out io.Writer, v any) error { | |||
| 10 | + | enc := json.NewEncoder(out) | |||
| 11 | + | enc.SetIndent("", " ") | |||
| 12 | + | return enc.Encode(v) | |||
| 13 | + | } | |||
A internal/adapters/in/cli/json_parity_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/spf13/cobra" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |
| 11 | + | commands := []struct { | |
| 12 | + | name string | |
| 13 | + | builder func() *cobra.Command | |
| 14 | + | }{ | |
| 15 | + | {"routes list", newRoutesListCmd}, | |
| 16 | + | {"attachments list", newAttachmentsListCmd}, | |
| 17 | + | {"secrets list", newSecretsListCmd}, | |
| 18 | + | {"images list", newImagesListCmd}, | |
| 19 | + | {"backup list", newBackupListCmd}, | |
| 20 | + | {"auth token list", newTokenListCmd}, | |
| 21 | + | {"remotes list", newRemotesListCmd}, | |
| 22 | + | {"rollback list", newRollbackListCmd}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tc := range commands { | |
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |
| 27 | + | cmd := tc.builder() | |
| 28 | + | f := cmd.Flags().Lookup("json") | |
| 29 | + | assert.NotNil(t, f, "command %q should have --json flag", tc.name) | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/cobra" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 11 | + | commands := []struct { | |||
| 12 | + | name string | |||
| 13 | + | builder func() *cobra.Command | |||
| 14 | + | }{ | |||
| 15 | + | {"routes list", newRoutesListCmd}, | |||
| 16 | + | {"attachments list", newAttachmentsListCmd}, | |||
| 17 | + | {"secrets list", newSecretsListCmd}, | |||
| 18 | + | {"images list", newImagesListCmd}, | |||
| 19 | + | {"backup list", newBackupListCmd}, | |||
| 20 | + | {"auth token list", newTokenListCmd}, | |||
| 21 | + | {"remotes list", newRemotesListCmd}, | |||
| 22 | + | {"rollback list", newRollbackListCmd}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tc := range commands { | |||
| 26 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 27 | + | cmd := tc.builder() | |||
| 28 | + | f := cmd.Flags().Lookup("json") | |||
| 29 | + | assert.NotNil(t, f, "command %q should have --json flag", tc.name) | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
A internal/adapters/in/cli/json_test.go +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestRoutesList_JSONFlag_Accepted(t *testing.T) { | |
| 18 | + | cmd := newRoutesListCmd() | |
| 19 | + | f := cmd.Flags().Lookup("json") | |
| 20 | + | assert.NotNil(t, f) | |
| 21 | + | if f != nil { | |
| 22 | + | assert.Equal(t, "false", f.DefValue) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) { | |
| 27 | + | cmd := newAttachmentsListCmd() | |
| 28 | + | f := cmd.Flags().Lookup("json") | |
| 29 | + | assert.NotNil(t, f) | |
| 30 | + | if f != nil { | |
| 31 | + | assert.Equal(t, "false", f.DefValue) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |
| 36 | + | cmd := newSecretsListCmd() | |
| 37 | + | f := cmd.Flags().Lookup("json") | |
| 38 | + | assert.NotNil(t, f) | |
| 39 | + | if f != nil { | |
| 40 | + | assert.Equal(t, "false", f.DefValue) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |
| 45 | + | cmd := newImagesListCmd() | |
| 46 | + | f := cmd.Flags().Lookup("json") | |
| 47 | + | assert.NotNil(t, f) | |
| 48 | + | if f != nil { | |
| 49 | + | assert.Equal(t, "false", f.DefValue) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestBackupList_JSONFlag_Accepted(t *testing.T) { | |
| 54 | + | cmd := newBackupListCmd() | |
| 55 | + | f := cmd.Flags().Lookup("json") | |
| 56 | + | assert.NotNil(t, f) | |
| 57 | + | if f != nil { | |
| 58 | + | assert.Equal(t, "false", f.DefValue) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestTokenList_JSONFlag_Accepted(t *testing.T) { | |
| 63 | + | cmd := newTokenListCmd() | |
| 64 | + | f := cmd.Flags().Lookup("json") | |
| 65 | + | assert.NotNil(t, f) | |
| 66 | + | if f != nil { | |
| 67 | + | assert.Equal(t, "false", f.DefValue) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestRemotesList_JSONFlag_Accepted(t *testing.T) { | |
| 72 | + | cmd := newRemotesListCmd() | |
| 73 | + | f := cmd.Flags().Lookup("json") | |
| 74 | + | assert.NotNil(t, f) | |
| 75 | + | if f != nil { | |
| 76 | + | assert.Equal(t, "false", f.DefValue) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestRollbackList_JSONFlag_Accepted(t *testing.T) { | |
| 81 | + | cmd := newRollbackListCmd() | |
| 82 | + | f := cmd.Flags().Lookup("json") | |
| 83 | + | assert.NotNil(t, f) | |
| 84 | + | if f != nil { | |
| 85 | + | assert.Equal(t, "false", f.DefValue) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |
| 90 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 91 | + | images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}} | |
| 92 | + | ||
| 93 | + | payload, err := json.Marshal(images) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | ||
| 96 | + | var got []dto.Image | |
| 97 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 98 | + | require.Len(t, got, 1) | |
| 99 | + | assert.Equal(t, images[0].Repository, got[0].Repository) | |
| 100 | + | assert.Equal(t, images[0].Tag, got[0].Tag) | |
| 101 | + | assert.Equal(t, images[0].ID, got[0].ID) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) { | |
| 105 | + | tags := []string{"v1.2.0", "v1.1.0"} | |
| 106 | + | payload, err := json.Marshal(tags) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | var got []string | |
| 110 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 111 | + | assert.Equal(t, tags, got) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |
| 115 | + | startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |
| 116 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |
| 117 | + | ||
| 118 | + | payload, err := json.Marshal(jobs) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | ||
| 121 | + | var got []domain.BackupJob | |
| 122 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 123 | + | require.Len(t, got, 1) | |
| 124 | + | assert.Equal(t, "b1", got[0].ID) | |
| 125 | + | assert.Equal(t, "app.example.com", got[0].Domain) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |
| 129 | + | tokens := []domain.Token{{ | |
| 130 | + | ID: "tok-1", | |
| 131 | + | Subject: "deploy", | |
| 132 | + | Scopes: []string{"admin:*:*"}, | |
| 133 | + | IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC), | |
| 134 | + | ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC), | |
| 135 | + | }} | |
| 136 | + | ||
| 137 | + | payload, err := json.Marshal(tokens) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | ||
| 140 | + | var got []domain.Token | |
| 141 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 142 | + | require.Len(t, got, 1) | |
| 143 | + | assert.Equal(t, tokens[0].ID, got[0].ID) | |
| 144 | + | assert.Equal(t, tokens[0].Subject, got[0].Subject) | |
| 145 | + | assert.Equal(t, tokens[0].Scopes, got[0].Scopes) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |
| 149 | + | items := []struct { | |
| 150 | + | Name string `json:"name"` | |
| 151 | + | URL string `json:"url"` | |
| 152 | + | Active bool `json:"active"` | |
| 153 | + | InsecureTLS bool `json:"insecure_tls"` | |
| 154 | + | }{ | |
| 155 | + | {Name: "prod", URL: "https://prod.example.com", Active: true, InsecureTLS: true}, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | payload, err := json.Marshal(items) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | ||
| 161 | + | var got []struct { | |
| 162 | + | Name string `json:"name"` | |
| 163 | + | URL string `json:"url"` | |
| 164 | + | Active bool `json:"active"` | |
| 165 | + | InsecureTLS bool `json:"insecure_tls"` | |
| 166 | + | } | |
| 167 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |
| 168 | + | require.Len(t, got, 1) | |
| 169 | + | assert.Equal(t, "prod", got[0].Name) | |
| 170 | + | assert.Equal(t, "https://prod.example.com", got[0].URL) | |
| 171 | + | assert.True(t, got[0].Active) | |
| 172 | + | assert.True(t, got[0].InsecureTLS) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) { | |
| 176 | + | payload := struct { | |
| 177 | + | Target string `json:"target"` | |
| 178 | + | Images []string `json:"images"` | |
| 179 | + | }{ | |
| 180 | + | Target: "app.example.com", | |
| 181 | + | Images: []string{"postgres:16"}, | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | encoded, err := json.Marshal(payload) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | ||
| 187 | + | var got struct { | |
| 188 | + | Target string `json:"target"` | |
| 189 | + | Images []string `json:"images"` | |
| 190 | + | } | |
| 191 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 192 | + | assert.Equal(t, payload.Target, got.Target) | |
| 193 | + | assert.Equal(t, payload.Images, got.Images) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |
| 197 | + | payload := struct { | |
| 198 | + | Domain string `json:"domain"` | |
| 199 | + | Keys []string `json:"keys"` | |
| 200 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 201 | + | }{ | |
| 202 | + | Domain: "app.example.com", | |
| 203 | + | Keys: []string{"API_KEY"}, | |
| 204 | + | Attachments: []remote.AttachmentSecrets{{ | |
| 205 | + | Service: "postgres", | |
| 206 | + | Keys: []string{"POSTGRES_PASSWORD"}, | |
| 207 | + | }}, | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | encoded, err := json.Marshal(payload) | |
| 211 | + | require.NoError(t, err) | |
| 212 | + | ||
| 213 | + | var got struct { | |
| 214 | + | Domain string `json:"domain"` | |
| 215 | + | Keys []string `json:"keys"` | |
| 216 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 217 | + | } | |
| 218 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 219 | + | assert.Equal(t, payload.Domain, got.Domain) | |
| 220 | + | assert.Equal(t, payload.Keys, got.Keys) | |
| 221 | + | assert.Equal(t, payload.Attachments, got.Attachments) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) { | |
| 225 | + | payload := struct { | |
| 226 | + | Domain string `json:"domain"` | |
| 227 | + | Keys []string `json:"keys"` | |
| 228 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 229 | + | }{ | |
| 230 | + | Domain: "app.example.com", | |
| 231 | + | Keys: []string{"API_KEY"}, | |
| 232 | + | Attachments: []remote.AttachmentSecrets{}, | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | encoded, err := json.Marshal(payload) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | assert.Contains(t, string(encoded), `"attachments":[]`) | |
| 238 | + | ||
| 239 | + | var got struct { | |
| 240 | + | Domain string `json:"domain"` | |
| 241 | + | Keys []string `json:"keys"` | |
| 242 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |
| 243 | + | } | |
| 244 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 245 | + | assert.NotNil(t, got.Attachments) | |
| 246 | + | assert.Empty(t, got.Attachments) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) { | |
| 250 | + | payload := []struct { | |
| 251 | + | Domain string `json:"domain"` | |
| 252 | + | Image string `json:"image"` | |
| 253 | + | }{ | |
| 254 | + | {Domain: "app.example.com", Image: "app:latest"}, | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | encoded, err := json.Marshal(payload) | |
| 258 | + | require.NoError(t, err) | |
| 259 | + | ||
| 260 | + | var got []struct { | |
| 261 | + | Domain string `json:"domain"` | |
| 262 | + | Image string `json:"image"` | |
| 263 | + | } | |
| 264 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |
| 265 | + | require.Len(t, got, 1) | |
| 266 | + | assert.Equal(t, payload[0], got[0]) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |
| 270 | + | var out bytes.Buffer | |
| 271 | + | err := writeJSON(&out, map[string]any{"ok": true}) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | ||
| 274 | + | var got map[string]any | |
| 275 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |
| 276 | + | assert.Equal(t, true, got["ok"]) | |
| 277 | + | assert.Contains(t, out.String(), "\n") | |
| 278 | + | } | |
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestRoutesList_JSONFlag_Accepted(t *testing.T) { | |||
| 18 | + | cmd := newRoutesListCmd() | |||
| 19 | + | f := cmd.Flags().Lookup("json") | |||
| 20 | + | assert.NotNil(t, f) | |||
| 21 | + | if f != nil { | |||
| 22 | + | assert.Equal(t, "false", f.DefValue) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAttachmentsList_JSONFlag_Accepted(t *testing.T) { | |||
| 27 | + | cmd := newAttachmentsListCmd() | |||
| 28 | + | f := cmd.Flags().Lookup("json") | |||
| 29 | + | assert.NotNil(t, f) | |||
| 30 | + | if f != nil { | |||
| 31 | + | assert.Equal(t, "false", f.DefValue) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestSecretsList_JSONFlag_Accepted(t *testing.T) { | |||
| 36 | + | cmd := newSecretsListCmd() | |||
| 37 | + | f := cmd.Flags().Lookup("json") | |||
| 38 | + | assert.NotNil(t, f) | |||
| 39 | + | if f != nil { | |||
| 40 | + | assert.Equal(t, "false", f.DefValue) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestImagesList_JSONFlag_Accepted(t *testing.T) { | |||
| 45 | + | cmd := newImagesListCmd() | |||
| 46 | + | f := cmd.Flags().Lookup("json") | |||
| 47 | + | assert.NotNil(t, f) | |||
| 48 | + | if f != nil { | |||
| 49 | + | assert.Equal(t, "false", f.DefValue) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestBackupList_JSONFlag_Accepted(t *testing.T) { | |||
| 54 | + | cmd := newBackupListCmd() | |||
| 55 | + | f := cmd.Flags().Lookup("json") | |||
| 56 | + | assert.NotNil(t, f) | |||
| 57 | + | if f != nil { | |||
| 58 | + | assert.Equal(t, "false", f.DefValue) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestTokenList_JSONFlag_Accepted(t *testing.T) { | |||
| 63 | + | cmd := newTokenListCmd() | |||
| 64 | + | f := cmd.Flags().Lookup("json") | |||
| 65 | + | assert.NotNil(t, f) | |||
| 66 | + | if f != nil { | |||
| 67 | + | assert.Equal(t, "false", f.DefValue) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestRemotesList_JSONFlag_Accepted(t *testing.T) { | |||
| 72 | + | cmd := newRemotesListCmd() | |||
| 73 | + | f := cmd.Flags().Lookup("json") | |||
| 74 | + | assert.NotNil(t, f) | |||
| 75 | + | if f != nil { | |||
| 76 | + | assert.Equal(t, "false", f.DefValue) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestRollbackList_JSONFlag_Accepted(t *testing.T) { | |||
| 81 | + | cmd := newRollbackListCmd() | |||
| 82 | + | f := cmd.Flags().Lookup("json") | |||
| 83 | + | assert.NotNil(t, f) | |||
| 84 | + | if f != nil { | |||
| 85 | + | assert.Equal(t, "false", f.DefValue) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestImagesList_JSONShape_RoundTripsDTO(t *testing.T) { | |||
| 90 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 91 | + | images := []dto.Image{{Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}} | |||
| 92 | + | ||||
| 93 | + | payload, err := json.Marshal(images) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | ||||
| 96 | + | var got []dto.Image | |||
| 97 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 98 | + | require.Len(t, got, 1) | |||
| 99 | + | assert.Equal(t, images[0].Repository, got[0].Repository) | |||
| 100 | + | assert.Equal(t, images[0].Tag, got[0].Tag) | |||
| 101 | + | assert.Equal(t, images[0].ID, got[0].ID) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestRollbackList_JSONShape_RoundTripsTags(t *testing.T) { | |||
| 105 | + | tags := []string{"v1.2.0", "v1.1.0"} | |||
| 106 | + | payload, err := json.Marshal(tags) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | var got []string | |||
| 110 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 111 | + | assert.Equal(t, tags, got) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestBackupList_JSONShape_RoundTripsJobs(t *testing.T) { | |||
| 115 | + | startedAt := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |||
| 116 | + | jobs := []domain.BackupJob{{ID: "b1", Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, StartedAt: startedAt}} | |||
| 117 | + | ||||
| 118 | + | payload, err := json.Marshal(jobs) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | ||||
| 121 | + | var got []domain.BackupJob | |||
| 122 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 123 | + | require.Len(t, got, 1) | |||
| 124 | + | assert.Equal(t, "b1", got[0].ID) | |||
| 125 | + | assert.Equal(t, "app.example.com", got[0].Domain) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestTokenList_JSONShape_RoundTripsTokens(t *testing.T) { | |||
| 129 | + | tokens := []domain.Token{{ | |||
| 130 | + | ID: "tok-1", | |||
| 131 | + | Subject: "deploy", | |||
| 132 | + | Scopes: []string{"admin:*:*"}, | |||
| 133 | + | IssuedAt: time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC), | |||
| 134 | + | ExpiresAt: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC), | |||
| 135 | + | }} | |||
| 136 | + | ||||
| 137 | + | payload, err := json.Marshal(tokens) | |||
| 138 | + | require.NoError(t, err) | |||
| 139 | + | ||||
| 140 | + | var got []domain.Token | |||
| 141 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 142 | + | require.Len(t, got, 1) | |||
| 143 | + | assert.Equal(t, tokens[0].ID, got[0].ID) | |||
| 144 | + | assert.Equal(t, tokens[0].Subject, got[0].Subject) | |||
| 145 | + | assert.Equal(t, tokens[0].Scopes, got[0].Scopes) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRemotesList_JSONShape_RoundTripsRemoteObjects(t *testing.T) { | |||
| 149 | + | items := []struct { | |||
| 150 | + | Name string `json:"name"` | |||
| 151 | + | URL string `json:"url"` | |||
| 152 | + | Active bool `json:"active"` | |||
| 153 | + | InsecureTLS bool `json:"insecure_tls"` | |||
| 154 | + | }{ | |||
| 155 | + | {Name: "prod", URL: "https://prod.example.com", Active: true, InsecureTLS: true}, | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | payload, err := json.Marshal(items) | |||
| 159 | + | require.NoError(t, err) | |||
| 160 | + | ||||
| 161 | + | var got []struct { | |||
| 162 | + | Name string `json:"name"` | |||
| 163 | + | URL string `json:"url"` | |||
| 164 | + | Active bool `json:"active"` | |||
| 165 | + | InsecureTLS bool `json:"insecure_tls"` | |||
| 166 | + | } | |||
| 167 | + | require.NoError(t, json.Unmarshal(payload, &got)) | |||
| 168 | + | require.Len(t, got, 1) | |||
| 169 | + | assert.Equal(t, "prod", got[0].Name) | |||
| 170 | + | assert.Equal(t, "https://prod.example.com", got[0].URL) | |||
| 171 | + | assert.True(t, got[0].Active) | |||
| 172 | + | assert.True(t, got[0].InsecureTLS) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestAttachmentsList_JSONShape_RoundTripsTargetPayload(t *testing.T) { | |||
| 176 | + | payload := struct { | |||
| 177 | + | Target string `json:"target"` | |||
| 178 | + | Images []string `json:"images"` | |||
| 179 | + | }{ | |||
| 180 | + | Target: "app.example.com", | |||
| 181 | + | Images: []string{"postgres:16"}, | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | encoded, err := json.Marshal(payload) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | ||||
| 187 | + | var got struct { | |||
| 188 | + | Target string `json:"target"` | |||
| 189 | + | Images []string `json:"images"` | |||
| 190 | + | } | |||
| 191 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 192 | + | assert.Equal(t, payload.Target, got.Target) | |||
| 193 | + | assert.Equal(t, payload.Images, got.Images) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestSecretsList_JSONShape_RoundTripsPayload(t *testing.T) { | |||
| 197 | + | payload := struct { | |||
| 198 | + | Domain string `json:"domain"` | |||
| 199 | + | Keys []string `json:"keys"` | |||
| 200 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 201 | + | }{ | |||
| 202 | + | Domain: "app.example.com", | |||
| 203 | + | Keys: []string{"API_KEY"}, | |||
| 204 | + | Attachments: []remote.AttachmentSecrets{{ | |||
| 205 | + | Service: "postgres", | |||
| 206 | + | Keys: []string{"POSTGRES_PASSWORD"}, | |||
| 207 | + | }}, | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | encoded, err := json.Marshal(payload) | |||
| 211 | + | require.NoError(t, err) | |||
| 212 | + | ||||
| 213 | + | var got struct { | |||
| 214 | + | Domain string `json:"domain"` | |||
| 215 | + | Keys []string `json:"keys"` | |||
| 216 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 217 | + | } | |||
| 218 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 219 | + | assert.Equal(t, payload.Domain, got.Domain) | |||
| 220 | + | assert.Equal(t, payload.Keys, got.Keys) | |||
| 221 | + | assert.Equal(t, payload.Attachments, got.Attachments) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func TestSecretsList_JSONShape_NormalizesNilAttachmentsToEmptySlice(t *testing.T) { | |||
| 225 | + | payload := struct { | |||
| 226 | + | Domain string `json:"domain"` | |||
| 227 | + | Keys []string `json:"keys"` | |||
| 228 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 229 | + | }{ | |||
| 230 | + | Domain: "app.example.com", | |||
| 231 | + | Keys: []string{"API_KEY"}, | |||
| 232 | + | Attachments: []remote.AttachmentSecrets{}, | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | encoded, err := json.Marshal(payload) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | assert.Contains(t, string(encoded), `"attachments":[]`) | |||
| 238 | + | ||||
| 239 | + | var got struct { | |||
| 240 | + | Domain string `json:"domain"` | |||
| 241 | + | Keys []string `json:"keys"` | |||
| 242 | + | Attachments []remote.AttachmentSecrets `json:"attachments"` | |||
| 243 | + | } | |||
| 244 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 245 | + | assert.NotNil(t, got.Attachments) | |||
| 246 | + | assert.Empty(t, got.Attachments) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) { | |||
| 250 | + | payload := []struct { | |||
| 251 | + | Domain string `json:"domain"` | |||
| 252 | + | Image string `json:"image"` | |||
| 253 | + | }{ | |||
| 254 | + | {Domain: "app.example.com", Image: "app:latest"}, | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | encoded, err := json.Marshal(payload) | |||
| 258 | + | require.NoError(t, err) | |||
| 259 | + | ||||
| 260 | + | var got []struct { | |||
| 261 | + | Domain string `json:"domain"` | |||
| 262 | + | Image string `json:"image"` | |||
| 263 | + | } | |||
| 264 | + | require.NoError(t, json.Unmarshal(encoded, &got)) | |||
| 265 | + | require.Len(t, got, 1) | |||
| 266 | + | assert.Equal(t, payload[0], got[0]) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) { | |||
| 270 | + | var out bytes.Buffer | |||
| 271 | + | err := writeJSON(&out, map[string]any{"ok": true}) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | ||||
| 274 | + | var got map[string]any | |||
| 275 | + | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | |||
| 276 | + | assert.Equal(t, true, got["ok"]) | |||
| 277 | + | assert.Contains(t, out.String(), "\n") | |||
| 278 | + | } | |||
M internal/adapters/in/cli/push.go +4 -1
| @@ -206,8 +206,11 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | 208 | if len(routes) == 0 { | |
| 209 | + | // bootstrap command is registered in bootstrap.go (see issue #98) | |
| 209 | 210 | return "", "", "", fmt.Errorf( | |
| 210 | - | "no route configured for image %q\n\nUse 'gordon routes add' to create a route first", | |
| 211 | + | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'", | |
| 212 | + | imageArg, | |
| 213 | + | imageArg, | |
| 211 | 214 | imageArg, | |
| 212 | 215 | ) | |
| 213 | 216 | } | |
| @@ -206,8 +206,11 @@ func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile | |||||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | if len(routes) == 0 { | 208 | if len(routes) == 0 { | ||
| 209 | + | // bootstrap command is registered in bootstrap.go (see issue #98) | |||
| 209 | return "", "", "", fmt.Errorf( | 210 | return "", "", "", fmt.Errorf( | ||
| 210 | - | "no route configured for image %q\n\nUse 'gordon routes add' to create a route first", | 211 | + | "no route configured for image %q\n\nFor a first deploy, use 'gordon bootstrap <domain> %s'\nOr configure the route directly with 'gordon routes add <domain> %s'", |
| 212 | + | imageArg, | |||
| 213 | + | imageArg, | |||
| 211 | imageArg, | 214 | imageArg, | ||
| 212 | ) | 215 | ) | ||
| 213 | } | 216 | } | ||
M internal/adapters/in/cli/push_test.go +150 -0
| @@ -9,9 +9,145 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | 15 | "github.com/stretchr/testify/assert" | |
| 13 | 16 | ) | |
| 14 | 17 | ||
| 18 | + | type resolveFromImageTestControlPlane struct { | |
| 19 | + | findRoutesByImage func(context.Context, string) ([]domain.Route, error) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | var _ ControlPlane = (*resolveFromImageTestControlPlane)(nil) | |
| 23 | + | ||
| 24 | + | func (c *resolveFromImageTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |
| 25 | + | panic("unexpected call") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |
| 29 | + | panic("unexpected call") | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |
| 33 | + | panic("unexpected call") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (c *resolveFromImageTestControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 37 | + | if c.findRoutesByImage != nil { | |
| 38 | + | return c.findRoutesByImage(ctx, imageName) | |
| 39 | + | } | |
| 40 | + | return nil, nil | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (c *resolveFromImageTestControlPlane) AddRoute(context.Context, domain.Route) error { | |
| 44 | + | panic("unexpected call") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (c *resolveFromImageTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |
| 48 | + | panic("unexpected call") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) error { | |
| 52 | + | panic("unexpected call") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 56 | + | panic("unexpected call") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |
| 60 | + | panic("unexpected call") | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (c *resolveFromImageTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |
| 64 | + | panic("unexpected call") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (c *resolveFromImageTestControlPlane) DeleteSecret(context.Context, string, string) error { | |
| 68 | + | panic("unexpected call") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (c *resolveFromImageTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |
| 72 | + | panic("unexpected call") | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (c *resolveFromImageTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |
| 76 | + | panic("unexpected call") | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (c *resolveFromImageTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |
| 80 | + | panic("unexpected call") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |
| 84 | + | panic("unexpected call") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error { | |
| 88 | + | panic("unexpected call") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |
| 92 | + | panic("unexpected call") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |
| 96 | + | panic("unexpected call") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (c *resolveFromImageTestControlPlane) Reload(context.Context) error { | |
| 100 | + | panic("unexpected call") | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error { | |
| 104 | + | panic("unexpected call") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (c *resolveFromImageTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |
| 108 | + | panic("unexpected call") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (c *resolveFromImageTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |
| 112 | + | panic("unexpected call") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (c *resolveFromImageTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |
| 116 | + | panic("unexpected call") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (c *resolveFromImageTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |
| 120 | + | panic("unexpected call") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (c *resolveFromImageTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |
| 124 | + | panic("unexpected call") | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (c *resolveFromImageTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |
| 128 | + | panic("unexpected call") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (c *resolveFromImageTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |
| 132 | + | panic("unexpected call") | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (c *resolveFromImageTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |
| 136 | + | panic("unexpected call") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (c *resolveFromImageTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |
| 140 | + | panic("unexpected call") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (c *resolveFromImageTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |
| 144 | + | panic("unexpected call") | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |
| 148 | + | panic("unexpected call") | |
| 149 | + | } | |
| 150 | + | ||
| 15 | 151 | func TestValidateBuildArg(t *testing.T) { | |
| 16 | 152 | tests := []struct { | |
| 17 | 153 | name string | |
| @@ -421,3 +557,17 @@ func TestParseLabelPair(t *testing.T) { | |||
| 421 | 557 | }) | |
| 422 | 558 | } | |
| 423 | 559 | } | |
| 560 | + | ||
| 561 | + | func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) { | |
| 562 | + | cp := &resolveFromImageTestControlPlane{ | |
| 563 | + | findRoutesByImage: func(context.Context, string) ([]domain.Route, error) { | |
| 564 | + | return nil, nil | |
| 565 | + | }, | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | _, _, _, err := resolveFromImage(context.Background(), cp, "myapp", "Dockerfile") | |
| 569 | + | ||
| 570 | + | assert.Error(t, err) | |
| 571 | + | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | |
| 572 | + | assert.Contains(t, err.Error(), "gordon bootstrap") | |
| 573 | + | } | |
| @@ -9,9 +9,145 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 18 | + | type resolveFromImageTestControlPlane struct { | |||
| 19 | + | findRoutesByImage func(context.Context, string) ([]domain.Route, error) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | var _ ControlPlane = (*resolveFromImageTestControlPlane)(nil) | |||
| 23 | + | ||||
| 24 | + | func (c *resolveFromImageTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |||
| 25 | + | panic("unexpected call") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (c *resolveFromImageTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |||
| 29 | + | panic("unexpected call") | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (c *resolveFromImageTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |||
| 33 | + | panic("unexpected call") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (c *resolveFromImageTestControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |||
| 37 | + | if c.findRoutesByImage != nil { | |||
| 38 | + | return c.findRoutesByImage(ctx, imageName) | |||
| 39 | + | } | |||
| 40 | + | return nil, nil | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (c *resolveFromImageTestControlPlane) AddRoute(context.Context, domain.Route) error { | |||
| 44 | + | panic("unexpected call") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (c *resolveFromImageTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |||
| 48 | + | panic("unexpected call") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) error { | |||
| 52 | + | panic("unexpected call") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 56 | + | panic("unexpected call") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |||
| 60 | + | panic("unexpected call") | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (c *resolveFromImageTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |||
| 64 | + | panic("unexpected call") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (c *resolveFromImageTestControlPlane) DeleteSecret(context.Context, string, string) error { | |||
| 68 | + | panic("unexpected call") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (c *resolveFromImageTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |||
| 72 | + | panic("unexpected call") | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (c *resolveFromImageTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |||
| 76 | + | panic("unexpected call") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (c *resolveFromImageTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |||
| 80 | + | panic("unexpected call") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (c *resolveFromImageTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |||
| 84 | + | panic("unexpected call") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func (c *resolveFromImageTestControlPlane) AddAttachment(context.Context, string, string) error { | |||
| 88 | + | panic("unexpected call") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (c *resolveFromImageTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |||
| 92 | + | panic("unexpected call") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |||
| 96 | + | panic("unexpected call") | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (c *resolveFromImageTestControlPlane) Reload(context.Context) error { | |||
| 100 | + | panic("unexpected call") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (c *resolveFromImageTestControlPlane) DeployIntent(context.Context, string) error { | |||
| 104 | + | panic("unexpected call") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (c *resolveFromImageTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |||
| 108 | + | panic("unexpected call") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (c *resolveFromImageTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |||
| 112 | + | panic("unexpected call") | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (c *resolveFromImageTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |||
| 116 | + | panic("unexpected call") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (c *resolveFromImageTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |||
| 120 | + | panic("unexpected call") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func (c *resolveFromImageTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |||
| 124 | + | panic("unexpected call") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (c *resolveFromImageTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |||
| 128 | + | panic("unexpected call") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (c *resolveFromImageTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |||
| 132 | + | panic("unexpected call") | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (c *resolveFromImageTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |||
| 136 | + | panic("unexpected call") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (c *resolveFromImageTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |||
| 140 | + | panic("unexpected call") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (c *resolveFromImageTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |||
| 144 | + | panic("unexpected call") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |||
| 148 | + | panic("unexpected call") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 15 | func TestValidateBuildArg(t *testing.T) { | 151 | func TestValidateBuildArg(t *testing.T) { | ||
| 16 | tests := []struct { | 152 | tests := []struct { | ||
| 17 | name string | 153 | name string | ||
| @@ -421,3 +557,17 @@ func TestParseLabelPair(t *testing.T) { | |||||
| 421 | }) | 557 | }) | ||
| 422 | } | 558 | } | ||
| 423 | } | 559 | } | ||
| 560 | + | ||||
| 561 | + | func TestResolveFromImage_NoRouteSuggestsBootstrap(t *testing.T) { | |||
| 562 | + | cp := &resolveFromImageTestControlPlane{ | |||
| 563 | + | findRoutesByImage: func(context.Context, string) ([]domain.Route, error) { | |||
| 564 | + | return nil, nil | |||
| 565 | + | }, | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | _, _, _, err := resolveFromImage(context.Background(), cp, "myapp", "Dockerfile") | |||
| 569 | + | ||||
| 570 | + | assert.Error(t, err) | |||
| 571 | + | assert.Contains(t, err.Error(), `no route configured for image "myapp"`) | |||
| 572 | + | assert.Contains(t, err.Error(), "gordon bootstrap") | |||
| 573 | + | } | |||
M internal/adapters/in/cli/remote/client.go +12 -0
| @@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||
| 407 | 407 | return parseResponse(resp, nil) | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | + | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 411 | + | resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req) | |
| 412 | + | if err != nil { | |
| 413 | + | return nil, err | |
| 414 | + | } | |
| 415 | + | var result dto.BootstrapResponse | |
| 416 | + | if err := parseResponse(resp, &result); err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | return &result, nil | |
| 420 | + | } | |
| 421 | + | ||
| 410 | 422 | // Secrets API | |
| 411 | 423 | ||
| 412 | 424 | // AttachmentSecrets represents secrets for an attachment container. | |
| @@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||||
| 407 | return parseResponse(resp, nil) | 407 | return parseResponse(resp, nil) | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | + | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 411 | + | resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req) | |||
| 412 | + | if err != nil { | |||
| 413 | + | return nil, err | |||
| 414 | + | } | |||
| 415 | + | var result dto.BootstrapResponse | |||
| 416 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 417 | + | return nil, err | |||
| 418 | + | } | |||
| 419 | + | return &result, nil | |||
| 420 | + | } | |||
| 421 | + | ||||
| 410 | // Secrets API | 422 | // Secrets API | ||
| 411 | 423 | ||||
| 412 | // AttachmentSecrets represents secrets for an attachment container. | 424 | // AttachmentSecrets represents secrets for an attachment container. | ||
M internal/adapters/in/cli/remotes.go +102 -48
| @@ -2,6 +2,8 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "sort" | |
| 5 | 7 | "strings" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -35,67 +37,119 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||
| 35 | 37 | ||
| 36 | 38 | // newRemotesListCmd creates the remotes list command. | |
| 37 | 39 | func newRemotesListCmd() *cobra.Command { | |
| 38 | - | return &cobra.Command{ | |
| 40 | + | var jsonOut bool | |
| 41 | + | ||
| 42 | + | cmd := &cobra.Command{ | |
| 39 | 43 | Use: "list", | |
| 40 | 44 | Short: "List saved remotes", | |
| 41 | 45 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 42 | - | remotes, active, err := remote.ListRemotes() | |
| 43 | - | if err != nil { | |
| 44 | - | return fmt.Errorf("failed to list remotes: %w", err) | |
| 45 | - | } | |
| 46 | + | return runRemotesList(cmd.OutOrStdout(), jsonOut) | |
| 47 | + | }, | |
| 48 | + | } | |
| 46 | 49 | ||
| 47 | - | if len(remotes) == 0 { | |
| 48 | - | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |
| 49 | - | fmt.Println() | |
| 50 | - | fmt.Println("Add a remote with:") | |
| 51 | - | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |
| 52 | - | return nil | |
| 53 | - | } | |
| 50 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 54 | 51 | ||
| 55 | - | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | |
| 56 | - | fmt.Println() | |
| 52 | + | return cmd | |
| 53 | + | } | |
| 57 | 54 | ||
| 58 | - | // Build table rows | |
| 59 | - | rows := make([][]string, 0, len(remotes)) | |
| 60 | - | for name, r := range remotes { | |
| 61 | - | status := "" | |
| 62 | - | if name == active { | |
| 63 | - | status = styles.Theme.Success.Render("active") | |
| 64 | - | } | |
| 55 | + | func runRemotesList(out io.Writer, jsonOut bool) error { | |
| 56 | + | remotes, active, err := remote.ListRemotes() | |
| 57 | + | if err != nil { | |
| 58 | + | return fmt.Errorf("failed to list remotes: %w", err) | |
| 59 | + | } | |
| 65 | 60 | ||
| 66 | - | // Mask token if present | |
| 67 | - | tokenStatus := styles.Theme.Muted.Render("none") | |
| 68 | - | if r.Token != "" { | |
| 69 | - | tokenStatus = styles.Theme.Success.Render("set") | |
| 70 | - | } else if r.TokenEnv != "" { | |
| 71 | - | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |
| 72 | - | } | |
| 61 | + | if len(remotes) == 0 { | |
| 62 | + | return remotesListEmpty(out, jsonOut) | |
| 63 | + | } | |
| 73 | 64 | ||
| 74 | - | tlsStatus := "" | |
| 75 | - | if r.InsecureTLS { | |
| 76 | - | tlsStatus = styles.Theme.Warning.Render("insecure") | |
| 77 | - | } | |
| 65 | + | names := sortedRemoteNames(remotes) | |
| 78 | 66 | ||
| 79 | - | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |
| 80 | - | } | |
| 67 | + | if jsonOut { | |
| 68 | + | return remotesListJSON(out, remotes, names, active) | |
| 69 | + | } | |
| 81 | 70 | ||
| 82 | - | // Render table | |
| 83 | - | table := components.NewTable( | |
| 84 | - | components.WithColumns([]components.TableColumn{ | |
| 85 | - | {Title: "Name", Width: 15}, | |
| 86 | - | {Title: "URL", Width: 35}, | |
| 87 | - | {Title: "Token", Width: 15}, | |
| 88 | - | {Title: "TLS", Width: 10}, | |
| 89 | - | {Title: "Status", Width: 10}, | |
| 90 | - | }), | |
| 91 | - | components.WithRows(rows), | |
| 92 | - | ) | |
| 71 | + | if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 93 | 77 | ||
| 94 | - | fmt.Println(table.View()) | |
| 78 | + | rows := make([][]string, 0, len(names)) | |
| 79 | + | for _, name := range names { | |
| 80 | + | r := remotes[name] | |
| 81 | + | status := "" | |
| 82 | + | if name == active { | |
| 83 | + | status = styles.Theme.Success.Render("active") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | tokenStatus := styles.Theme.Muted.Render("none") | |
| 87 | + | if r.Token != "" { | |
| 88 | + | tokenStatus = styles.Theme.Success.Render("set") | |
| 89 | + | } else if r.TokenEnv != "" { | |
| 90 | + | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | tlsStatus := "" | |
| 94 | + | if r.InsecureTLS { | |
| 95 | + | tlsStatus = styles.Theme.Warning.Render("insecure") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |
| 99 | + | } | |
| 95 | 100 | ||
| 96 | - | return nil | |
| 97 | - | }, | |
| 101 | + | table := components.NewTable( | |
| 102 | + | components.WithColumns([]components.TableColumn{ | |
| 103 | + | {Title: "Name", Width: 15}, | |
| 104 | + | {Title: "URL", Width: 35}, | |
| 105 | + | {Title: "Token", Width: 15}, | |
| 106 | + | {Title: "TLS", Width: 10}, | |
| 107 | + | {Title: "Status", Width: 10}, | |
| 108 | + | }), | |
| 109 | + | components.WithRows(rows), | |
| 110 | + | ) | |
| 111 | + | ||
| 112 | + | _, err = fmt.Fprintln(out, table.View()) | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func remotesListEmpty(out io.Writer, jsonOut bool) error { | |
| 117 | + | if jsonOut { | |
| 118 | + | return writeJSON(out, []map[string]any{}) | |
| 119 | + | } | |
| 120 | + | if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | if err := cliWriteLine(out, "Add a remote with:"); err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string { | |
| 133 | + | names := make([]string, 0, len(remotes)) | |
| 134 | + | for name := range remotes { | |
| 135 | + | names = append(names, name) | |
| 136 | + | } | |
| 137 | + | sort.Strings(names) | |
| 138 | + | return names | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error { | |
| 142 | + | payload := make([]map[string]any, 0, len(names)) | |
| 143 | + | for _, name := range names { | |
| 144 | + | r := remotes[name] | |
| 145 | + | payload = append(payload, map[string]any{ | |
| 146 | + | "name": name, | |
| 147 | + | "url": r.URL, | |
| 148 | + | "active": name == active, | |
| 149 | + | "insecure_tls": r.InsecureTLS, | |
| 150 | + | }) | |
| 98 | 151 | } | |
| 152 | + | return writeJSON(out, payload) | |
| 99 | 153 | } | |
| 100 | 154 | ||
| 101 | 155 | // newRemotesAddCmd creates the remotes add command. | |
| @@ -2,6 +2,8 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "io" | |||
| 6 | + | "sort" | |||
| 5 | "strings" | 7 | "strings" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -35,67 +37,119 @@ Configuration is stored in ~/.config/gordon/remotes.toml`, | |||||
| 35 | 37 | ||||
| 36 | // newRemotesListCmd creates the remotes list command. | 38 | // newRemotesListCmd creates the remotes list command. | ||
| 37 | func newRemotesListCmd() *cobra.Command { | 39 | func newRemotesListCmd() *cobra.Command { | ||
| 38 | - | return &cobra.Command{ | 40 | + | var jsonOut bool |
| 41 | + | ||||
| 42 | + | cmd := &cobra.Command{ | |||
| 39 | Use: "list", | 43 | Use: "list", | ||
| 40 | Short: "List saved remotes", | 44 | Short: "List saved remotes", | ||
| 41 | RunE: func(cmd *cobra.Command, args []string) error { | 45 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 42 | - | remotes, active, err := remote.ListRemotes() | 46 | + | return runRemotesList(cmd.OutOrStdout(), jsonOut) |
| 43 | - | if err != nil { | 47 | + | }, |
| 44 | - | return fmt.Errorf("failed to list remotes: %w", err) | 48 | + | } |
| 45 | - | } | |||
| 46 | 49 | ||||
| 47 | - | if len(remotes) == 0 { | 50 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") |
| 48 | - | fmt.Println(styles.Theme.Muted.Render("No remotes configured")) | |||
| 49 | - | fmt.Println() | |||
| 50 | - | fmt.Println("Add a remote with:") | |||
| 51 | - | fmt.Println(styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |||
| 52 | - | return nil | |||
| 53 | - | } | |||
| 54 | 51 | ||||
| 55 | - | fmt.Println(styles.Theme.Title.Render("Saved Remotes")) | 52 | + | return cmd |
| 56 | - | fmt.Println() | 53 | + | } |
| 57 | 54 | ||||
| 58 | - | // Build table rows | 55 | + | func runRemotesList(out io.Writer, jsonOut bool) error { |
| 59 | - | rows := make([][]string, 0, len(remotes)) | 56 | + | remotes, active, err := remote.ListRemotes() |
| 60 | - | for name, r := range remotes { | 57 | + | if err != nil { |
| 61 | - | status := "" | 58 | + | return fmt.Errorf("failed to list remotes: %w", err) |
| 62 | - | if name == active { | 59 | + | } |
| 63 | - | status = styles.Theme.Success.Render("active") | |||
| 64 | - | } | |||
| 65 | 60 | ||||
| 66 | - | // Mask token if present | 61 | + | if len(remotes) == 0 { |
| 67 | - | tokenStatus := styles.Theme.Muted.Render("none") | 62 | + | return remotesListEmpty(out, jsonOut) |
| 68 | - | if r.Token != "" { | 63 | + | } |
| 69 | - | tokenStatus = styles.Theme.Success.Render("set") | |||
| 70 | - | } else if r.TokenEnv != "" { | |||
| 71 | - | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |||
| 72 | - | } | |||
| 73 | 64 | ||||
| 74 | - | tlsStatus := "" | 65 | + | names := sortedRemoteNames(remotes) |
| 75 | - | if r.InsecureTLS { | |||
| 76 | - | tlsStatus = styles.Theme.Warning.Render("insecure") | |||
| 77 | - | } | |||
| 78 | 66 | ||||
| 79 | - | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | 67 | + | if jsonOut { |
| 80 | - | } | 68 | + | return remotesListJSON(out, remotes, names, active) |
| 69 | + | } | |||
| 81 | 70 | ||||
| 82 | - | // Render table | 71 | + | if err := cliWriteLine(out, cliRenderTitle("Saved Remotes")); err != nil { |
| 83 | - | table := components.NewTable( | 72 | + | return err |
| 84 | - | components.WithColumns([]components.TableColumn{ | 73 | + | } |
| 85 | - | {Title: "Name", Width: 15}, | 74 | + | if err := cliWriteLine(out, ""); err != nil { |
| 86 | - | {Title: "URL", Width: 35}, | 75 | + | return err |
| 87 | - | {Title: "Token", Width: 15}, | 76 | + | } |
| 88 | - | {Title: "TLS", Width: 10}, | |||
| 89 | - | {Title: "Status", Width: 10}, | |||
| 90 | - | }), | |||
| 91 | - | components.WithRows(rows), | |||
| 92 | - | ) | |||
| 93 | 77 | ||||
| 94 | - | fmt.Println(table.View()) | 78 | + | rows := make([][]string, 0, len(names)) |
| 79 | + | for _, name := range names { | |||
| 80 | + | r := remotes[name] | |||
| 81 | + | status := "" | |||
| 82 | + | if name == active { | |||
| 83 | + | status = styles.Theme.Success.Render("active") | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | tokenStatus := styles.Theme.Muted.Render("none") | |||
| 87 | + | if r.Token != "" { | |||
| 88 | + | tokenStatus = styles.Theme.Success.Render("set") | |||
| 89 | + | } else if r.TokenEnv != "" { | |||
| 90 | + | tokenStatus = fmt.Sprintf("$%s", r.TokenEnv) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | tlsStatus := "" | |||
| 94 | + | if r.InsecureTLS { | |||
| 95 | + | tlsStatus = styles.Theme.Warning.Render("insecure") | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | rows = append(rows, []string{name, r.URL, tokenStatus, tlsStatus, status}) | |||
| 99 | + | } | |||
| 95 | 100 | ||||
| 96 | - | return nil | 101 | + | table := components.NewTable( |
| 97 | - | }, | 102 | + | components.WithColumns([]components.TableColumn{ |
| 103 | + | {Title: "Name", Width: 15}, | |||
| 104 | + | {Title: "URL", Width: 35}, | |||
| 105 | + | {Title: "Token", Width: 15}, | |||
| 106 | + | {Title: "TLS", Width: 10}, | |||
| 107 | + | {Title: "Status", Width: 10}, | |||
| 108 | + | }), | |||
| 109 | + | components.WithRows(rows), | |||
| 110 | + | ) | |||
| 111 | + | ||||
| 112 | + | _, err = fmt.Fprintln(out, table.View()) | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func remotesListEmpty(out io.Writer, jsonOut bool) error { | |||
| 117 | + | if jsonOut { | |||
| 118 | + | return writeJSON(out, []map[string]any{}) | |||
| 119 | + | } | |||
| 120 | + | if err := cliWriteLine(out, cliRenderMuted("No remotes configured")); err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | if err := cliWriteLine(out, "Add a remote with:"); err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | return cliWriteLine(out, styles.Theme.Bold.Render(" gordon remotes add <name> <url>")) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func sortedRemoteNames(remotes map[string]remote.RemoteEntry) []string { | |||
| 133 | + | names := make([]string, 0, len(remotes)) | |||
| 134 | + | for name := range remotes { | |||
| 135 | + | names = append(names, name) | |||
| 136 | + | } | |||
| 137 | + | sort.Strings(names) | |||
| 138 | + | return names | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func remotesListJSON(out io.Writer, remotes map[string]remote.RemoteEntry, names []string, active string) error { | |||
| 142 | + | payload := make([]map[string]any, 0, len(names)) | |||
| 143 | + | for _, name := range names { | |||
| 144 | + | r := remotes[name] | |||
| 145 | + | payload = append(payload, map[string]any{ | |||
| 146 | + | "name": name, | |||
| 147 | + | "url": r.URL, | |||
| 148 | + | "active": name == active, | |||
| 149 | + | "insecure_tls": r.InsecureTLS, | |||
| 150 | + | }) | |||
| 98 | } | 151 | } | ||
| 152 | + | return writeJSON(out, payload) | |||
| 99 | } | 153 | } | ||
| 100 | 154 | ||||
| 101 | // newRemotesAddCmd creates the remotes add command. | 155 | // newRemotesAddCmd creates the remotes add command. | ||
M internal/adapters/in/cli/rollback.go +19 -5
| @@ -42,14 +42,20 @@ Examples: | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func newRollbackListCmd() *cobra.Command { | |
| 45 | - | return &cobra.Command{ | |
| 45 | + | var jsonOut bool | |
| 46 | + | ||
| 47 | + | cmd := &cobra.Command{ | |
| 46 | 48 | Use: "list <domain>", | |
| 47 | 49 | Short: "List available rollback tags", | |
| 48 | 50 | Args: cobra.ExactArgs(1), | |
| 49 | 51 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 50 | - | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | |
| 52 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut) | |
| 51 | 53 | }, | |
| 52 | 54 | } | |
| 55 | + | ||
| 56 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 57 | + | ||
| 58 | + | return cmd | |
| 53 | 59 | } | |
| 54 | 60 | ||
| 55 | 61 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |
| @@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 87 | 93 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | |
| 88 | 94 | } | |
| 89 | 95 | ||
| 90 | - | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | |
| 96 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error { | |
| 91 | 97 | handle, err := resolveControlPlane(configPath) | |
| 92 | 98 | if err != nil { | |
| 93 | 99 | return err | |
| @@ -109,10 +115,18 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er | |||
| 109 | 115 | return err | |
| 110 | 116 | } | |
| 111 | 117 | ||
| 112 | - | return printRollbackTags(w, rollbackDomain, currentTag, tags) | |
| 118 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut) | |
| 113 | 119 | } | |
| 114 | 120 | ||
| 115 | - | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | |
| 121 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error { | |
| 122 | + | if jsonOut { | |
| 123 | + | return writeJSON(w, map[string]any{ | |
| 124 | + | "domain": rollbackDomain, | |
| 125 | + | "current_tag": currentTag, | |
| 126 | + | "tags": tags, | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 116 | 130 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | |
| 117 | 131 | return err | |
| 118 | 132 | } | |
| @@ -42,14 +42,20 @@ Examples: | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | func newRollbackListCmd() *cobra.Command { | 44 | func newRollbackListCmd() *cobra.Command { | ||
| 45 | - | return &cobra.Command{ | 45 | + | var jsonOut bool |
| 46 | + | ||||
| 47 | + | cmd := &cobra.Command{ | |||
| 46 | Use: "list <domain>", | 48 | Use: "list <domain>", | ||
| 47 | Short: "List available rollback tags", | 49 | Short: "List available rollback tags", | ||
| 48 | Args: cobra.ExactArgs(1), | 50 | Args: cobra.ExactArgs(1), | ||
| 49 | RunE: func(cmd *cobra.Command, args []string) error { | 51 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 50 | - | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | 52 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0], jsonOut) |
| 51 | }, | 53 | }, | ||
| 52 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 57 | + | ||||
| 58 | + | return cmd | |||
| 53 | } | 59 | } | ||
| 54 | 60 | ||||
| 55 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | 61 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | ||
| @@ -87,7 +93,7 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 87 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | 93 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | ||
| 88 | } | 94 | } | ||
| 89 | 95 | ||||
| 90 | - | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | 96 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string, jsonOut bool) error { |
| 91 | handle, err := resolveControlPlane(configPath) | 97 | handle, err := resolveControlPlane(configPath) | ||
| 92 | if err != nil { | 98 | if err != nil { | ||
| 93 | return err | 99 | return err | ||
| @@ -109,10 +115,18 @@ func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) er | |||||
| 109 | return err | 115 | return err | ||
| 110 | } | 116 | } | ||
| 111 | 117 | ||||
| 112 | - | return printRollbackTags(w, rollbackDomain, currentTag, tags) | 118 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags, jsonOut) |
| 113 | } | 119 | } | ||
| 114 | 120 | ||||
| 115 | - | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | 121 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string, jsonOut bool) error { |
| 122 | + | if jsonOut { | |||
| 123 | + | return writeJSON(w, map[string]any{ | |||
| 124 | + | "domain": rollbackDomain, | |||
| 125 | + | "current_tag": currentTag, | |||
| 126 | + | "tags": tags, | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 116 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | 130 | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | ||
| 117 | return err | 131 | return err | ||
| 118 | } | 132 | } | ||
M internal/adapters/in/cli/rollback_test.go +2 -2
| @@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) { | |||
| 63 | 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |
| 64 | 64 | var buf bytes.Buffer | |
| 65 | 65 | ||
| 66 | - | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | |
| 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false) | |
| 67 | 67 | assert.NoError(t, err) | |
| 68 | 68 | ||
| 69 | 69 | output := buf.String() | |
| @@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | |
| 77 | - | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | |
| 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false) | |
| 78 | 78 | assert.Error(t, err) | |
| 79 | 79 | assert.Contains(t, err.Error(), "write failed") | |
| 80 | 80 | } | |
| @@ -63,7 +63,7 @@ func TestValidateTagExists(t *testing.T) { | |||||
| 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | 63 | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | ||
| 64 | var buf bytes.Buffer | 64 | var buf bytes.Buffer | ||
| 65 | 65 | ||||
| 66 | - | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}, false) |
| 67 | assert.NoError(t, err) | 67 | assert.NoError(t, err) | ||
| 68 | 68 | ||||
| 69 | output := buf.String() | 69 | output := buf.String() | ||
| @@ -74,7 +74,7 @@ func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | 76 | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | ||
| 77 | - | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}, false) |
| 78 | assert.Error(t, err) | 78 | assert.Error(t, err) | ||
| 79 | assert.Contains(t, err.Error(), "write failed") | 79 | assert.Contains(t, err.Error(), "write failed") | ||
| 80 | } | 80 | } | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -125,6 +125,10 @@ Commands are organized by where they run: | |||
| 125 | 125 | imagesCmd.GroupID = groupManage | |
| 126 | 126 | rootCmd.AddCommand(imagesCmd) | |
| 127 | 127 | ||
| 128 | + | bootstrapCmd := newBootstrapCmd() | |
| 129 | + | bootstrapCmd.GroupID = groupManage | |
| 130 | + | rootCmd.AddCommand(bootstrapCmd) | |
| 131 | + | ||
| 128 | 132 | // Client-only commands (no server needed) | |
| 129 | 133 | remotesCmd := newRemotesCmd() | |
| 130 | 134 | remotesCmd.GroupID = groupClient | |
| @@ -125,6 +125,10 @@ Commands are organized by where they run: | |||||
| 125 | imagesCmd.GroupID = groupManage | 125 | imagesCmd.GroupID = groupManage | ||
| 126 | rootCmd.AddCommand(imagesCmd) | 126 | rootCmd.AddCommand(imagesCmd) | ||
| 127 | 127 | ||||
| 128 | + | bootstrapCmd := newBootstrapCmd() | |||
| 129 | + | bootstrapCmd.GroupID = groupManage | |||
| 130 | + | rootCmd.AddCommand(bootstrapCmd) | |||
| 131 | + | ||||
| 128 | // Client-only commands (no server needed) | 132 | // Client-only commands (no server needed) | ||
| 129 | remotesCmd := newRemotesCmd() | 133 | remotesCmd := newRemotesCmd() | ||
| 130 | remotesCmd.GroupID = groupClient | 134 | remotesCmd.GroupID = groupClient | ||
M internal/adapters/in/cli/routes.go +85 -20
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string { | |||
| 111 | 112 | ||
| 112 | 113 | // newRoutesListCmd creates the routes list command. | |
| 113 | 114 | func newRoutesListCmd() *cobra.Command { | |
| 114 | - | return &cobra.Command{ | |
| 115 | + | var jsonOut bool | |
| 116 | + | ||
| 117 | + | cmd := &cobra.Command{ | |
| 115 | 118 | Use: "list", | |
| 116 | 119 | Short: "List all routes", | |
| 117 | 120 | RunE: func(cmd *cobra.Command, args []string) error { | |
| @@ -119,31 +122,39 @@ func newRoutesListCmd() *cobra.Command { | |||
| 119 | 122 | ||
| 120 | 123 | client, isRemote := GetRemoteClient() | |
| 121 | 124 | if isRemote { | |
| 122 | - | return runRoutesListRemote(ctx, client) | |
| 125 | + | return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout()) | |
| 123 | 126 | } | |
| 124 | - | return runRoutesListLocal(ctx, configPath) | |
| 127 | + | return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout()) | |
| 125 | 128 | }, | |
| 126 | 129 | } | |
| 130 | + | ||
| 131 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 132 | + | ||
| 133 | + | return cmd | |
| 127 | 134 | } | |
| 128 | 135 | ||
| 129 | 136 | // runRoutesListRemote lists routes from a remote Gordon instance. | |
| 130 | - | func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |
| 137 | + | func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { | |
| 131 | 138 | routes, err := client.ListRoutesWithDetails(ctx) | |
| 132 | 139 | if err != nil { | |
| 133 | 140 | return fmt.Errorf("failed to list routes: %w", err) | |
| 134 | 141 | } | |
| 135 | 142 | ||
| 136 | - | if len(routes) == 0 { | |
| 137 | - | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | |
| 138 | - | return nil | |
| 139 | - | } | |
| 140 | - | ||
| 141 | 143 | // Get health status for each route (includes container status and HTTP probe) | |
| 142 | 144 | health, _ := client.GetHealth(ctx) | |
| 143 | 145 | if health == nil { | |
| 144 | 146 | health = make(map[string]*remote.RouteHealth) | |
| 145 | 147 | } | |
| 146 | 148 | ||
| 149 | + | if jsonOut { | |
| 150 | + | return routesListJSON(out, routes, health) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | if len(routes) == 0 { | |
| 154 | + | _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) | |
| 155 | + | return nil | |
| 156 | + | } | |
| 157 | + | ||
| 147 | 158 | const imageColWidth = 35 | |
| 148 | 159 | const networkColWidth = 22 | |
| 149 | 160 | rows := make([][]string, 0, len(routes)) | |
| @@ -199,15 +210,55 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||
| 199 | 210 | components.WithRows(rows), | |
| 200 | 211 | ) | |
| 201 | 212 | ||
| 202 | - | fmt.Println(styles.Theme.Title.Render("Routes")) | |
| 203 | - | fmt.Println() | |
| 204 | - | fmt.Println(table.View()) | |
| 213 | + | _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes")) | |
| 214 | + | _, _ = fmt.Fprintln(out) | |
| 215 | + | _, _ = fmt.Fprintln(out, table.View()) | |
| 205 | 216 | ||
| 206 | 217 | return nil | |
| 207 | 218 | } | |
| 208 | 219 | ||
| 220 | + | func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error { | |
| 221 | + | payload := make([]map[string]any, 0, len(routes)) | |
| 222 | + | for _, route := range routes { | |
| 223 | + | routeHealth := health[route.Domain] | |
| 224 | + | containerStatus := route.ContainerStatus | |
| 225 | + | if containerStatus == "" { | |
| 226 | + | if routeHealth != nil { | |
| 227 | + | containerStatus = routeHealth.ContainerStatus | |
| 228 | + | } else { | |
| 229 | + | containerStatus = "unknown" | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | httpStatus := 0 | |
| 234 | + | if routeHealth != nil { | |
| 235 | + | httpStatus = routeHealth.HTTPStatus | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | attachments := make([]map[string]string, 0, len(route.Attachments)) | |
| 239 | + | for _, attachment := range route.Attachments { | |
| 240 | + | attachments = append(attachments, map[string]string{ | |
| 241 | + | "name": attachment.Name, | |
| 242 | + | "image": attachment.Image, | |
| 243 | + | "status": attachment.Status, | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | payload = append(payload, map[string]any{ | |
| 248 | + | "domain": route.Domain, | |
| 249 | + | "image": route.Image, | |
| 250 | + | "container_status": containerStatus, | |
| 251 | + | "network": route.Network, | |
| 252 | + | "http_status": httpStatus, | |
| 253 | + | "attachments": attachments, | |
| 254 | + | }) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | return writeJSON(out, payload) | |
| 258 | + | } | |
| 259 | + | ||
| 209 | 260 | // runRoutesListLocal lists routes from local configuration. | |
| 210 | - | func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |
| 261 | + | func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error { | |
| 211 | 262 | local, err := GetLocalServices(cfgPath) | |
| 212 | 263 | if err != nil { | |
| 213 | 264 | return fmt.Errorf("failed to initialize local services: %w", err) | |
| @@ -215,8 +266,19 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||
| 215 | 266 | ||
| 216 | 267 | routes := local.GetConfigService().GetRoutes(ctx) | |
| 217 | 268 | ||
| 269 | + | if jsonOut { | |
| 270 | + | payload := make([]map[string]string, 0, len(routes)) | |
| 271 | + | for _, route := range routes { | |
| 272 | + | payload = append(payload, map[string]string{ | |
| 273 | + | "domain": route.Domain, | |
| 274 | + | "image": route.Image, | |
| 275 | + | }) | |
| 276 | + | } | |
| 277 | + | return writeJSON(out, payload) | |
| 278 | + | } | |
| 279 | + | ||
| 218 | 280 | if len(routes) == 0 { | |
| 219 | - | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | |
| 281 | + | _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) | |
| 220 | 282 | return nil | |
| 221 | 283 | } | |
| 222 | 284 | ||
| @@ -237,9 +299,9 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||
| 237 | 299 | components.WithRows(rows), | |
| 238 | 300 | ) | |
| 239 | 301 | ||
| 240 | - | fmt.Println(styles.Theme.Title.Render("Routes (local)")) | |
| 241 | - | fmt.Println() | |
| 242 | - | fmt.Println(table.View()) | |
| 302 | + | _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)")) | |
| 303 | + | _, _ = fmt.Fprintln(out) | |
| 304 | + | _, _ = fmt.Fprintln(out, table.View()) | |
| 243 | 305 | ||
| 244 | 306 | return nil | |
| 245 | 307 | } | |
| @@ -250,8 +312,11 @@ func newRoutesAddCmd() *cobra.Command { | |||
| 250 | 312 | ||
| 251 | 313 | cmd := &cobra.Command{ | |
| 252 | 314 | Use: "add <domain> <image>", | |
| 253 | - | Short: "Add a new route", | |
| 254 | - | Long: `Add a new route mapping a domain to a container image. | |
| 315 | + | Short: "Create or update a route", | |
| 316 | + | Long: `Create or update a route mapping a domain to a container image. | |
| 317 | + | ||
| 318 | + | If the route already exists with the same image, this is a no-op. | |
| 319 | + | If it exists with a different image, the image is updated. | |
| 255 | 320 | ||
| 256 | 321 | Examples: | |
| 257 | 322 | gordon routes add app.mydomain.com myapp:latest | |
| @@ -299,7 +364,7 @@ Examples: | |||
| 299 | 364 | } | |
| 300 | 365 | } | |
| 301 | 366 | ||
| 302 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image))) | |
| 367 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) | |
| 303 | 368 | return nil | |
| 304 | 369 | }, | |
| 305 | 370 | } | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 9 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -111,7 +112,9 @@ func formatHTTPStatus(health *remote.RouteHealth) string { | |||||
| 111 | 112 | ||||
| 112 | // newRoutesListCmd creates the routes list command. | 113 | // newRoutesListCmd creates the routes list command. | ||
| 113 | func newRoutesListCmd() *cobra.Command { | 114 | func newRoutesListCmd() *cobra.Command { | ||
| 114 | - | return &cobra.Command{ | 115 | + | var jsonOut bool |
| 116 | + | ||||
| 117 | + | cmd := &cobra.Command{ | |||
| 115 | Use: "list", | 118 | Use: "list", | ||
| 116 | Short: "List all routes", | 119 | Short: "List all routes", | ||
| 117 | RunE: func(cmd *cobra.Command, args []string) error { | 120 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| @@ -119,31 +122,39 @@ func newRoutesListCmd() *cobra.Command { | |||||
| 119 | 122 | ||||
| 120 | client, isRemote := GetRemoteClient() | 123 | client, isRemote := GetRemoteClient() | ||
| 121 | if isRemote { | 124 | if isRemote { | ||
| 122 | - | return runRoutesListRemote(ctx, client) | 125 | + | return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout()) |
| 123 | } | 126 | } | ||
| 124 | - | return runRoutesListLocal(ctx, configPath) | 127 | + | return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout()) |
| 125 | }, | 128 | }, | ||
| 126 | } | 129 | } | ||
| 130 | + | ||||
| 131 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 132 | + | ||||
| 133 | + | return cmd | |||
| 127 | } | 134 | } | ||
| 128 | 135 | ||||
| 129 | // runRoutesListRemote lists routes from a remote Gordon instance. | 136 | // runRoutesListRemote lists routes from a remote Gordon instance. | ||
| 130 | - | func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | 137 | + | func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error { |
| 131 | routes, err := client.ListRoutesWithDetails(ctx) | 138 | routes, err := client.ListRoutesWithDetails(ctx) | ||
| 132 | if err != nil { | 139 | if err != nil { | ||
| 133 | return fmt.Errorf("failed to list routes: %w", err) | 140 | return fmt.Errorf("failed to list routes: %w", err) | ||
| 134 | } | 141 | } | ||
| 135 | 142 | ||||
| 136 | - | if len(routes) == 0 { | |||
| 137 | - | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | |||
| 138 | - | return nil | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | // Get health status for each route (includes container status and HTTP probe) | 143 | // Get health status for each route (includes container status and HTTP probe) | ||
| 142 | health, _ := client.GetHealth(ctx) | 144 | health, _ := client.GetHealth(ctx) | ||
| 143 | if health == nil { | 145 | if health == nil { | ||
| 144 | health = make(map[string]*remote.RouteHealth) | 146 | health = make(map[string]*remote.RouteHealth) | ||
| 145 | } | 147 | } | ||
| 146 | 148 | ||||
| 149 | + | if jsonOut { | |||
| 150 | + | return routesListJSON(out, routes, health) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | if len(routes) == 0 { | |||
| 154 | + | _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) | |||
| 155 | + | return nil | |||
| 156 | + | } | |||
| 157 | + | ||||
| 147 | const imageColWidth = 35 | 158 | const imageColWidth = 35 | ||
| 148 | const networkColWidth = 22 | 159 | const networkColWidth = 22 | ||
| 149 | rows := make([][]string, 0, len(routes)) | 160 | rows := make([][]string, 0, len(routes)) | ||
| @@ -199,15 +210,55 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error { | |||||
| 199 | components.WithRows(rows), | 210 | components.WithRows(rows), | ||
| 200 | ) | 211 | ) | ||
| 201 | 212 | ||||
| 202 | - | fmt.Println(styles.Theme.Title.Render("Routes")) | 213 | + | _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes")) |
| 203 | - | fmt.Println() | 214 | + | _, _ = fmt.Fprintln(out) |
| 204 | - | fmt.Println(table.View()) | 215 | + | _, _ = fmt.Fprintln(out, table.View()) |
| 205 | 216 | ||||
| 206 | return nil | 217 | return nil | ||
| 207 | } | 218 | } | ||
| 208 | 219 | ||||
| 220 | + | func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error { | |||
| 221 | + | payload := make([]map[string]any, 0, len(routes)) | |||
| 222 | + | for _, route := range routes { | |||
| 223 | + | routeHealth := health[route.Domain] | |||
| 224 | + | containerStatus := route.ContainerStatus | |||
| 225 | + | if containerStatus == "" { | |||
| 226 | + | if routeHealth != nil { | |||
| 227 | + | containerStatus = routeHealth.ContainerStatus | |||
| 228 | + | } else { | |||
| 229 | + | containerStatus = "unknown" | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | httpStatus := 0 | |||
| 234 | + | if routeHealth != nil { | |||
| 235 | + | httpStatus = routeHealth.HTTPStatus | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | attachments := make([]map[string]string, 0, len(route.Attachments)) | |||
| 239 | + | for _, attachment := range route.Attachments { | |||
| 240 | + | attachments = append(attachments, map[string]string{ | |||
| 241 | + | "name": attachment.Name, | |||
| 242 | + | "image": attachment.Image, | |||
| 243 | + | "status": attachment.Status, | |||
| 244 | + | }) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | payload = append(payload, map[string]any{ | |||
| 248 | + | "domain": route.Domain, | |||
| 249 | + | "image": route.Image, | |||
| 250 | + | "container_status": containerStatus, | |||
| 251 | + | "network": route.Network, | |||
| 252 | + | "http_status": httpStatus, | |||
| 253 | + | "attachments": attachments, | |||
| 254 | + | }) | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | return writeJSON(out, payload) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 209 | // runRoutesListLocal lists routes from local configuration. | 260 | // runRoutesListLocal lists routes from local configuration. | ||
| 210 | - | func runRoutesListLocal(ctx context.Context, cfgPath string) error { | 261 | + | func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error { |
| 211 | local, err := GetLocalServices(cfgPath) | 262 | local, err := GetLocalServices(cfgPath) | ||
| 212 | if err != nil { | 263 | if err != nil { | ||
| 213 | return fmt.Errorf("failed to initialize local services: %w", err) | 264 | return fmt.Errorf("failed to initialize local services: %w", err) | ||
| @@ -215,8 +266,19 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||||
| 215 | 266 | ||||
| 216 | routes := local.GetConfigService().GetRoutes(ctx) | 267 | routes := local.GetConfigService().GetRoutes(ctx) | ||
| 217 | 268 | ||||
| 269 | + | if jsonOut { | |||
| 270 | + | payload := make([]map[string]string, 0, len(routes)) | |||
| 271 | + | for _, route := range routes { | |||
| 272 | + | payload = append(payload, map[string]string{ | |||
| 273 | + | "domain": route.Domain, | |||
| 274 | + | "image": route.Image, | |||
| 275 | + | }) | |||
| 276 | + | } | |||
| 277 | + | return writeJSON(out, payload) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 218 | if len(routes) == 0 { | 280 | if len(routes) == 0 { | ||
| 219 | - | fmt.Println(styles.Theme.Muted.Render("No routes configured")) | 281 | + | _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured")) |
| 220 | return nil | 282 | return nil | ||
| 221 | } | 283 | } | ||
| 222 | 284 | ||||
| @@ -237,9 +299,9 @@ func runRoutesListLocal(ctx context.Context, cfgPath string) error { | |||||
| 237 | components.WithRows(rows), | 299 | components.WithRows(rows), | ||
| 238 | ) | 300 | ) | ||
| 239 | 301 | ||||
| 240 | - | fmt.Println(styles.Theme.Title.Render("Routes (local)")) | 302 | + | _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)")) |
| 241 | - | fmt.Println() | 303 | + | _, _ = fmt.Fprintln(out) |
| 242 | - | fmt.Println(table.View()) | 304 | + | _, _ = fmt.Fprintln(out, table.View()) |
| 243 | 305 | ||||
| 244 | return nil | 306 | return nil | ||
| 245 | } | 307 | } | ||
| @@ -250,8 +312,11 @@ func newRoutesAddCmd() *cobra.Command { | |||||
| 250 | 312 | ||||
| 251 | cmd := &cobra.Command{ | 313 | cmd := &cobra.Command{ | ||
| 252 | Use: "add <domain> <image>", | 314 | Use: "add <domain> <image>", | ||
| 253 | - | Short: "Add a new route", | 315 | + | Short: "Create or update a route", |
| 254 | - | Long: `Add a new route mapping a domain to a container image. | 316 | + | Long: `Create or update a route mapping a domain to a container image. |
| 317 | + | ||||
| 318 | + | If the route already exists with the same image, this is a no-op. | |||
| 319 | + | If it exists with a different image, the image is updated. | |||
| 255 | 320 | ||||
| 256 | Examples: | 321 | Examples: | ||
| 257 | gordon routes add app.mydomain.com myapp:latest | 322 | gordon routes add app.mydomain.com myapp:latest | ||
| @@ -299,7 +364,7 @@ Examples: | |||||
| 299 | } | 364 | } | ||
| 300 | } | 365 | } | ||
| 301 | 366 | ||||
| 302 | - | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image))) | 367 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route configured: %s -> %s", routeDomain, image))) |
| 303 | return nil | 368 | return nil | ||
| 304 | }, | 369 | }, | ||
| 305 | } | 370 | } | ||
M internal/adapters/in/cli/secrets.go +29 -3
| @@ -35,7 +35,9 @@ these commands operate on the remote server.`, | |||
| 35 | 35 | ||
| 36 | 36 | // newSecretsListCmd creates the secrets list command. | |
| 37 | 37 | func newSecretsListCmd() *cobra.Command { | |
| 38 | - | return &cobra.Command{ | |
| 38 | + | var jsonOut bool | |
| 39 | + | ||
| 40 | + | cmd := &cobra.Command{ | |
| 39 | 41 | Use: "list <domain>", | |
| 40 | 42 | Short: "List secrets for a domain", | |
| 41 | 43 | Long: `List all secret keys configured for a domain. | |
| @@ -47,12 +49,18 @@ Examples: | |||
| 47 | 49 | gordon secrets list app.mydomain.com | |
| 48 | 50 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | |
| 49 | 51 | Args: cobra.ExactArgs(1), | |
| 50 | - | RunE: runSecretsListCmd, | |
| 52 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 53 | + | return runSecretsListCmd(cmd, args, jsonOut) | |
| 54 | + | }, | |
| 51 | 55 | } | |
| 56 | + | ||
| 57 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 58 | + | ||
| 59 | + | return cmd | |
| 52 | 60 | } | |
| 53 | 61 | ||
| 54 | 62 | // runSecretsListCmd executes the secrets list command. | |
| 55 | - | func runSecretsListCmd(_ *cobra.Command, args []string) error { | |
| 63 | + | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { | |
| 56 | 64 | ctx := context.Background() | |
| 57 | 65 | secretDomain := args[0] | |
| 58 | 66 | ||
| @@ -67,10 +75,28 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error { | |||
| 67 | 75 | } | |
| 68 | 76 | ||
| 69 | 77 | if totalSecrets == 0 { | |
| 78 | + | if jsonOut { | |
| 79 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 80 | + | "domain": secretDomain, | |
| 81 | + | "keys": []string{}, | |
| 82 | + | "attachments": []any{}, | |
| 83 | + | }) | |
| 84 | + | } | |
| 70 | 85 | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | |
| 71 | 86 | return nil | |
| 72 | 87 | } | |
| 73 | 88 | ||
| 89 | + | if jsonOut { | |
| 90 | + | if attachments == nil { | |
| 91 | + | attachments = []remote.AttachmentSecrets{} | |
| 92 | + | } | |
| 93 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |
| 94 | + | "domain": secretDomain, | |
| 95 | + | "keys": keys, | |
| 96 | + | "attachments": attachments, | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | ||
| 74 | 100 | title := fmt.Sprintf("Secrets for %s", secretDomain) | |
| 75 | 101 | if !isRemote { | |
| 76 | 102 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | |
| @@ -35,7 +35,9 @@ these commands operate on the remote server.`, | |||||
| 35 | 35 | ||||
| 36 | // newSecretsListCmd creates the secrets list command. | 36 | // newSecretsListCmd creates the secrets list command. | ||
| 37 | func newSecretsListCmd() *cobra.Command { | 37 | func newSecretsListCmd() *cobra.Command { | ||
| 38 | - | return &cobra.Command{ | 38 | + | var jsonOut bool |
| 39 | + | ||||
| 40 | + | cmd := &cobra.Command{ | |||
| 39 | Use: "list <domain>", | 41 | Use: "list <domain>", | ||
| 40 | Short: "List secrets for a domain", | 42 | Short: "List secrets for a domain", | ||
| 41 | Long: `List all secret keys configured for a domain. | 43 | Long: `List all secret keys configured for a domain. | ||
| @@ -47,12 +49,18 @@ Examples: | |||||
| 47 | gordon secrets list app.mydomain.com | 49 | gordon secrets list app.mydomain.com | ||
| 48 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | 50 | gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`, | ||
| 49 | Args: cobra.ExactArgs(1), | 51 | Args: cobra.ExactArgs(1), | ||
| 50 | - | RunE: runSecretsListCmd, | 52 | + | RunE: func(cmd *cobra.Command, args []string) error { |
| 53 | + | return runSecretsListCmd(cmd, args, jsonOut) | |||
| 54 | + | }, | |||
| 51 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 58 | + | ||||
| 59 | + | return cmd | |||
| 52 | } | 60 | } | ||
| 53 | 61 | ||||
| 54 | // runSecretsListCmd executes the secrets list command. | 62 | // runSecretsListCmd executes the secrets list command. | ||
| 55 | - | func runSecretsListCmd(_ *cobra.Command, args []string) error { | 63 | + | func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error { |
| 56 | ctx := context.Background() | 64 | ctx := context.Background() | ||
| 57 | secretDomain := args[0] | 65 | secretDomain := args[0] | ||
| 58 | 66 | ||||
| @@ -67,10 +75,28 @@ func runSecretsListCmd(_ *cobra.Command, args []string) error { | |||||
| 67 | } | 75 | } | ||
| 68 | 76 | ||||
| 69 | if totalSecrets == 0 { | 77 | if totalSecrets == 0 { | ||
| 78 | + | if jsonOut { | |||
| 79 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 80 | + | "domain": secretDomain, | |||
| 81 | + | "keys": []string{}, | |||
| 82 | + | "attachments": []any{}, | |||
| 83 | + | }) | |||
| 84 | + | } | |||
| 70 | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | 85 | fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain))) | ||
| 71 | return nil | 86 | return nil | ||
| 72 | } | 87 | } | ||
| 73 | 88 | ||||
| 89 | + | if jsonOut { | |||
| 90 | + | if attachments == nil { | |||
| 91 | + | attachments = []remote.AttachmentSecrets{} | |||
| 92 | + | } | |||
| 93 | + | return writeJSON(cmd.OutOrStdout(), map[string]any{ | |||
| 94 | + | "domain": secretDomain, | |||
| 95 | + | "keys": keys, | |||
| 96 | + | "attachments": attachments, | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 74 | title := fmt.Sprintf("Secrets for %s", secretDomain) | 100 | title := fmt.Sprintf("Secrets for %s", secretDomain) | ||
| 75 | if !isRemote { | 101 | if !isRemote { | ||
| 76 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | 102 | title = fmt.Sprintf("Secrets for %s (local)", secretDomain) | ||
M internal/adapters/in/cli/ui_adoption_test.go +1 -1
| @@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 202 | 202 | } | |
| 203 | 203 | ||
| 204 | 204 | imgBuf := new(bytes.Buffer) | |
| 205 | - | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |
| 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil { | |
| 206 | 206 | t.Fatalf("runImagesList failed: %v", err) | |
| 207 | 207 | } | |
| 208 | 208 | ||
| @@ -202,7 +202,7 @@ func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| 204 | imgBuf := new(bytes.Buffer) | 204 | imgBuf := new(bytes.Buffer) | ||
| 205 | - | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf, false); err != nil { |
| 206 | t.Fatalf("runImagesList failed: %v", err) | 206 | t.Fatalf("runImagesList failed: %v", err) | ||
| 207 | } | 207 | } | ||
| 208 | 208 | ||||
A internal/adapters/in/http/admin/bootstrap_test.go +252 -0
| @@ -0,0 +1,252 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 17 | + | "github.com/bnema/gordon/internal/domain" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |
| 21 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 22 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 23 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 24 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 25 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 26 | + | ||
| 27 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 28 | + | ||
| 29 | + | payload := dto.BootstrapRequest{ | |
| 30 | + | Domain: "app.example.com", | |
| 31 | + | Image: "myapp:latest", | |
| 32 | + | Attachments: []string{"postgres:16"}, | |
| 33 | + | Env: map[string]string{"APP_ENV": "prod"}, | |
| 34 | + | AttachmentEnv: map[string]map[string]string{ | |
| 35 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |
| 36 | + | }, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |
| 40 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |
| 41 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 42 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| 43 | + | ||
| 44 | + | body, err := json.Marshal(payload) | |
| 45 | + | require.NoError(t, err) | |
| 46 | + | ||
| 47 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 48 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 49 | + | rec := httptest.NewRecorder() | |
| 50 | + | ||
| 51 | + | handler.ServeHTTP(rec, req) | |
| 52 | + | ||
| 53 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 54 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |
| 55 | + | ||
| 56 | + | var response dto.BootstrapResponse | |
| 57 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 58 | + | assert.Equal(t, payload.Domain, response.Domain) | |
| 59 | + | assert.Equal(t, payload.Image, response.Image) | |
| 60 | + | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |
| 61 | + | assert.Equal(t, []dto.BootstrapStep{ | |
| 62 | + | {Name: "route", Status: "configured"}, | |
| 63 | + | {Name: "attachment:postgres:16", Status: "created"}, | |
| 64 | + | {Name: "env", Status: "updated"}, | |
| 65 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |
| 66 | + | }, response.Steps) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |
| 70 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 71 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 72 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 73 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 74 | + | ||
| 75 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 76 | + | ||
| 77 | + | payload := dto.BootstrapRequest{ | |
| 78 | + | Domain: "app.example.com", | |
| 79 | + | Image: "myapp:latest", | |
| 80 | + | Attachments: []string{"postgres:16"}, | |
| 81 | + | Env: map[string]string{"APP_ENV": "prod"}, | |
| 82 | + | AttachmentEnv: map[string]map[string]string{ | |
| 83 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |
| 88 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |
| 89 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 90 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| 91 | + | ||
| 92 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |
| 93 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once() | |
| 94 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 95 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| 96 | + | ||
| 97 | + | body, err := json.Marshal(payload) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | ||
| 100 | + | for i := range 2 { | |
| 101 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 102 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 103 | + | rec := httptest.NewRecorder() | |
| 104 | + | ||
| 105 | + | handler.ServeHTTP(rec, req) | |
| 106 | + | ||
| 107 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 108 | + | ||
| 109 | + | var response dto.BootstrapResponse | |
| 110 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 111 | + | assert.Equal(t, payload.Domain, response.Domain) | |
| 112 | + | assert.Equal(t, payload.Image, response.Image) | |
| 113 | + | require.Len(t, response.Steps, 4) | |
| 114 | + | if i == 0 { | |
| 115 | + | assert.Equal(t, []dto.BootstrapStep{ | |
| 116 | + | {Name: "route", Status: "configured"}, | |
| 117 | + | {Name: "attachment:postgres:16", Status: "created"}, | |
| 118 | + | {Name: "env", Status: "updated"}, | |
| 119 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |
| 120 | + | }, response.Steps) | |
| 121 | + | } else { | |
| 122 | + | assert.Equal(t, []dto.BootstrapStep{ | |
| 123 | + | {Name: "route", Status: "configured"}, | |
| 124 | + | {Name: "attachment:postgres:16", Status: "noop"}, | |
| 125 | + | {Name: "env", Status: "updated"}, | |
| 126 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |
| 127 | + | }, response.Steps) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestBootstrap_MissingDomain(t *testing.T) { | |
| 133 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 134 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 135 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 136 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 137 | + | ||
| 138 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 139 | + | ||
| 140 | + | body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"}) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | ||
| 143 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 144 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 145 | + | rec := httptest.NewRecorder() | |
| 146 | + | ||
| 147 | + | handler.ServeHTTP(rec, req) | |
| 148 | + | ||
| 149 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 150 | + | assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String()) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func TestBootstrap_MissingImage(t *testing.T) { | |
| 154 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 155 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 156 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 157 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 158 | + | ||
| 159 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 160 | + | ||
| 161 | + | body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"}) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | ||
| 164 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 165 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 166 | + | rec := httptest.NewRecorder() | |
| 167 | + | ||
| 168 | + | handler.ServeHTTP(rec, req) | |
| 169 | + | ||
| 170 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 171 | + | assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String()) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |
| 175 | + | tests := []struct { | |
| 176 | + | name string | |
| 177 | + | mockErr error | |
| 178 | + | }{ | |
| 179 | + | { | |
| 180 | + | name: "empty domain from service", | |
| 181 | + | mockErr: domain.ErrRouteDomainEmpty, | |
| 182 | + | }, | |
| 183 | + | { | |
| 184 | + | name: "empty image from service", | |
| 185 | + | mockErr: domain.ErrRouteImageEmpty, | |
| 186 | + | }, | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | for _, tt := range tests { | |
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |
| 191 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 192 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 193 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 194 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 195 | + | ||
| 196 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 197 | + | ||
| 198 | + | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"} | |
| 199 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once() | |
| 200 | + | ||
| 201 | + | body, err := json.Marshal(payload) | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | ||
| 204 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 205 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 206 | + | rec := httptest.NewRecorder() | |
| 207 | + | ||
| 208 | + | handler.ServeHTTP(rec, req) | |
| 209 | + | ||
| 210 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 211 | + | assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String()) | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |
| 217 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 218 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 219 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 220 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 221 | + | ||
| 222 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 223 | + | ||
| 224 | + | payload := dto.BootstrapRequest{ | |
| 225 | + | Domain: "app.example.com", | |
| 226 | + | Image: "myapp:latest", | |
| 227 | + | Attachments: []string{"postgres:16"}, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |
| 231 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once() | |
| 232 | + | ||
| 233 | + | body, err := json.Marshal(payload) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | ||
| 236 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 237 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 238 | + | rec := httptest.NewRecorder() | |
| 239 | + | ||
| 240 | + | handler.ServeHTTP(rec, req) | |
| 241 | + | ||
| 242 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 243 | + | ||
| 244 | + | var response dto.BootstrapResponse | |
| 245 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 246 | + | assert.Equal(t, []dto.BootstrapStep{ | |
| 247 | + | {Name: "route", Status: "configured"}, | |
| 248 | + | {Name: "attachment:postgres:16", Status: "failed"}, | |
| 249 | + | }, response.Steps) | |
| 250 | + | secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything) | |
| 251 | + | secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything) | |
| 252 | + | } | |
| @@ -0,0 +1,252 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 16 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |||
| 21 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 22 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 23 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 24 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 25 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 26 | + | ||||
| 27 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |||
| 28 | + | ||||
| 29 | + | payload := dto.BootstrapRequest{ | |||
| 30 | + | Domain: "app.example.com", | |||
| 31 | + | Image: "myapp:latest", | |||
| 32 | + | Attachments: []string{"postgres:16"}, | |||
| 33 | + | Env: map[string]string{"APP_ENV": "prod"}, | |||
| 34 | + | AttachmentEnv: map[string]map[string]string{ | |||
| 35 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |||
| 36 | + | }, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |||
| 40 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |||
| 41 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |||
| 42 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |||
| 43 | + | ||||
| 44 | + | body, err := json.Marshal(payload) | |||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | ||||
| 47 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 48 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |||
| 49 | + | rec := httptest.NewRecorder() | |||
| 50 | + | ||||
| 51 | + | handler.ServeHTTP(rec, req) | |||
| 52 | + | ||||
| 53 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 54 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |||
| 55 | + | ||||
| 56 | + | var response dto.BootstrapResponse | |||
| 57 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 58 | + | assert.Equal(t, payload.Domain, response.Domain) | |||
| 59 | + | assert.Equal(t, payload.Image, response.Image) | |||
| 60 | + | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |||
| 61 | + | assert.Equal(t, []dto.BootstrapStep{ | |||
| 62 | + | {Name: "route", Status: "configured"}, | |||
| 63 | + | {Name: "attachment:postgres:16", Status: "created"}, | |||
| 64 | + | {Name: "env", Status: "updated"}, | |||
| 65 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |||
| 66 | + | }, response.Steps) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||
| 70 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 71 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 72 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 73 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 74 | + | ||||
| 75 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 76 | + | ||||
| 77 | + | payload := dto.BootstrapRequest{ | |||
| 78 | + | Domain: "app.example.com", | |||
| 79 | + | Image: "myapp:latest", | |||
| 80 | + | Attachments: []string{"postgres:16"}, | |||
| 81 | + | Env: map[string]string{"APP_ENV": "prod"}, | |||
| 82 | + | AttachmentEnv: map[string]map[string]string{ | |||
| 83 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |||
| 88 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |||
| 89 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |||
| 90 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |||
| 91 | + | ||||
| 92 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |||
| 93 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once() | |||
| 94 | + | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |||
| 95 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |||
| 96 | + | ||||
| 97 | + | body, err := json.Marshal(payload) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | for i := range 2 { | |||
| 101 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 102 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |||
| 103 | + | rec := httptest.NewRecorder() | |||
| 104 | + | ||||
| 105 | + | handler.ServeHTTP(rec, req) | |||
| 106 | + | ||||
| 107 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 108 | + | ||||
| 109 | + | var response dto.BootstrapResponse | |||
| 110 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 111 | + | assert.Equal(t, payload.Domain, response.Domain) | |||
| 112 | + | assert.Equal(t, payload.Image, response.Image) | |||
| 113 | + | require.Len(t, response.Steps, 4) | |||
| 114 | + | if i == 0 { | |||
| 115 | + | assert.Equal(t, []dto.BootstrapStep{ | |||
| 116 | + | {Name: "route", Status: "configured"}, | |||
| 117 | + | {Name: "attachment:postgres:16", Status: "created"}, | |||
| 118 | + | {Name: "env", Status: "updated"}, | |||
| 119 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |||
| 120 | + | }, response.Steps) | |||
| 121 | + | } else { | |||
| 122 | + | assert.Equal(t, []dto.BootstrapStep{ | |||
| 123 | + | {Name: "route", Status: "configured"}, | |||
| 124 | + | {Name: "attachment:postgres:16", Status: "noop"}, | |||
| 125 | + | {Name: "env", Status: "updated"}, | |||
| 126 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |||
| 127 | + | }, response.Steps) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestBootstrap_MissingDomain(t *testing.T) { | |||
| 133 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 134 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 135 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 136 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 137 | + | ||||
| 138 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 139 | + | ||||
| 140 | + | body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"}) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | ||||
| 143 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 144 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |||
| 145 | + | rec := httptest.NewRecorder() | |||
| 146 | + | ||||
| 147 | + | handler.ServeHTTP(rec, req) | |||
| 148 | + | ||||
| 149 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 150 | + | assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String()) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestBootstrap_MissingImage(t *testing.T) { | |||
| 154 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 155 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 156 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 157 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 158 | + | ||||
| 159 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 160 | + | ||||
| 161 | + | body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"}) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | ||||
| 164 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 165 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |||
| 166 | + | rec := httptest.NewRecorder() | |||
| 167 | + | ||||
| 168 | + | handler.ServeHTTP(rec, req) | |||
| 169 | + | ||||
| 170 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 171 | + | assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String()) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||
| 175 | + | tests := []struct { | |||
| 176 | + | name string | |||
| 177 | + | mockErr error | |||
| 178 | + | }{ | |||
| 179 | + | { | |||
| 180 | + | name: "empty domain from service", | |||
| 181 | + | mockErr: domain.ErrRouteDomainEmpty, | |||
| 182 | + | }, | |||
| 183 | + | { | |||
| 184 | + | name: "empty image from service", | |||
| 185 | + | mockErr: domain.ErrRouteImageEmpty, | |||
| 186 | + | }, | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | for _, tt := range tests { | |||
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 191 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 192 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 193 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 194 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 195 | + | ||||
| 196 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 197 | + | ||||
| 198 | + | payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 199 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once() | |||
| 200 | + | ||||
| 201 | + | body, err := json.Marshal(payload) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | ||||
| 204 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 205 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |||
| 206 | + | rec := httptest.NewRecorder() | |||
| 207 | + | ||||
| 208 | + | handler.ServeHTTP(rec, req) | |||
| 209 | + | ||||
| 210 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 211 | + | assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String()) | |||
| 212 | + | }) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||
| 217 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 218 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 219 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 220 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 221 | + | ||||
| 222 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 223 | + | ||||
| 224 | + | payload := dto.BootstrapRequest{ | |||
| 225 | + | Domain: "app.example.com", | |||
| 226 | + | Image: "myapp:latest", | |||
| 227 | + | Attachments: []string{"postgres:16"}, | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |||
| 231 | + | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once() | |||
| 232 | + | ||||
| 233 | + | body, err := json.Marshal(payload) | |||
| 234 | + | require.NoError(t, err) | |||
| 235 | + | ||||
| 236 | + | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |||
| 237 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |||
| 238 | + | rec := httptest.NewRecorder() | |||
| 239 | + | ||||
| 240 | + | handler.ServeHTTP(rec, req) | |||
| 241 | + | ||||
| 242 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 243 | + | ||||
| 244 | + | var response dto.BootstrapResponse | |||
| 245 | + | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 246 | + | assert.Equal(t, []dto.BootstrapStep{ | |||
| 247 | + | {Name: "route", Status: "configured"}, | |||
| 248 | + | {Name: "attachment:postgres:16", Status: "failed"}, | |||
| 249 | + | }, response.Steps) | |||
| 250 | + | secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything) | |||
| 251 | + | secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything) | |||
| 252 | + | } | |||
M internal/adapters/in/http/admin/handler.go +140 -30
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package admin | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| @@ -201,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 201 | 202 | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | |
| 202 | 203 | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | |
| 203 | 204 | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | |
| 205 | + | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, | |
| 204 | 206 | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | |
| 205 | 207 | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | |
| 206 | 208 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | |
| @@ -277,6 +279,144 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat | |||
| 277 | 279 | }) | |
| 278 | 280 | } | |
| 279 | 281 | ||
| 282 | + | func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |
| 283 | + | if r.Method != http.MethodPost { | |
| 284 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 285 | + | return | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | ctx := r.Context() | |
| 289 | + | log := zerowrap.FromCtx(ctx) | |
| 290 | + | ||
| 291 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { | |
| 292 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |
| 293 | + | return | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 297 | + | ||
| 298 | + | var req dto.BootstrapRequest | |
| 299 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |
| 300 | + | log.Warn().Err(err).Msg("invalid bootstrap JSON") | |
| 301 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | if req.Domain == "" { | |
| 306 | + | h.sendError(w, http.StatusBadRequest, "domain is required") | |
| 307 | + | return | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | if req.Image == "" { | |
| 311 | + | h.sendError(w, http.StatusBadRequest, "image is required") | |
| 312 | + | return | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | if err := h.validateBootstrapPermissions(ctx, req); err != nil { | |
| 316 | + | h.sendError(w, http.StatusForbidden, err.Error()) | |
| 317 | + | return | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | resp := dto.BootstrapResponse{ | |
| 321 | + | Domain: req.Domain, | |
| 322 | + | Image: req.Image, | |
| 323 | + | Next: fmt.Sprintf("push %s to trigger deployment", req.Image), | |
| 324 | + | } | |
| 325 | + | addStep := func(name, status string) { | |
| 326 | + | resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |
| 330 | + | switch { | |
| 331 | + | case err == nil: | |
| 332 | + | addStep("route", "configured") | |
| 333 | + | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | |
| 334 | + | addStep("route", "failed") | |
| 335 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |
| 336 | + | return | |
| 337 | + | default: | |
| 338 | + | addStep("route", "failed") | |
| 339 | + | log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route") | |
| 340 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |
| 341 | + | return | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil { | |
| 345 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments") | |
| 346 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |
| 347 | + | return | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil { | |
| 351 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env") | |
| 352 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil { | |
| 357 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env") | |
| 358 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |
| 359 | + | return | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | h.sendJSON(w, http.StatusOK, resp) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error { | |
| 366 | + | if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 367 | + | return errors.New("insufficient permissions for secrets:write") | |
| 368 | + | } | |
| 369 | + | if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 370 | + | return errors.New("insufficient permissions for config:write") | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | return nil | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error { | |
| 377 | + | for _, attachment := range attachments { | |
| 378 | + | err := configSvc.AddAttachment(ctx, domainName, attachment) | |
| 379 | + | switch { | |
| 380 | + | case err == nil: | |
| 381 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"}) | |
| 382 | + | case errors.Is(err, domain.ErrAttachmentExists): | |
| 383 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"}) | |
| 384 | + | default: | |
| 385 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"}) | |
| 386 | + | return err | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | return nil | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error { | |
| 394 | + | if len(env) == 0 { | |
| 395 | + | return nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | if err := secretSvc.Set(ctx, domainName, env); err != nil { | |
| 399 | + | *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"}) | |
| 400 | + | return err | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"}) | |
| 404 | + | return nil | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error { | |
| 408 | + | for service, env := range attachmentEnv { | |
| 409 | + | if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil { | |
| 410 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"}) | |
| 411 | + | return err | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"}) | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | return nil | |
| 418 | + | } | |
| 419 | + | ||
| 280 | 420 | // sendJSON sends a JSON response. | |
| 281 | 421 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | |
| 282 | 422 | w.Header().Set("Content-Type", "application/json") | |
| @@ -385,21 +525,6 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||
| 385 | 525 | return | |
| 386 | 526 | } | |
| 387 | 527 | ||
| 388 | - | // Validate image exists in registry before adding route | |
| 389 | - | if h.registrySvc != nil && route.Image != "" { | |
| 390 | - | imageName, imageRef := validation.ParseImageReference(route.Image) | |
| 391 | - | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |
| 392 | - | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 393 | - | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |
| 394 | - | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |
| 395 | - | } else { | |
| 396 | - | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |
| 397 | - | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |
| 398 | - | } | |
| 399 | - | return | |
| 400 | - | } | |
| 401 | - | } | |
| 402 | - | ||
| 403 | 528 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | |
| 404 | 529 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | |
| 405 | 530 | switch { | |
| @@ -442,21 +567,6 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD | |||
| 442 | 567 | ||
| 443 | 568 | route.Domain = routeDomain | |
| 444 | 569 | ||
| 445 | - | // Validate image exists in registry before updating route | |
| 446 | - | if h.registrySvc != nil && route.Image != "" { | |
| 447 | - | imageName, imageRef := validation.ParseImageReference(route.Image) | |
| 448 | - | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |
| 449 | - | if errors.Is(err, domain.ErrManifestNotFound) { | |
| 450 | - | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |
| 451 | - | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |
| 452 | - | } else { | |
| 453 | - | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |
| 454 | - | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |
| 455 | - | } | |
| 456 | - | return | |
| 457 | - | } | |
| 458 | - | } | |
| 459 | - | ||
| 460 | 570 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | |
| 461 | 571 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | |
| 462 | 572 | switch { | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package admin | 2 | package admin | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| @@ -201,6 +202,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 201 | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | 202 | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | ||
| 202 | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | 203 | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | ||
| 203 | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | 204 | "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) }, | ||
| 205 | + | "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) }, | |||
| 204 | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | 206 | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | ||
| 205 | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | 207 | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | ||
| 206 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | 208 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | ||
| @@ -277,6 +279,144 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat | |||||
| 277 | }) | 279 | }) | ||
| 278 | } | 280 | } | ||
| 279 | 281 | ||||
| 282 | + | func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 283 | + | if r.Method != http.MethodPost { | |||
| 284 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 285 | + | return | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | ctx := r.Context() | |||
| 289 | + | log := zerowrap.FromCtx(ctx) | |||
| 290 | + | ||||
| 291 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) { | |||
| 292 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |||
| 293 | + | return | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 297 | + | ||||
| 298 | + | var req dto.BootstrapRequest | |||
| 299 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | |||
| 300 | + | log.Warn().Err(err).Msg("invalid bootstrap JSON") | |||
| 301 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 302 | + | return | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | if req.Domain == "" { | |||
| 306 | + | h.sendError(w, http.StatusBadRequest, "domain is required") | |||
| 307 | + | return | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | if req.Image == "" { | |||
| 311 | + | h.sendError(w, http.StatusBadRequest, "image is required") | |||
| 312 | + | return | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | if err := h.validateBootstrapPermissions(ctx, req); err != nil { | |||
| 316 | + | h.sendError(w, http.StatusForbidden, err.Error()) | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | resp := dto.BootstrapResponse{ | |||
| 321 | + | Domain: req.Domain, | |||
| 322 | + | Image: req.Image, | |||
| 323 | + | Next: fmt.Sprintf("push %s to trigger deployment", req.Image), | |||
| 324 | + | } | |||
| 325 | + | addStep := func(name, status string) { | |||
| 326 | + | resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status}) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |||
| 330 | + | switch { | |||
| 331 | + | case err == nil: | |||
| 332 | + | addStep("route", "configured") | |||
| 333 | + | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | |||
| 334 | + | addStep("route", "failed") | |||
| 335 | + | h.sendError(w, http.StatusBadRequest, err.Error()) | |||
| 336 | + | return | |||
| 337 | + | default: | |||
| 338 | + | addStep("route", "failed") | |||
| 339 | + | log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route") | |||
| 340 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |||
| 341 | + | return | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | if err := h.bootstrapAttachments(ctx, h.configSvc, req.Domain, req.Attachments, &resp.Steps); err != nil { | |||
| 345 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachments") | |||
| 346 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |||
| 347 | + | return | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | if err := h.bootstrapSecrets(ctx, h.secretSvc, req.Domain, req.Env, &resp.Steps); err != nil { | |||
| 351 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env") | |||
| 352 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | if err := h.bootstrapAttachmentSecrets(ctx, h.secretSvc, req.Domain, req.AttachmentEnv, &resp.Steps); err != nil { | |||
| 357 | + | log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap attachment env") | |||
| 358 | + | h.sendJSON(w, http.StatusInternalServerError, resp) | |||
| 359 | + | return | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | h.sendJSON(w, http.StatusOK, resp) | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | func (h *Handler) validateBootstrapPermissions(ctx context.Context, req dto.BootstrapRequest) error { | |||
| 366 | + | if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 367 | + | return errors.New("insufficient permissions for secrets:write") | |||
| 368 | + | } | |||
| 369 | + | if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 370 | + | return errors.New("insufficient permissions for config:write") | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | return nil | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func (h *Handler) bootstrapAttachments(ctx context.Context, configSvc in.ConfigService, domainName string, attachments []string, steps *[]dto.BootstrapStep) error { | |||
| 377 | + | for _, attachment := range attachments { | |||
| 378 | + | err := configSvc.AddAttachment(ctx, domainName, attachment) | |||
| 379 | + | switch { | |||
| 380 | + | case err == nil: | |||
| 381 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "created"}) | |||
| 382 | + | case errors.Is(err, domain.ErrAttachmentExists): | |||
| 383 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "noop"}) | |||
| 384 | + | default: | |||
| 385 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment:" + attachment, Status: "failed"}) | |||
| 386 | + | return err | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | return nil | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func (h *Handler) bootstrapSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, env map[string]string, steps *[]dto.BootstrapStep) error { | |||
| 394 | + | if len(env) == 0 { | |||
| 395 | + | return nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | if err := secretSvc.Set(ctx, domainName, env); err != nil { | |||
| 399 | + | *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "failed"}) | |||
| 400 | + | return err | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | *steps = append(*steps, dto.BootstrapStep{Name: "env", Status: "updated"}) | |||
| 404 | + | return nil | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func (h *Handler) bootstrapAttachmentSecrets(ctx context.Context, secretSvc in.SecretService, domainName string, attachmentEnv map[string]map[string]string, steps *[]dto.BootstrapStep) error { | |||
| 408 | + | for service, env := range attachmentEnv { | |||
| 409 | + | if err := secretSvc.SetAttachment(ctx, domainName, service, env); err != nil { | |||
| 410 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "failed"}) | |||
| 411 | + | return err | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | *steps = append(*steps, dto.BootstrapStep{Name: "attachment_env:" + service, Status: "updated"}) | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | return nil | |||
| 418 | + | } | |||
| 419 | + | ||||
| 280 | // sendJSON sends a JSON response. | 420 | // sendJSON sends a JSON response. | ||
| 281 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | 421 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | ||
| 282 | w.Header().Set("Content-Type", "application/json") | 422 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -385,21 +525,6 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) { | |||||
| 385 | return | 525 | return | ||
| 386 | } | 526 | } | ||
| 387 | 527 | ||||
| 388 | - | // Validate image exists in registry before adding route | |||
| 389 | - | if h.registrySvc != nil && route.Image != "" { | |||
| 390 | - | imageName, imageRef := validation.ParseImageReference(route.Image) | |||
| 391 | - | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |||
| 392 | - | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 393 | - | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |||
| 394 | - | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |||
| 395 | - | } else { | |||
| 396 | - | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |||
| 397 | - | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |||
| 398 | - | } | |||
| 399 | - | return | |||
| 400 | - | } | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | 528 | if err := h.configSvc.AddRoute(ctx, route); err != nil { | ||
| 404 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | 529 | log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route") | ||
| 405 | switch { | 530 | switch { | ||
| @@ -442,21 +567,6 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD | |||||
| 442 | 567 | ||||
| 443 | route.Domain = routeDomain | 568 | route.Domain = routeDomain | ||
| 444 | 569 | ||||
| 445 | - | // Validate image exists in registry before updating route | |||
| 446 | - | if h.registrySvc != nil && route.Image != "" { | |||
| 447 | - | imageName, imageRef := validation.ParseImageReference(route.Image) | |||
| 448 | - | if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil { | |||
| 449 | - | if errors.Is(err, domain.ErrManifestNotFound) { | |||
| 450 | - | log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry") | |||
| 451 | - | h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image)) | |||
| 452 | - | } else { | |||
| 453 | - | log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry") | |||
| 454 | - | h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry") | |||
| 455 | - | } | |||
| 456 | - | return | |||
| 457 | - | } | |||
| 458 | - | } | |||
| 459 | - | ||||
| 460 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | 570 | if err := h.configSvc.UpdateRoute(ctx, route); err != nil { | ||
| 461 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | 571 | log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route") | ||
| 462 | switch { | 572 | switch { | ||
M internal/adapters/in/http/admin/handler_test.go +131 -58
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/mock" | |
| 17 | 17 | ||
| 18 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 18 | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 19 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 20 | 21 | ) | |
| @@ -158,7 +159,6 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 158 | 159 | t.Run(tt.name, func(t *testing.T) { | |
| 159 | 160 | if tt.wantStatus == http.StatusCreated { | |
| 160 | 161 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |
| 161 | - | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe() | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 164 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | |
| @@ -204,7 +204,6 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 204 | 204 | t.Run(tt.name, func(t *testing.T) { | |
| 205 | 205 | if tt.wantStatus == http.StatusOK { | |
| 206 | 206 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | |
| 207 | - | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe() | |
| 208 | 207 | } | |
| 209 | 208 | ||
| 210 | 209 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | |
| @@ -218,6 +217,52 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 218 | 217 | } | |
| 219 | 218 | } | |
| 220 | 219 | ||
| 220 | + | func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) { | |
| 221 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 222 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 223 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 224 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 225 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 226 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |
| 227 | + | ||
| 228 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 229 | + | ||
| 230 | + | configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() | |
| 231 | + | ||
| 232 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) | |
| 233 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| 234 | + | rec := httptest.NewRecorder() | |
| 235 | + | ||
| 236 | + | handler.ServeHTTP(rec, req) | |
| 237 | + | ||
| 238 | + | assert.Equal(t, http.StatusCreated, rec.Code) | |
| 239 | + | assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String()) | |
| 240 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T) { | |
| 244 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 245 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 246 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 247 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 248 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 249 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"} | |
| 250 | + | ||
| 251 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 252 | + | ||
| 253 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() | |
| 254 | + | ||
| 255 | + | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`)) | |
| 256 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| 257 | + | rec := httptest.NewRecorder() | |
| 258 | + | ||
| 259 | + | handler.ServeHTTP(rec, req) | |
| 260 | + | ||
| 261 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 262 | + | assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String()) | |
| 263 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |
| 264 | + | } | |
| 265 | + | ||
| 221 | 266 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |
| 222 | 267 | configSvc := inmocks.NewMockConfigService(t) | |
| 223 | 268 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -463,6 +508,90 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||
| 463 | 508 | assert.Equal(t, "deleted", response.Status) | |
| 464 | 509 | } | |
| 465 | 510 | ||
| 511 | + | func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |
| 512 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 513 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 514 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 515 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 516 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 517 | + | ||
| 518 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 519 | + | ||
| 520 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | |
| 521 | + | configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once() | |
| 522 | + | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 523 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| 524 | + | ||
| 525 | + | body, err := json.Marshal(dto.BootstrapRequest{ | |
| 526 | + | Domain: "app.example.com", | |
| 527 | + | Image: "myapp:latest", | |
| 528 | + | Attachments: []string{"postgres:16"}, | |
| 529 | + | Env: map[string]string{"APP_ENV": "prod"}, | |
| 530 | + | AttachmentEnv: map[string]map[string]string{ | |
| 531 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |
| 532 | + | }, | |
| 533 | + | }) | |
| 534 | + | assert.NoError(t, err) | |
| 535 | + | ||
| 536 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) | |
| 537 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 538 | + | rec := httptest.NewRecorder() | |
| 539 | + | ||
| 540 | + | handler.ServeHTTP(rec, req) | |
| 541 | + | ||
| 542 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 543 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |
| 544 | + | ||
| 545 | + | var response dto.BootstrapResponse | |
| 546 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 547 | + | assert.Equal(t, "app.example.com", response.Domain) | |
| 548 | + | assert.Equal(t, "myapp:latest", response.Image) | |
| 549 | + | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |
| 550 | + | assert.Equal(t, []dto.BootstrapStep{ | |
| 551 | + | {Name: "route", Status: "configured"}, | |
| 552 | + | {Name: "attachment:postgres:16", Status: "created"}, | |
| 553 | + | {Name: "env", Status: "updated"}, | |
| 554 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |
| 555 | + | }, response.Steps) | |
| 556 | + | } | |
| 557 | + | ||
| 558 | + | func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |
| 559 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 560 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 561 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 562 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 563 | + | ||
| 564 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 565 | + | ||
| 566 | + | tests := []struct { | |
| 567 | + | name string | |
| 568 | + | scopes []string | |
| 569 | + | body string | |
| 570 | + | wantStatus int | |
| 571 | + | }{ | |
| 572 | + | {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK}, | |
| 573 | + | {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden}, | |
| 574 | + | {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden}, | |
| 575 | + | {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden}, | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | for _, tt := range tests { | |
| 579 | + | t.Run(tt.name, func(t *testing.T) { | |
| 580 | + | if tt.wantStatus == http.StatusOK { | |
| 581 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body)) | |
| 585 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 586 | + | rec := httptest.NewRecorder() | |
| 587 | + | ||
| 588 | + | handler.ServeHTTP(rec, req) | |
| 589 | + | ||
| 590 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 591 | + | }) | |
| 592 | + | } | |
| 593 | + | } | |
| 594 | + | ||
| 466 | 595 | // Status endpoint tests | |
| 467 | 596 | ||
| 468 | 597 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | |
| @@ -749,61 +878,6 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 749 | 878 | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 750 | 879 | ||
| 751 | 880 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |
| 752 | - | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |
| 753 | - | ||
| 754 | - | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 755 | - | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| 756 | - | rec := httptest.NewRecorder() | |
| 757 | - | ||
| 758 | - | handler.ServeHTTP(rec, req) | |
| 759 | - | ||
| 760 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 761 | - | }) | |
| 762 | - | } | |
| 763 | - | } | |
| 764 | - | ||
| 765 | - | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |
| 766 | - | configSvc := inmocks.NewMockConfigService(t) | |
| 767 | - | authSvc := inmocks.NewMockAuthService(t) | |
| 768 | - | containerSvc := inmocks.NewMockContainerService(t) | |
| 769 | - | secretSvc := inmocks.NewMockSecretService(t) | |
| 770 | - | registrySvc := inmocks.NewMockRegistryService(t) | |
| 771 | - | ||
| 772 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |
| 773 | - | ||
| 774 | - | tests := []struct { | |
| 775 | - | name string | |
| 776 | - | image string | |
| 777 | - | body string | |
| 778 | - | manifestName string | |
| 779 | - | manifestRef string | |
| 780 | - | }{ | |
| 781 | - | { | |
| 782 | - | name: "tagged image not found", | |
| 783 | - | image: "myapp:latest", | |
| 784 | - | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |
| 785 | - | manifestName: "myapp", | |
| 786 | - | manifestRef: "latest", | |
| 787 | - | }, | |
| 788 | - | { | |
| 789 | - | name: "digest image not found", | |
| 790 | - | image: "myapp@sha256:abc123", | |
| 791 | - | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |
| 792 | - | manifestName: "myapp", | |
| 793 | - | manifestRef: "sha256:abc123", | |
| 794 | - | }, | |
| 795 | - | { | |
| 796 | - | name: "implicit latest tag not found", | |
| 797 | - | image: "myapp", | |
| 798 | - | body: `{"domain": "app.example.com", "image": "myapp"}`, | |
| 799 | - | manifestName: "myapp", | |
| 800 | - | manifestRef: "latest", | |
| 801 | - | }, | |
| 802 | - | } | |
| 803 | - | ||
| 804 | - | for _, tt := range tests { | |
| 805 | - | t.Run(tt.name, func(t *testing.T) { | |
| 806 | - | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound) | |
| 807 | 881 | ||
| 808 | 882 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |
| 809 | 883 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -812,7 +886,6 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||
| 812 | 886 | handler.ServeHTTP(rec, req) | |
| 813 | 887 | ||
| 814 | 888 | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 815 | - | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |
| 816 | 889 | }) | |
| 817 | 890 | } | |
| 818 | 891 | } | |
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| 16 | "github.com/stretchr/testify/mock" | 16 | "github.com/stretchr/testify/mock" | ||
| 17 | 17 | ||||
| 18 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 18 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | ||
| 19 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| 20 | ) | 21 | ) | ||
| @@ -158,7 +159,6 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||||
| 158 | t.Run(tt.name, func(t *testing.T) { | 159 | t.Run(tt.name, func(t *testing.T) { | ||
| 159 | if tt.wantStatus == http.StatusCreated { | 160 | if tt.wantStatus == http.StatusCreated { | ||
| 160 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | 161 | configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | ||
| 161 | - | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe() | |||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | 164 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) | ||
| @@ -204,7 +204,6 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 204 | t.Run(tt.name, func(t *testing.T) { | 204 | t.Run(tt.name, func(t *testing.T) { | ||
| 205 | if tt.wantStatus == http.StatusOK { | 205 | if tt.wantStatus == http.StatusOK { | ||
| 206 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | 206 | configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() | ||
| 207 | - | registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe() | |||
| 208 | } | 207 | } | ||
| 209 | 208 | ||||
| 210 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | 209 | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) | ||
| @@ -218,6 +217,52 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 218 | } | 217 | } | ||
| 219 | } | 218 | } | ||
| 220 | 219 | ||||
| 220 | + | func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) { | |||
| 221 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 222 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 223 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 224 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 225 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 226 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:latest"} | |||
| 227 | + | ||||
| 228 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |||
| 229 | + | ||||
| 230 | + | configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() | |||
| 231 | + | ||||
| 232 | + | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) | |||
| 233 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 234 | + | rec := httptest.NewRecorder() | |||
| 235 | + | ||||
| 236 | + | handler.ServeHTTP(rec, req) | |||
| 237 | + | ||||
| 238 | + | assert.Equal(t, http.StatusCreated, rec.Code) | |||
| 239 | + | assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":false}`, rec.Body.String()) | |||
| 240 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestHandler_RoutesPut_AllowsRouteUpdateWithoutRegistryManifest(t *testing.T) { | |||
| 244 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 245 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 246 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 247 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 248 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 249 | + | route := domain.Route{Domain: "app.example.com", Image: "myapp:v2"} | |||
| 250 | + | ||||
| 251 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |||
| 252 | + | ||||
| 253 | + | configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() | |||
| 254 | + | ||||
| 255 | + | req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`)) | |||
| 256 | + | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 257 | + | rec := httptest.NewRecorder() | |||
| 258 | + | ||||
| 259 | + | handler.ServeHTTP(rec, req) | |||
| 260 | + | ||||
| 261 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 262 | + | assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String()) | |||
| 263 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 221 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | 266 | func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | ||
| 222 | configSvc := inmocks.NewMockConfigService(t) | 267 | configSvc := inmocks.NewMockConfigService(t) | ||
| 223 | authSvc := inmocks.NewMockAuthService(t) | 268 | authSvc := inmocks.NewMockAuthService(t) | ||
| @@ -463,6 +508,90 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||||
| 463 | assert.Equal(t, "deleted", response.Status) | 508 | assert.Equal(t, "deleted", response.Status) | ||
| 464 | } | 509 | } | ||
| 465 | 510 | ||||
| 511 | + | func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||
| 512 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 513 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 514 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 515 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 516 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 517 | + | ||||
| 518 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |||
| 519 | + | ||||
| 520 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | |||
| 521 | + | configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once() | |||
| 522 | + | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |||
| 523 | + | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |||
| 524 | + | ||||
| 525 | + | body, err := json.Marshal(dto.BootstrapRequest{ | |||
| 526 | + | Domain: "app.example.com", | |||
| 527 | + | Image: "myapp:latest", | |||
| 528 | + | Attachments: []string{"postgres:16"}, | |||
| 529 | + | Env: map[string]string{"APP_ENV": "prod"}, | |||
| 530 | + | AttachmentEnv: map[string]map[string]string{ | |||
| 531 | + | "postgres": {"POSTGRES_PASSWORD": "secret"}, | |||
| 532 | + | }, | |||
| 533 | + | }) | |||
| 534 | + | assert.NoError(t, err) | |||
| 535 | + | ||||
| 536 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) | |||
| 537 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |||
| 538 | + | rec := httptest.NewRecorder() | |||
| 539 | + | ||||
| 540 | + | handler.ServeHTTP(rec, req) | |||
| 541 | + | ||||
| 542 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 543 | + | registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) | |||
| 544 | + | ||||
| 545 | + | var response dto.BootstrapResponse | |||
| 546 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 547 | + | assert.Equal(t, "app.example.com", response.Domain) | |||
| 548 | + | assert.Equal(t, "myapp:latest", response.Image) | |||
| 549 | + | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |||
| 550 | + | assert.Equal(t, []dto.BootstrapStep{ | |||
| 551 | + | {Name: "route", Status: "configured"}, | |||
| 552 | + | {Name: "attachment:postgres:16", Status: "created"}, | |||
| 553 | + | {Name: "env", Status: "updated"}, | |||
| 554 | + | {Name: "attachment_env:postgres", Status: "updated"}, | |||
| 555 | + | }, response.Steps) | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||
| 559 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 560 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 561 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 562 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 563 | + | ||||
| 564 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 565 | + | ||||
| 566 | + | tests := []struct { | |||
| 567 | + | name string | |||
| 568 | + | scopes []string | |||
| 569 | + | body string | |||
| 570 | + | wantStatus int | |||
| 571 | + | }{ | |||
| 572 | + | {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK}, | |||
| 573 | + | {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden}, | |||
| 574 | + | {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden}, | |||
| 575 | + | {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden}, | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | for _, tt := range tests { | |||
| 579 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 580 | + | if tt.wantStatus == http.StatusOK { | |||
| 581 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body)) | |||
| 585 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 586 | + | rec := httptest.NewRecorder() | |||
| 587 | + | ||||
| 588 | + | handler.ServeHTTP(rec, req) | |||
| 589 | + | ||||
| 590 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 591 | + | }) | |||
| 592 | + | } | |||
| 593 | + | } | |||
| 594 | + | ||||
| 466 | // Status endpoint tests | 595 | // Status endpoint tests | ||
| 467 | 596 | ||||
| 468 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | 597 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | ||
| @@ -749,61 +878,6 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 749 | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | 878 | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | ||
| 750 | 879 | ||||
| 751 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | 880 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | ||
| 752 | - | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |||
| 753 | - | ||||
| 754 | - | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | |||
| 755 | - | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |||
| 756 | - | rec := httptest.NewRecorder() | |||
| 757 | - | ||||
| 758 | - | handler.ServeHTTP(rec, req) | |||
| 759 | - | ||||
| 760 | - | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 761 | - | }) | |||
| 762 | - | } | |||
| 763 | - | } | |||
| 764 | - | ||||
| 765 | - | func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||
| 766 | - | configSvc := inmocks.NewMockConfigService(t) | |||
| 767 | - | authSvc := inmocks.NewMockAuthService(t) | |||
| 768 | - | containerSvc := inmocks.NewMockContainerService(t) | |||
| 769 | - | secretSvc := inmocks.NewMockSecretService(t) | |||
| 770 | - | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 771 | - | ||||
| 772 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil) | |||
| 773 | - | ||||
| 774 | - | tests := []struct { | |||
| 775 | - | name string | |||
| 776 | - | image string | |||
| 777 | - | body string | |||
| 778 | - | manifestName string | |||
| 779 | - | manifestRef string | |||
| 780 | - | }{ | |||
| 781 | - | { | |||
| 782 | - | name: "tagged image not found", | |||
| 783 | - | image: "myapp:latest", | |||
| 784 | - | body: `{"domain": "app.example.com", "image": "myapp:latest"}`, | |||
| 785 | - | manifestName: "myapp", | |||
| 786 | - | manifestRef: "latest", | |||
| 787 | - | }, | |||
| 788 | - | { | |||
| 789 | - | name: "digest image not found", | |||
| 790 | - | image: "myapp@sha256:abc123", | |||
| 791 | - | body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`, | |||
| 792 | - | manifestName: "myapp", | |||
| 793 | - | manifestRef: "sha256:abc123", | |||
| 794 | - | }, | |||
| 795 | - | { | |||
| 796 | - | name: "implicit latest tag not found", | |||
| 797 | - | image: "myapp", | |||
| 798 | - | body: `{"domain": "app.example.com", "image": "myapp"}`, | |||
| 799 | - | manifestName: "myapp", | |||
| 800 | - | manifestRef: "latest", | |||
| 801 | - | }, | |||
| 802 | - | } | |||
| 803 | - | ||||
| 804 | - | for _, tt := range tests { | |||
| 805 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 806 | - | registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound) | |||
| 807 | 881 | ||||
| 808 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | 882 | req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) | ||
| 809 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 883 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -812,7 +886,6 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||||
| 812 | handler.ServeHTTP(rec, req) | 886 | handler.ServeHTTP(rec, req) | ||
| 813 | 887 | ||||
| 814 | assert.Equal(t, http.StatusBadRequest, rec.Code) | 888 | assert.Equal(t, http.StatusBadRequest, rec.Code) | ||
| 815 | - | assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image)) | |||
| 816 | }) | 889 | }) | ||
| 817 | } | 890 | } | ||
| 818 | } | 891 | } | ||
M internal/usecase/config/service.go +4 -0
| @@ -315,6 +315,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||
| 315 | 315 | if s.config.Routes == nil { | |
| 316 | 316 | s.config.Routes = make(map[string]string) | |
| 317 | 317 | } | |
| 318 | + | if existed && previousImage == route.Image { | |
| 319 | + | s.mu.Unlock() | |
| 320 | + | return nil | |
| 321 | + | } | |
| 318 | 322 | s.config.Routes[route.Domain] = route.Image | |
| 319 | 323 | s.mu.Unlock() | |
| 320 | 324 | ||
| @@ -315,6 +315,10 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |||||
| 315 | if s.config.Routes == nil { | 315 | if s.config.Routes == nil { | ||
| 316 | s.config.Routes = make(map[string]string) | 316 | s.config.Routes = make(map[string]string) | ||
| 317 | } | 317 | } | ||
| 318 | + | if existed && previousImage == route.Image { | |||
| 319 | + | s.mu.Unlock() | |||
| 320 | + | return nil | |||
| 321 | + | } | |||
| 318 | s.config.Routes[route.Domain] = route.Image | 322 | s.config.Routes[route.Domain] = route.Image | ||
| 319 | s.mu.Unlock() | 323 | s.mu.Unlock() | ||
| 320 | 324 | ||||
M internal/usecase/config/service_test.go +35 -0
| @@ -260,6 +260,41 @@ func TestService_AddRoute(t *testing.T) { | |||
| 260 | 260 | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | |
| 261 | 261 | }) | |
| 262 | 262 | ||
| 263 | + | t.Run("idempotent same domain and image saves once", func(t *testing.T) { | |
| 264 | + | tmpDir := t.TempDir() | |
| 265 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 266 | + | err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | ||
| 269 | + | v := viper.New() | |
| 270 | + | v.SetConfigFile(configFile) | |
| 271 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 272 | + | svc := NewService(v, eventBus) | |
| 273 | + | ctx := testContext() | |
| 274 | + | ||
| 275 | + | _ = svc.Load(ctx) | |
| 276 | + | ||
| 277 | + | route := domain.Route{ | |
| 278 | + | Domain: "new.example.com", | |
| 279 | + | Image: "newapp:latest", | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | err = svc.AddRoute(ctx, route) | |
| 283 | + | require.NoError(t, err) | |
| 284 | + | ||
| 285 | + | before, err := os.Stat(configFile) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | ||
| 288 | + | err = svc.AddRoute(ctx, route) | |
| 289 | + | require.NoError(t, err) | |
| 290 | + | ||
| 291 | + | after, err := os.Stat(configFile) | |
| 292 | + | require.NoError(t, err) | |
| 293 | + | ||
| 294 | + | assert.Equal(t, before.ModTime(), after.ModTime()) | |
| 295 | + | assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"]) | |
| 296 | + | }) | |
| 297 | + | ||
| 263 | 298 | t.Run("empty domain", func(t *testing.T) { | |
| 264 | 299 | v := viper.New() | |
| 265 | 300 | eventBus := mocks.NewMockEventPublisher(t) | |
| @@ -260,6 +260,41 @@ func TestService_AddRoute(t *testing.T) { | |||||
| 260 | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | 260 | assert.Equal(t, "newapp:latest", config.Routes["new.example.com"]) | ||
| 261 | }) | 261 | }) | ||
| 262 | 262 | ||||
| 263 | + | t.Run("idempotent same domain and image saves once", func(t *testing.T) { | |||
| 264 | + | tmpDir := t.TempDir() | |||
| 265 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 266 | + | err := os.WriteFile(configFile, []byte("[routes]\n"), 0600) | |||
| 267 | + | require.NoError(t, err) | |||
| 268 | + | ||||
| 269 | + | v := viper.New() | |||
| 270 | + | v.SetConfigFile(configFile) | |||
| 271 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 272 | + | svc := NewService(v, eventBus) | |||
| 273 | + | ctx := testContext() | |||
| 274 | + | ||||
| 275 | + | _ = svc.Load(ctx) | |||
| 276 | + | ||||
| 277 | + | route := domain.Route{ | |||
| 278 | + | Domain: "new.example.com", | |||
| 279 | + | Image: "newapp:latest", | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | err = svc.AddRoute(ctx, route) | |||
| 283 | + | require.NoError(t, err) | |||
| 284 | + | ||||
| 285 | + | before, err := os.Stat(configFile) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | ||||
| 288 | + | err = svc.AddRoute(ctx, route) | |||
| 289 | + | require.NoError(t, err) | |||
| 290 | + | ||||
| 291 | + | after, err := os.Stat(configFile) | |||
| 292 | + | require.NoError(t, err) | |||
| 293 | + | ||||
| 294 | + | assert.Equal(t, before.ModTime(), after.ModTime()) | |||
| 295 | + | assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"]) | |||
| 296 | + | }) | |||
| 297 | + | ||||
| 263 | t.Run("empty domain", func(t *testing.T) { | 298 | t.Run("empty domain", func(t *testing.T) { | ||
| 264 | v := viper.New() | 299 | v := viper.New() | ||
| 265 | eventBus := mocks.NewMockEventPublisher(t) | 300 | eventBus := mocks.NewMockEventPublisher(t) | ||