fix: tighten bootstrap auth, docs accuracy, and dead code cleanup
Bootstrap now requires config:write when attachments are present and secrets:write only when env is provided. Removed dead ErrRouteExists branches from bootstrap handlers. Route step reports 'configured' for idempotent AddRoute. Fixed docs to not claim bootstrap pushes or deploys. Part of #98.
- commit
- 3dcd86a8d8165a32916b8c71127d49f56d3046d0
- author
- brice · · 2026-03-13 20:54 UTC
- parent
- 1e2458b
- signature
- unsigned
8 files · +54 -41
M docs/cli/bootstrap.md +11 -8
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Bootstrap Command | |
| 2 | 2 | ||
| 3 | - | Create or update a route, then push and deploy an image in one command. | |
| 3 | + | Create or update route configuration, attachments, and secrets in one command. | |
| 4 | 4 | ||
| 5 | 5 | ## gordon bootstrap | |
| 6 | 6 | ||
| @@ -28,22 +28,25 @@ gordon bootstrap <domain> <image> [options] | |||
| 28 | 28 | ||
| 29 | 29 | ### Description | |
| 30 | 30 | ||
| 31 | - | `gordon bootstrap` is the recommended first-deploy workflow. | |
| 31 | + | `gordon bootstrap` is the recommended first-step setup workflow. | |
| 32 | 32 | ||
| 33 | 33 | - It creates the route when it does not exist. | |
| 34 | 34 | - It updates the route when it already exists. | |
| 35 | 35 | - It can attach services and set environment variables as part of the same command. | |
| 36 | - | - It then pushes and deploys the image for the target route. | |
| 36 | + | - It does not push or deploy the image. | |
| 37 | 37 | ||
| 38 | - | Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. | |
| 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 | 39 | ||
| 40 | 40 | ### Examples | |
| 41 | 41 | ||
| 42 | 42 | ```bash | |
| 43 | - | # First deploy | |
| 43 | + | # First-time route setup | |
| 44 | 44 | gordon bootstrap app.example.com myapp:latest | |
| 45 | 45 | ||
| 46 | - | # First deploy with a database attachment and environment variable | |
| 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 | |
| 47 | 50 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 48 | 51 | ||
| 49 | 52 | # Multiple attachments and environment variables | |
| @@ -59,8 +62,8 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c | |||
| 59 | 62 | ||
| 60 | 63 | ### Notes | |
| 61 | 64 | ||
| 62 | - | - `gordon bootstrap` is idempotent for route configuration: rerunning it updates the route instead of failing. | |
| 63 | - | - Use `gordon push` for later image-only deploys when the route already exists. | |
| 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. | |
| 64 | 67 | ||
| 65 | 68 | ## Related | |
| 66 | 69 | ||
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Bootstrap Command | 1 | # Bootstrap Command | ||
| 2 | 2 | ||||
| 3 | - | Create or update a route, then push and deploy an image in one command. | 3 | + | Create or update route configuration, attachments, and secrets in one command. |
| 4 | 4 | ||||
| 5 | ## gordon bootstrap | 5 | ## gordon bootstrap | ||
| 6 | 6 | ||||
| @@ -28,22 +28,25 @@ gordon bootstrap <domain> <image> [options] | |||||
| 28 | 28 | ||||
| 29 | ### Description | 29 | ### Description | ||
| 30 | 30 | ||||
| 31 | - | `gordon bootstrap` is the recommended first-deploy workflow. | 31 | + | `gordon bootstrap` is the recommended first-step setup workflow. |
| 32 | 32 | ||||
| 33 | - It creates the route when it does not exist. | 33 | - It creates the route when it does not exist. | ||
| 34 | - It updates the route when it already exists. | 34 | - It updates the route when it already exists. | ||
| 35 | - It can attach services and set environment variables as part of the same command. | 35 | - It can attach services and set environment variables as part of the same command. | ||
| 36 | - | - It then pushes and deploys the image for the target route. | 36 | + | - It does not push or deploy the image. |
| 37 | 37 | ||||
| 38 | - | Unlike `gordon push`, `gordon bootstrap` does not require the route to exist first. | 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 | 39 | ||||
| 40 | ### Examples | 40 | ### Examples | ||
| 41 | 41 | ||||
| 42 | ```bash | 42 | ```bash | ||
| 43 | - | # First deploy | 43 | + | # First-time route setup |
| 44 | gordon bootstrap app.example.com myapp:latest | 44 | gordon bootstrap app.example.com myapp:latest | ||
| 45 | 45 | ||||
| 46 | - | # First deploy with a database attachment and environment variable | 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 | |||
| 47 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | 50 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | ||
| 48 | 51 | ||||
| 49 | # Multiple attachments and environment variables | 52 | # Multiple attachments and environment variables | ||
| @@ -59,8 +62,8 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c | |||||
| 59 | 62 | ||||
| 60 | ### Notes | 63 | ### Notes | ||
| 61 | 64 | ||||
| 62 | - | - `gordon bootstrap` is idempotent for route configuration: rerunning it updates the route instead of failing. | 65 | + | - `gordon bootstrap` is idempotent for route configuration: rerunning it re-applies config instead of failing. |
| 63 | - | - Use `gordon push` for later image-only deploys when the route already exists. | 66 | + | - Run `gordon push` after bootstrap to upload and deploy the image. |
| 64 | 67 | ||||
| 65 | ## Related | 68 | ## Related | ||
| 66 | 69 | ||||
M docs/cli/index.md +5 -2
| @@ -28,7 +28,7 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 28 | 28 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 29 | 29 | | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) | | |
| 30 | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | |
| 31 | - | | `gordon bootstrap` | Create or update a route, then push and deploy an image | [bootstrap](./bootstrap.md) | | |
| 31 | + | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | | |
| 32 | 32 | | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) | | |
| 33 | 33 | | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) | | |
| 34 | 34 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| @@ -61,9 +61,12 @@ gordon deploy myapp.example.com | |||
| 61 | 61 | # Restart a running container | |
| 62 | 62 | gordon restart myapp.example.com | |
| 63 | 63 | ||
| 64 | - | # First deploy for a route | |
| 64 | + | # First-time route setup | |
| 65 | 65 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | |
| 66 | 66 | ||
| 67 | + | # Then push and deploy | |
| 68 | + | gordon push app.example.com --build --no-confirm | |
| 69 | + | ||
| 67 | 70 | # Push an image and deploy | |
| 68 | 71 | gordon push myapp.example.com --build | |
| 69 | 72 | ||
| @@ -28,7 +28,7 @@ Management commands run locally through in-process services by default. Add `--r | |||||
| 28 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | 28 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | ||
| 29 | | `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) | | ||
| 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | 30 | | `gordon restart` | Restart a running container | [restart](./restart.md) | | ||
| 31 | - | | `gordon bootstrap` | Create or update a route, then push and deploy an image | [bootstrap](./bootstrap.md) | | 31 | + | | `gordon bootstrap` | Configure a route, attachments, and secrets for an app | [bootstrap](./bootstrap.md) | |
| 32 | | `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) | | ||
| 33 | | `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) | | ||
| 34 | | `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) | | ||
| @@ -61,9 +61,12 @@ gordon deploy myapp.example.com | |||||
| 61 | # Restart a running container | 61 | # Restart a running container | ||
| 62 | gordon restart myapp.example.com | 62 | gordon restart myapp.example.com | ||
| 63 | 63 | ||||
| 64 | - | # First deploy for a route | 64 | + | # First-time route setup |
| 65 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | 65 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | ||
| 66 | 66 | ||||
| 67 | + | # Then push and deploy | |||
| 68 | + | gordon push app.example.com --build --no-confirm | |||
| 69 | + | ||||
| 67 | # Push an image and deploy | 70 | # Push an image and deploy | ||
| 68 | gordon push myapp.example.com --build | 71 | gordon push myapp.example.com --build | ||
| 69 | 72 | ||||
M docs/cli/push.md +1 -1
| @@ -35,7 +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, prefer `gordon bootstrap`, which creates or updates the route and then pushes and deploys the image. | |
| 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. | |
| 39 | 39 | - The registry and repository are derived from the route image on the server. | |
| 40 | 40 | - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available, | |
| 41 | 41 | then falls back to `git describe --tags --dirty` (for example | |
| @@ -35,7 +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, prefer `gordon bootstrap`, which creates or updates the route and then pushes and deploys the image. | 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. |
| 39 | - 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. | ||
| 40 | - 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, | ||
| 41 | then falls back to `git describe --tags --dirty` (for example | 41 | then falls back to `git describe --tags --dirty` (for example | ||
M docs/getting-started.md +7 -3
| @@ -143,16 +143,20 @@ 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 | - | # Recommended first deploy path | |
| 146 | + | # Recommended first-time setup | |
| 147 | 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 bootstrap` creates or updates the route, applies requested attachments | |
| 153 | - | and environment variables, then pushes and deploys the image. | |
| 155 | + | - `gordon bootstrap` creates or updates the route, applies requested attachments, | |
| 156 | + | and stores environment variables. | |
| 154 | 157 | - This is the recommended path for first deploys because it does not require the | |
| 155 | 158 | route to exist ahead of time. | |
| 159 | + | - Run `gordon push` separately after bootstrap to build, upload, and deploy the image. | |
| 156 | 160 | ||
| 157 | 161 | If the route already exists and you only need to push a new image version, use: | |
| 158 | 162 | ||
| @@ -143,16 +143,20 @@ 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 | - | # Recommended first deploy path | 146 | + | # Recommended first-time setup |
| 147 | gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production | 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 bootstrap` creates or updates the route, applies requested attachments | 155 | + | - `gordon bootstrap` creates or updates the route, applies requested attachments, |
| 153 | - | and environment variables, then pushes and deploys the image. | 156 | + | and stores environment variables. |
| 154 | - This is the recommended path for first deploys because it does not require the | 157 | - This is the recommended path for first deploys because it does not require the | ||
| 155 | route to exist ahead of time. | 158 | route to exist ahead of time. | ||
| 159 | + | - Run `gordon push` separately after bootstrap to build, upload, and deploy the image. | |||
| 156 | 160 | ||||
| 157 | If the route already exists and you only need to push a new image version, use: | 161 | If the route already exists and you only need to push a new image version, use: | ||
| 158 | 162 | ||||
M internal/adapters/in/cli/controlplane_local.go +1 -3
| @@ -144,9 +144,7 @@ func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequ | |||
| 144 | 144 | err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |
| 145 | 145 | switch { | |
| 146 | 146 | case err == nil: | |
| 147 | - | addStep("route", "created") | |
| 148 | - | case errors.Is(err, domain.ErrRouteExists): | |
| 149 | - | addStep("route", "noop") | |
| 147 | + | addStep("route", "configured") | |
| 150 | 148 | default: | |
| 151 | 149 | addStep("route", "failed") | |
| 152 | 150 | return resp, err | |
| @@ -144,9 +144,7 @@ func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequ | |||||
| 144 | err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | 144 | err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | ||
| 145 | switch { | 145 | switch { | ||
| 146 | case err == nil: | 146 | case err == nil: | ||
| 147 | - | addStep("route", "created") | 147 | + | addStep("route", "configured") |
| 148 | - | case errors.Is(err, domain.ErrRouteExists): | |||
| 149 | - | addStep("route", "noop") | |||
| 150 | default: | 148 | default: | ||
| 151 | addStep("route", "failed") | 149 | addStep("route", "failed") | ||
| 152 | return resp, err | 150 | return resp, err | ||
M internal/adapters/in/http/admin/bootstrap_test.go +11 -11
| @@ -45,7 +45,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |||
| 45 | 45 | require.NoError(t, err) | |
| 46 | 46 | ||
| 47 | 47 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 48 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 48 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 49 | 49 | rec := httptest.NewRecorder() | |
| 50 | 50 | ||
| 51 | 51 | handler.ServeHTTP(rec, req) | |
| @@ -59,7 +59,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |||
| 59 | 59 | assert.Equal(t, payload.Image, response.Image) | |
| 60 | 60 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |
| 61 | 61 | assert.Equal(t, []dto.BootstrapStep{ | |
| 62 | - | {Name: "route", Status: "created"}, | |
| 62 | + | {Name: "route", Status: "configured"}, | |
| 63 | 63 | {Name: "attachment:postgres:16", Status: "created"}, | |
| 64 | 64 | {Name: "env", Status: "updated"}, | |
| 65 | 65 | {Name: "attachment_env:postgres", Status: "updated"}, | |
| @@ -89,7 +89,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||
| 89 | 89 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 90 | 90 | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| 91 | 91 | ||
| 92 | - | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(domain.ErrRouteExists).Once() | |
| 92 | + | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once() | |
| 93 | 93 | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once() | |
| 94 | 94 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 95 | 95 | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | |
| @@ -99,7 +99,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||
| 99 | 99 | ||
| 100 | 100 | for i := range 2 { | |
| 101 | 101 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 102 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 102 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 103 | 103 | rec := httptest.NewRecorder() | |
| 104 | 104 | ||
| 105 | 105 | handler.ServeHTTP(rec, req) | |
| @@ -113,14 +113,14 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||
| 113 | 113 | require.Len(t, response.Steps, 4) | |
| 114 | 114 | if i == 0 { | |
| 115 | 115 | assert.Equal(t, []dto.BootstrapStep{ | |
| 116 | - | {Name: "route", Status: "created"}, | |
| 116 | + | {Name: "route", Status: "configured"}, | |
| 117 | 117 | {Name: "attachment:postgres:16", Status: "created"}, | |
| 118 | 118 | {Name: "env", Status: "updated"}, | |
| 119 | 119 | {Name: "attachment_env:postgres", Status: "updated"}, | |
| 120 | 120 | }, response.Steps) | |
| 121 | 121 | } else { | |
| 122 | 122 | assert.Equal(t, []dto.BootstrapStep{ | |
| 123 | - | {Name: "route", Status: "noop"}, | |
| 123 | + | {Name: "route", Status: "configured"}, | |
| 124 | 124 | {Name: "attachment:postgres:16", Status: "noop"}, | |
| 125 | 125 | {Name: "env", Status: "updated"}, | |
| 126 | 126 | {Name: "attachment_env:postgres", Status: "updated"}, | |
| @@ -141,7 +141,7 @@ func TestBootstrap_MissingDomain(t *testing.T) { | |||
| 141 | 141 | require.NoError(t, err) | |
| 142 | 142 | ||
| 143 | 143 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 144 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 144 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 145 | 145 | rec := httptest.NewRecorder() | |
| 146 | 146 | ||
| 147 | 147 | handler.ServeHTTP(rec, req) | |
| @@ -162,7 +162,7 @@ func TestBootstrap_MissingImage(t *testing.T) { | |||
| 162 | 162 | require.NoError(t, err) | |
| 163 | 163 | ||
| 164 | 164 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 165 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 165 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 166 | 166 | rec := httptest.NewRecorder() | |
| 167 | 167 | ||
| 168 | 168 | handler.ServeHTTP(rec, req) | |
| @@ -202,7 +202,7 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||
| 202 | 202 | require.NoError(t, err) | |
| 203 | 203 | ||
| 204 | 204 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 205 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 205 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 206 | 206 | rec := httptest.NewRecorder() | |
| 207 | 207 | ||
| 208 | 208 | handler.ServeHTTP(rec, req) | |
| @@ -234,7 +234,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||
| 234 | 234 | require.NoError(t, err) | |
| 235 | 235 | ||
| 236 | 236 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | |
| 237 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 237 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) | |
| 238 | 238 | rec := httptest.NewRecorder() | |
| 239 | 239 | ||
| 240 | 240 | handler.ServeHTTP(rec, req) | |
| @@ -244,7 +244,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||
| 244 | 244 | var response dto.BootstrapResponse | |
| 245 | 245 | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 246 | 246 | assert.Equal(t, []dto.BootstrapStep{ | |
| 247 | - | {Name: "route", Status: "created"}, | |
| 247 | + | {Name: "route", Status: "configured"}, | |
| 248 | 248 | {Name: "attachment:postgres:16", Status: "failed"}, | |
| 249 | 249 | }, response.Steps) | |
| 250 | 250 | secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything) | |
| @@ -45,7 +45,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |||||
| 45 | require.NoError(t, err) | 45 | require.NoError(t, err) | ||
| 46 | 46 | ||||
| 47 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 47 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 48 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 48 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) |
| 49 | rec := httptest.NewRecorder() | 49 | rec := httptest.NewRecorder() | ||
| 50 | 50 | ||||
| 51 | handler.ServeHTTP(rec, req) | 51 | handler.ServeHTTP(rec, req) | ||
| @@ -59,7 +59,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) { | |||||
| 59 | assert.Equal(t, payload.Image, response.Image) | 59 | assert.Equal(t, payload.Image, response.Image) | ||
| 60 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | 60 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | ||
| 61 | assert.Equal(t, []dto.BootstrapStep{ | 61 | assert.Equal(t, []dto.BootstrapStep{ | ||
| 62 | - | {Name: "route", Status: "created"}, | 62 | + | {Name: "route", Status: "configured"}, |
| 63 | {Name: "attachment:postgres:16", Status: "created"}, | 63 | {Name: "attachment:postgres:16", Status: "created"}, | ||
| 64 | {Name: "env", Status: "updated"}, | 64 | {Name: "env", Status: "updated"}, | ||
| 65 | {Name: "attachment_env:postgres", Status: "updated"}, | 65 | {Name: "attachment_env:postgres", Status: "updated"}, | ||
| @@ -89,7 +89,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||||
| 89 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).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() | 90 | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | ||
| 91 | 91 | ||||
| 92 | - | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(domain.ErrRouteExists).Once() | 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() | 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() | 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() | 95 | secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() | ||
| @@ -99,7 +99,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||||
| 99 | 99 | ||||
| 100 | for i := range 2 { | 100 | for i := range 2 { | ||
| 101 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 101 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 102 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 102 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) |
| 103 | rec := httptest.NewRecorder() | 103 | rec := httptest.NewRecorder() | ||
| 104 | 104 | ||||
| 105 | handler.ServeHTTP(rec, req) | 105 | handler.ServeHTTP(rec, req) | ||
| @@ -113,14 +113,14 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) { | |||||
| 113 | require.Len(t, response.Steps, 4) | 113 | require.Len(t, response.Steps, 4) | ||
| 114 | if i == 0 { | 114 | if i == 0 { | ||
| 115 | assert.Equal(t, []dto.BootstrapStep{ | 115 | assert.Equal(t, []dto.BootstrapStep{ | ||
| 116 | - | {Name: "route", Status: "created"}, | 116 | + | {Name: "route", Status: "configured"}, |
| 117 | {Name: "attachment:postgres:16", Status: "created"}, | 117 | {Name: "attachment:postgres:16", Status: "created"}, | ||
| 118 | {Name: "env", Status: "updated"}, | 118 | {Name: "env", Status: "updated"}, | ||
| 119 | {Name: "attachment_env:postgres", Status: "updated"}, | 119 | {Name: "attachment_env:postgres", Status: "updated"}, | ||
| 120 | }, response.Steps) | 120 | }, response.Steps) | ||
| 121 | } else { | 121 | } else { | ||
| 122 | assert.Equal(t, []dto.BootstrapStep{ | 122 | assert.Equal(t, []dto.BootstrapStep{ | ||
| 123 | - | {Name: "route", Status: "noop"}, | 123 | + | {Name: "route", Status: "configured"}, |
| 124 | {Name: "attachment:postgres:16", Status: "noop"}, | 124 | {Name: "attachment:postgres:16", Status: "noop"}, | ||
| 125 | {Name: "env", Status: "updated"}, | 125 | {Name: "env", Status: "updated"}, | ||
| 126 | {Name: "attachment_env:postgres", Status: "updated"}, | 126 | {Name: "attachment_env:postgres", Status: "updated"}, | ||
| @@ -141,7 +141,7 @@ func TestBootstrap_MissingDomain(t *testing.T) { | |||||
| 141 | require.NoError(t, err) | 141 | require.NoError(t, err) | ||
| 142 | 142 | ||||
| 143 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 143 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 144 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 144 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) |
| 145 | rec := httptest.NewRecorder() | 145 | rec := httptest.NewRecorder() | ||
| 146 | 146 | ||||
| 147 | handler.ServeHTTP(rec, req) | 147 | handler.ServeHTTP(rec, req) | ||
| @@ -162,7 +162,7 @@ func TestBootstrap_MissingImage(t *testing.T) { | |||||
| 162 | require.NoError(t, err) | 162 | require.NoError(t, err) | ||
| 163 | 163 | ||||
| 164 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 164 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 165 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 165 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) |
| 166 | rec := httptest.NewRecorder() | 166 | rec := httptest.NewRecorder() | ||
| 167 | 167 | ||||
| 168 | handler.ServeHTTP(rec, req) | 168 | handler.ServeHTTP(rec, req) | ||
| @@ -202,7 +202,7 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||||
| 202 | require.NoError(t, err) | 202 | require.NoError(t, err) | ||
| 203 | 203 | ||||
| 204 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 204 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 205 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 205 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) |
| 206 | rec := httptest.NewRecorder() | 206 | rec := httptest.NewRecorder() | ||
| 207 | 207 | ||||
| 208 | handler.ServeHTTP(rec, req) | 208 | handler.ServeHTTP(rec, req) | ||
| @@ -234,7 +234,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||||
| 234 | require.NoError(t, err) | 234 | require.NoError(t, err) | ||
| 235 | 235 | ||||
| 236 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | 236 | req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body)) | ||
| 237 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 237 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write")) |
| 238 | rec := httptest.NewRecorder() | 238 | rec := httptest.NewRecorder() | ||
| 239 | 239 | ||||
| 240 | handler.ServeHTTP(rec, req) | 240 | handler.ServeHTTP(rec, req) | ||
| @@ -244,7 +244,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||||
| 244 | var response dto.BootstrapResponse | 244 | var response dto.BootstrapResponse | ||
| 245 | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | 245 | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | ||
| 246 | assert.Equal(t, []dto.BootstrapStep{ | 246 | assert.Equal(t, []dto.BootstrapStep{ | ||
| 247 | - | {Name: "route", Status: "created"}, | 247 | + | {Name: "route", Status: "configured"}, |
| 248 | {Name: "attachment:postgres:16", Status: "failed"}, | 248 | {Name: "attachment:postgres:16", Status: "failed"}, | ||
| 249 | }, response.Steps) | 249 | }, response.Steps) | ||
| 250 | secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything) | 250 | secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything) | ||
M internal/adapters/in/http/admin/handler.go +10 -7
| @@ -291,10 +291,6 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 291 | 291 | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | |
| 292 | 292 | return | |
| 293 | 293 | } | |
| 294 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 295 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 296 | - | return | |
| 297 | - | } | |
| 298 | 294 | ||
| 299 | 295 | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 300 | 296 | ||
| @@ -315,6 +311,15 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 315 | 311 | return | |
| 316 | 312 | } | |
| 317 | 313 | ||
| 314 | + | if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |
| 315 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |
| 316 | + | return | |
| 317 | + | } | |
| 318 | + | if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 319 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | ||
| 318 | 323 | resp := dto.BootstrapResponse{ | |
| 319 | 324 | Domain: req.Domain, | |
| 320 | 325 | Image: req.Image, | |
| @@ -327,9 +332,7 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 327 | 332 | err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | |
| 328 | 333 | switch { | |
| 329 | 334 | case err == nil: | |
| 330 | - | addStep("route", "created") | |
| 331 | - | case errors.Is(err, domain.ErrRouteExists): | |
| 332 | - | addStep("route", "noop") | |
| 335 | + | addStep("route", "configured") | |
| 333 | 336 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | |
| 334 | 337 | addStep("route", "failed") | |
| 335 | 338 | h.sendError(w, http.StatusBadRequest, err.Error()) | |
| @@ -291,10 +291,6 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||||
| 291 | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | 291 | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write") | ||
| 292 | return | 292 | return | ||
| 293 | } | 293 | } | ||
| 294 | - | if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 295 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 296 | - | return | |||
| 297 | - | } | |||
| 298 | 294 | ||||
| 299 | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | 295 | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | ||
| 300 | 296 | ||||
| @@ -315,6 +311,15 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||||
| 315 | return | 311 | return | ||
| 316 | } | 312 | } | ||
| 317 | 313 | ||||
| 314 | + | if (len(req.Env) > 0 || len(req.AttachmentEnv) > 0) && !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) { | |||
| 315 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write") | |||
| 316 | + | return | |||
| 317 | + | } | |||
| 318 | + | if len(req.Attachments) > 0 && !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 319 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 320 | + | return | |||
| 321 | + | } | |||
| 322 | + | ||||
| 318 | resp := dto.BootstrapResponse{ | 323 | resp := dto.BootstrapResponse{ | ||
| 319 | Domain: req.Domain, | 324 | Domain: req.Domain, | ||
| 320 | Image: req.Image, | 325 | Image: req.Image, | ||
| @@ -327,9 +332,7 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||||
| 327 | err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | 332 | err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image}) | ||
| 328 | switch { | 333 | switch { | ||
| 329 | case err == nil: | 334 | case err == nil: | ||
| 330 | - | addStep("route", "created") | 335 | + | addStep("route", "configured") |
| 331 | - | case errors.Is(err, domain.ErrRouteExists): | |||
| 332 | - | addStep("route", "noop") | |||
| 333 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | 336 | case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty): | ||
| 334 | addStep("route", "failed") | 337 | addStep("route", "failed") | ||
| 335 | h.sendError(w, http.StatusBadRequest, err.Error()) | 338 | h.sendError(w, http.StatusBadRequest, err.Error()) | ||
M internal/adapters/in/http/admin/handler_test.go +8 -6
| @@ -534,7 +534,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||
| 534 | 534 | assert.NoError(t, err) | |
| 535 | 535 | ||
| 536 | 536 | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) | |
| 537 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | |
| 537 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) | |
| 538 | 538 | rec := httptest.NewRecorder() | |
| 539 | 539 | ||
| 540 | 540 | handler.ServeHTTP(rec, req) | |
| @@ -548,7 +548,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||
| 548 | 548 | assert.Equal(t, "myapp:latest", response.Image) | |
| 549 | 549 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | |
| 550 | 550 | assert.Equal(t, []dto.BootstrapStep{ | |
| 551 | - | {Name: "route", Status: "created"}, | |
| 551 | + | {Name: "route", Status: "configured"}, | |
| 552 | 552 | {Name: "attachment:postgres:16", Status: "created"}, | |
| 553 | 553 | {Name: "env", Status: "updated"}, | |
| 554 | 554 | {Name: "attachment_env:postgres", Status: "updated"}, | |
| @@ -566,11 +566,13 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||
| 566 | 566 | tests := []struct { | |
| 567 | 567 | name string | |
| 568 | 568 | scopes []string | |
| 569 | + | body string | |
| 569 | 570 | wantStatus int | |
| 570 | 571 | }{ | |
| 571 | - | {name: "both permissions", scopes: []string{"admin:routes:write", "admin:secrets:write"}, wantStatus: http.StatusOK}, | |
| 572 | - | {name: "missing secrets permission", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusForbidden}, | |
| 573 | - | {name: "missing routes permission", scopes: []string{"admin:secrets:write"}, wantStatus: http.StatusForbidden}, | |
| 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}, | |
| 574 | 576 | } | |
| 575 | 577 | ||
| 576 | 578 | for _, tt := range tests { | |
| @@ -579,7 +581,7 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||
| 579 | 581 | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | |
| 580 | 582 | } | |
| 581 | 583 | ||
| 582 | - | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) | |
| 584 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body)) | |
| 583 | 585 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 584 | 586 | rec := httptest.NewRecorder() | |
| 585 | 587 | ||
| @@ -534,7 +534,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||||
| 534 | assert.NoError(t, err) | 534 | assert.NoError(t, err) | ||
| 535 | 535 | ||||
| 536 | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) | 536 | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) | ||
| 537 | - | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write")) | 537 | + | req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) |
| 538 | rec := httptest.NewRecorder() | 538 | rec := httptest.NewRecorder() | ||
| 539 | 539 | ||||
| 540 | handler.ServeHTTP(rec, req) | 540 | handler.ServeHTTP(rec, req) | ||
| @@ -548,7 +548,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||||
| 548 | assert.Equal(t, "myapp:latest", response.Image) | 548 | assert.Equal(t, "myapp:latest", response.Image) | ||
| 549 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | 549 | assert.Equal(t, "push myapp:latest to trigger deployment", response.Next) | ||
| 550 | assert.Equal(t, []dto.BootstrapStep{ | 550 | assert.Equal(t, []dto.BootstrapStep{ | ||
| 551 | - | {Name: "route", Status: "created"}, | 551 | + | {Name: "route", Status: "configured"}, |
| 552 | {Name: "attachment:postgres:16", Status: "created"}, | 552 | {Name: "attachment:postgres:16", Status: "created"}, | ||
| 553 | {Name: "env", Status: "updated"}, | 553 | {Name: "env", Status: "updated"}, | ||
| 554 | {Name: "attachment_env:postgres", Status: "updated"}, | 554 | {Name: "attachment_env:postgres", Status: "updated"}, | ||
| @@ -566,11 +566,13 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||||
| 566 | tests := []struct { | 566 | tests := []struct { | ||
| 567 | name string | 567 | name string | ||
| 568 | scopes []string | 568 | scopes []string | ||
| 569 | + | body string | |||
| 569 | wantStatus int | 570 | wantStatus int | ||
| 570 | }{ | 571 | }{ | ||
| 571 | - | {name: "both permissions", scopes: []string{"admin:routes:write", "admin:secrets:write"}, wantStatus: http.StatusOK}, | 572 | + | {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK}, |
| 572 | - | {name: "missing secrets permission", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusForbidden}, | 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}, |
| 573 | - | {name: "missing routes permission", scopes: []string{"admin:secrets:write"}, 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}, | |||
| 574 | } | 576 | } | ||
| 575 | 577 | ||||
| 576 | for _, tt := range tests { | 578 | for _, tt := range tests { | ||
| @@ -579,7 +581,7 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||||
| 579 | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | 581 | configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once() | ||
| 580 | } | 582 | } | ||
| 581 | 583 | ||||
| 582 | - | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) | 584 | + | req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body)) |
| 583 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | 585 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | ||
| 584 | rec := httptest.NewRecorder() | 586 | rec := httptest.NewRecorder() | ||
| 585 | 587 | ||||