detached 3dcd86a

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 @@
11 # Bootstrap Command
22
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.
44
55 ## gordon bootstrap
66
@@ -28,22 +28,25 @@ gordon bootstrap <domain> <image> [options]
2828
2929 ### Description
3030
31-`gordon bootstrap` is the recommended first-deploy workflow.
31+`gordon bootstrap` is the recommended first-step setup workflow.
3232
3333 - It creates the route when it does not exist.
3434 - It updates the route when it already exists.
3535 - 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.
3737
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.
3939
4040 ### Examples
4141
4242 ```bash
43-# First deploy
43+# First-time route setup
4444 gordon bootstrap app.example.com myapp:latest
4545
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
4750 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
4851
4952 # Multiple attachments and environment variables
@@ -59,8 +62,8 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c
5962
6063 ### Notes
6164
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.
6467
6568 ## Related
6669
@@ -1,6 +1,6 @@
1 # Bootstrap Command1 # 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 bootstrap5 ## gordon bootstrap
6 6
@@ -28,22 +28,25 @@ gordon bootstrap <domain> <image> [options]
28 28
29 ### Description29 ### 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 ### Examples40 ### Examples
41 41
42 ```bash42 ```bash
43-# First deploy43+# First-time route setup
44 gordon bootstrap app.example.com myapp:latest44 gordon bootstrap app.example.com myapp:latest
45 45
46-# First deploy with a database attachment and environment variable46+# 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=production50 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
48 51
49 # Multiple attachments and environment variables52 # Multiple attachments and environment variables
@@ -59,8 +62,8 @@ gordon bootstrap app.example.com myapp:latest --remote https://gordon.mydomain.c
59 62
60 ### Notes63 ### 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 ## Related68 ## 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
2828 | `gordon secrets` | Manage secrets | [secrets](./secrets.md) |
2929 | `gordon deploy` | Manually deploy or redeploy a route | [serve](./serve.md#gordon-deploy) |
3030 | `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) |
3232 | `gordon push` | Tag, push, and optionally deploy an image | [push](./push.md) |
3333 | `gordon rollback` | Roll back to a previous image tag | [rollback](./rollback.md) |
3434 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
@@ -61,9 +61,12 @@ gordon deploy myapp.example.com
6161 # Restart a running container
6262 gordon restart myapp.example.com
6363
64-# First deploy for a route
64+# First-time route setup
6565 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
6666
67+# Then push and deploy
68+gordon push app.example.com --build --no-confirm
69+
6770 # Push an image and deploy
6871 gordon push myapp.example.com --build
6972
@@ -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 container61 # Restart a running container
62 gordon restart myapp.example.com62 gordon restart myapp.example.com
63 63
64-# First deploy for a route64+# First-time route setup
65 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production65 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 deploy70 # Push an image and deploy
68 gordon push myapp.example.com --build71 gordon push myapp.example.com --build
69 72
M docs/cli/push.md +1 -1
@@ -35,7 +35,7 @@ gordon push <domain> [options]
3535 `gordon push` resolves the route image for `<domain>`, tags the image for the
3636 Gordon registry, pushes it, and optionally deploys it.
3737
38-- For first deploys, 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.
3939 - The registry and repository are derived from the route image on the server.
4040 - The version tag defaults to a CI tag ref (like `refs/tags/v1.2.3`) when available,
4141 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 the35 `gordon push` resolves the route image for `<domain>`, tags the image for the
36 Gordon registry, pushes it, and optionally deploys it.36 Gordon registry, pushes it, and optionally deploys it.
37 37
38-- For first deploys, 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 example41 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:
143143 gordon remotes add prod https://gordon.mydomain.com --token <your-token>
144144 gordon remotes use prod
145145
146-# Recommended first deploy path
146+# Recommended first-time setup
147147 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production
148+
149+# Then build, push, and deploy
150+gordon push app.example.com --build --no-confirm
148151 ```
149152
150153 What this command does:
151154
152-- `gordon 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.
154157 - This is the recommended path for first deploys because it does not require the
155158 route to exist ahead of time.
159+- Run `gordon push` separately after bootstrap to build, upload, and deploy the image.
156160
157161 If the route already exists and you only need to push a new image version, use:
158162
@@ -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 prod144 gordon remotes use prod
145 145
146-# Recommended first deploy path146+# Recommended first-time setup
147 gordon bootstrap app.example.com myapp:latest --attachment postgres:18 --env APP_ENV=production147 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 attachments155+- `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 the157 - 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
144144 err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
145145 switch {
146146 case err == nil:
147- addStep("route", "created")
148- case errors.Is(err, domain.ErrRouteExists):
149- addStep("route", "noop")
147+ addStep("route", "configured")
150148 default:
151149 addStep("route", "failed")
152150 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, err150 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) {
4545 require.NoError(t, err)
4646
4747 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"))
4949 rec := httptest.NewRecorder()
5050
5151 handler.ServeHTTP(rec, req)
@@ -59,7 +59,7 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
5959 assert.Equal(t, payload.Image, response.Image)
6060 assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
6161 assert.Equal(t, []dto.BootstrapStep{
62- {Name: "route", Status: "created"},
62+ {Name: "route", Status: "configured"},
6363 {Name: "attachment:postgres:16", Status: "created"},
6464 {Name: "env", Status: "updated"},
6565 {Name: "attachment_env:postgres", Status: "updated"},
@@ -89,7 +89,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
8989 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
9090 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
9191
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()
9393 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
9494 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
9595 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) {
9999
100100 for i := range 2 {
101101 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"))
103103 rec := httptest.NewRecorder()
104104
105105 handler.ServeHTTP(rec, req)
@@ -113,14 +113,14 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
113113 require.Len(t, response.Steps, 4)
114114 if i == 0 {
115115 assert.Equal(t, []dto.BootstrapStep{
116- {Name: "route", Status: "created"},
116+ {Name: "route", Status: "configured"},
117117 {Name: "attachment:postgres:16", Status: "created"},
118118 {Name: "env", Status: "updated"},
119119 {Name: "attachment_env:postgres", Status: "updated"},
120120 }, response.Steps)
121121 } else {
122122 assert.Equal(t, []dto.BootstrapStep{
123- {Name: "route", Status: "noop"},
123+ {Name: "route", Status: "configured"},
124124 {Name: "attachment:postgres:16", Status: "noop"},
125125 {Name: "env", Status: "updated"},
126126 {Name: "attachment_env:postgres", Status: "updated"},
@@ -141,7 +141,7 @@ func TestBootstrap_MissingDomain(t *testing.T) {
141141 require.NoError(t, err)
142142
143143 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"))
145145 rec := httptest.NewRecorder()
146146
147147 handler.ServeHTTP(rec, req)
@@ -162,7 +162,7 @@ func TestBootstrap_MissingImage(t *testing.T) {
162162 require.NoError(t, err)
163163
164164 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"))
166166 rec := httptest.NewRecorder()
167167
168168 handler.ServeHTTP(rec, req)
@@ -202,7 +202,7 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
202202 require.NoError(t, err)
203203
204204 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"))
206206 rec := httptest.NewRecorder()
207207
208208 handler.ServeHTTP(rec, req)
@@ -234,7 +234,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
234234 require.NoError(t, err)
235235
236236 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"))
238238 rec := httptest.NewRecorder()
239239
240240 handler.ServeHTTP(rec, req)
@@ -244,7 +244,7 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
244244 var response dto.BootstrapResponse
245245 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
246246 assert.Equal(t, []dto.BootstrapStep{
247- {Name: "route", Status: "created"},
247+ {Name: "route", Status: "configured"},
248248 {Name: "attachment:postgres:16", Status: "failed"},
249249 }, response.Steps)
250250 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.BootstrapResponse244 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) {
291291 h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
292292 return
293293 }
294- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
295- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
296- return
297- }
298294
299295 r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
300296
@@ -315,6 +311,15 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
315311 return
316312 }
317313
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+
318323 resp := dto.BootstrapResponse{
319324 Domain: req.Domain,
320325 Image: req.Image,
@@ -327,9 +332,7 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
327332 err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
328333 switch {
329334 case err == nil:
330- addStep("route", "created")
331- case errors.Is(err, domain.ErrRouteExists):
332- addStep("route", "noop")
335+ addStep("route", "configured")
333336 case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
334337 addStep("route", "failed")
335338 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 return292 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 return311 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) {
534534 assert.NoError(t, err)
535535
536536 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"))
538538 rec := httptest.NewRecorder()
539539
540540 handler.ServeHTTP(rec, req)
@@ -548,7 +548,7 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
548548 assert.Equal(t, "myapp:latest", response.Image)
549549 assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
550550 assert.Equal(t, []dto.BootstrapStep{
551- {Name: "route", Status: "created"},
551+ {Name: "route", Status: "configured"},
552552 {Name: "attachment:postgres:16", Status: "created"},
553553 {Name: "env", Status: "updated"},
554554 {Name: "attachment_env:postgres", Status: "updated"},
@@ -566,11 +566,13 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
566566 tests := []struct {
567567 name string
568568 scopes []string
569+ body string
569570 wantStatus int
570571 }{
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},
574576 }
575577
576578 for _, tt := range tests {
@@ -579,7 +581,7 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
579581 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
580582 }
581583
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))
583585 req = req.WithContext(ctxWithScopes(tt.scopes...))
584586 rec := httptest.NewRecorder()
585587
@@ -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 string567 name string
568 scopes []string568 scopes []string
569+ body string
569 wantStatus int570 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close