detached 4aeb35b

feat: add bootstrap command for first deploys

New 'gordon bootstrap <domain> <image>' command creates route, attachments,
and secrets in one operation. Works locally and remotely through the
ControlPlane abstraction. Supports --attachment, --env, and --attachment-env
flags. Idempotent: safe to rerun without duplicating config.

Adds POST /admin/bootstrap endpoint requiring routes:write + secrets:write.

Part of #98.
commit
4aeb35b9d15127eb4f411020ab021e0d81a3f272
author
brice · · 2026-03-13 20:43 UTC
parent
9609e08
signature
unsigned
10 files · +405 -0
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", "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", "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+}
A internal/adapters/in/cli/bootstrap.go +122 -0
@@ -0,0 +1,122 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sort"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+)
14+
15+func newBootstrapCmd() *cobra.Command {
16+ var attachments []string
17+ var envPairs []string
18+ var attachmentEnvPairs []string
19+ var configPath string
20+
21+ cmd := &cobra.Command{
22+ Use: "bootstrap <domain> <image>",
23+ Short: "Create a route, attachments, and secrets together",
24+ Args: cobra.ExactArgs(2),
25+ RunE: func(cmd *cobra.Command, args []string) error {
26+ ctx := context.Background()
27+ req := dto.BootstrapRequest{
28+ Domain: args[0],
29+ Image: args[1],
30+ Attachments: append([]string(nil), attachments...),
31+ Env: map[string]string{},
32+ AttachmentEnv: map[string]map[string]string{},
33+ }
34+
35+ for _, pair := range envPairs {
36+ parts := strings.SplitN(pair, "=", 2)
37+ if len(parts) != 2 {
38+ return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
39+ }
40+ req.Env[parts[0]] = parts[1]
41+ }
42+
43+ for _, pair := range attachmentEnvPairs {
44+ serviceAndKV := strings.SplitN(pair, ":", 2)
45+ if len(serviceAndKV) != 2 {
46+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
47+ }
48+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
49+ if len(keyValue) != 2 {
50+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
51+ }
52+ service := serviceAndKV[0]
53+ if req.AttachmentEnv[service] == nil {
54+ req.AttachmentEnv[service] = map[string]string{}
55+ }
56+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
57+ }
58+
59+ if len(req.Env) == 0 {
60+ req.Env = nil
61+ }
62+ if len(req.AttachmentEnv) == 0 {
63+ req.AttachmentEnv = nil
64+ }
65+
66+ handle, err := resolveControlPlane(configPath)
67+ if err != nil {
68+ return err
69+ }
70+ defer handle.close()
71+
72+ resp, err := handle.plane.Bootstrap(ctx, req)
73+ if resp != nil {
74+ if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
75+ return writeErr
76+ }
77+ }
78+ if err != nil {
79+ return fmt.Errorf("failed to bootstrap: %w", err)
80+ }
81+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Bootstrap complete")); err != nil {
82+ return err
83+ }
84+ return cliWriteLine(os.Stdout, fmt.Sprintf("Next: gordon push %s --build", req.Image))
85+ },
86+ }
87+
88+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
89+ cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
90+ cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
91+ cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
92+
93+ return cmd
94+}
95+
96+func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {
97+ out := cmd.OutOrStdout()
98+ if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
99+ return err
100+ }
101+ if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
102+ return err
103+ }
104+ if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
105+ return err
106+ }
107+ for _, step := range resp.Steps {
108+ if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
109+ return err
110+ }
111+ }
112+ if len(resp.Warnings) > 0 {
113+ sorted := append([]string(nil), resp.Warnings...)
114+ sort.Strings(sorted)
115+ for _, warning := range sorted {
116+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
117+ return err
118+ }
119+ }
120+ }
121+ return nil
122+}
@@ -0,0 +1,122 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sort"
8+ "strings"
9+
10+ "github.com/spf13/cobra"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+)
14+
15+func newBootstrapCmd() *cobra.Command {
16+ var attachments []string
17+ var envPairs []string
18+ var attachmentEnvPairs []string
19+ var configPath string
20+
21+ cmd := &cobra.Command{
22+ Use: "bootstrap <domain> <image>",
23+ Short: "Create a route, attachments, and secrets together",
24+ Args: cobra.ExactArgs(2),
25+ RunE: func(cmd *cobra.Command, args []string) error {
26+ ctx := context.Background()
27+ req := dto.BootstrapRequest{
28+ Domain: args[0],
29+ Image: args[1],
30+ Attachments: append([]string(nil), attachments...),
31+ Env: map[string]string{},
32+ AttachmentEnv: map[string]map[string]string{},
33+ }
34+
35+ for _, pair := range envPairs {
36+ parts := strings.SplitN(pair, "=", 2)
37+ if len(parts) != 2 {
38+ return fmt.Errorf("invalid --env value %q (expected KEY=VALUE)", pair)
39+ }
40+ req.Env[parts[0]] = parts[1]
41+ }
42+
43+ for _, pair := range attachmentEnvPairs {
44+ serviceAndKV := strings.SplitN(pair, ":", 2)
45+ if len(serviceAndKV) != 2 {
46+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
47+ }
48+ keyValue := strings.SplitN(serviceAndKV[1], "=", 2)
49+ if len(keyValue) != 2 {
50+ return fmt.Errorf("invalid --attachment-env value %q (expected service:KEY=VALUE)", pair)
51+ }
52+ service := serviceAndKV[0]
53+ if req.AttachmentEnv[service] == nil {
54+ req.AttachmentEnv[service] = map[string]string{}
55+ }
56+ req.AttachmentEnv[service][keyValue[0]] = keyValue[1]
57+ }
58+
59+ if len(req.Env) == 0 {
60+ req.Env = nil
61+ }
62+ if len(req.AttachmentEnv) == 0 {
63+ req.AttachmentEnv = nil
64+ }
65+
66+ handle, err := resolveControlPlane(configPath)
67+ if err != nil {
68+ return err
69+ }
70+ defer handle.close()
71+
72+ resp, err := handle.plane.Bootstrap(ctx, req)
73+ if resp != nil {
74+ if writeErr := printBootstrapSummary(cmd, resp); writeErr != nil {
75+ return writeErr
76+ }
77+ }
78+ if err != nil {
79+ return fmt.Errorf("failed to bootstrap: %w", err)
80+ }
81+ if err := cliWriteLine(os.Stdout, cliRenderSuccess("Bootstrap complete")); err != nil {
82+ return err
83+ }
84+ return cliWriteLine(os.Stdout, fmt.Sprintf("Next: gordon push %s --build", req.Image))
85+ },
86+ }
87+
88+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
89+ cmd.Flags().StringArrayVar(&attachments, "attachment", nil, "Attachment image (repeatable)")
90+ cmd.Flags().StringArrayVar(&envPairs, "env", nil, "Environment variable KEY=VALUE (repeatable)")
91+ cmd.Flags().StringArrayVar(&attachmentEnvPairs, "attachment-env", nil, "Attachment env service:KEY=VALUE (repeatable)")
92+
93+ return cmd
94+}
95+
96+func printBootstrapSummary(cmd *cobra.Command, resp *dto.BootstrapResponse) error {
97+ out := cmd.OutOrStdout()
98+ if err := cliWriteLine(out, cliRenderTitle("Bootstrap")); err != nil {
99+ return err
100+ }
101+ if err := cliWriteLine(out, cliRenderMeta("Domain:", resp.Domain)); err != nil {
102+ return err
103+ }
104+ if err := cliWriteLine(out, cliRenderMeta("Image:", resp.Image)); err != nil {
105+ return err
106+ }
107+ for _, step := range resp.Steps {
108+ if err := cliWriteLine(out, fmt.Sprintf("- %s: %s", step.Name, step.Status)); err != nil {
109+ return err
110+ }
111+ }
112+ if len(resp.Warnings) > 0 {
113+ sorted := append([]string(nil), resp.Warnings...)
114+ sort.Strings(sorted)
115+ for _, warning := range sorted {
116+ if err := cliWriteLine(out, cliRenderWarning(warning)); err != nil {
117+ return err
118+ }
119+ }
120+ }
121+ return nil
122+}
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -20,6 +20,7 @@ type ControlPlane interface {
2020 AddRoute(ctx context.Context, route domain.Route) error
2121 UpdateRoute(ctx context.Context, route domain.Route) error
2222 RemoveRoute(ctx context.Context, routeDomain string) error
23+ Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error)
2324
2425 ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error)
2526 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) error20 AddRoute(ctx context.Context, route domain.Route) error
21 UpdateRoute(ctx context.Context, route domain.Route) error21 UpdateRoute(ctx context.Context, route domain.Route) error
22 RemoveRoute(ctx context.Context, routeDomain string) error22 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) error26 SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error
M internal/adapters/in/cli/controlplane_local.go +62 -0
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "time"
78
@@ -122,6 +123,67 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string)
122123 return l.configSvc.RemoveRoute(ctx, routeDomain)
123124 }
124125
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+ if l.secretSvc == nil {
137+ return resp, fmt.Errorf("local secret service unavailable")
138+ }
139+
140+ addStep := func(name, status string) {
141+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
142+ }
143+
144+ err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
145+ switch {
146+ case err == nil:
147+ addStep("route", "created")
148+ case errors.Is(err, domain.ErrRouteExists):
149+ addStep("route", "noop")
150+ default:
151+ addStep("route", "failed")
152+ return resp, err
153+ }
154+
155+ for _, attachment := range req.Attachments {
156+ err = l.configSvc.AddAttachment(ctx, req.Domain, attachment)
157+ switch {
158+ case err == nil:
159+ addStep("attachment:"+attachment, "created")
160+ case errors.Is(err, domain.ErrAttachmentExists):
161+ addStep("attachment:"+attachment, "noop")
162+ default:
163+ addStep("attachment:"+attachment, "failed")
164+ return resp, err
165+ }
166+ }
167+
168+ if len(req.Env) > 0 {
169+ if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
170+ addStep("env", "failed")
171+ return resp, err
172+ }
173+ addStep("env", "updated")
174+ }
175+
176+ for service, env := range req.AttachmentEnv {
177+ if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
178+ addStep("attachment_env:"+service, "failed")
179+ return resp, err
180+ }
181+ addStep("attachment_env:"+service, "updated")
182+ }
183+
184+ return resp, nil
185+}
186+
125187 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
126188 if l.secretSvc == nil {
127189 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,67 @@ 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+ if l.secretSvc == nil {
137+ return resp, fmt.Errorf("local secret service unavailable")
138+ }
139+
140+ addStep := func(name, status string) {
141+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
142+ }
143+
144+ err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
145+ switch {
146+ case err == nil:
147+ addStep("route", "created")
148+ case errors.Is(err, domain.ErrRouteExists):
149+ addStep("route", "noop")
150+ default:
151+ addStep("route", "failed")
152+ return resp, err
153+ }
154+
155+ for _, attachment := range req.Attachments {
156+ err = l.configSvc.AddAttachment(ctx, req.Domain, attachment)
157+ switch {
158+ case err == nil:
159+ addStep("attachment:"+attachment, "created")
160+ case errors.Is(err, domain.ErrAttachmentExists):
161+ addStep("attachment:"+attachment, "noop")
162+ default:
163+ addStep("attachment:"+attachment, "failed")
164+ return resp, err
165+ }
166+ }
167+
168+ if len(req.Env) > 0 {
169+ if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
170+ addStep("env", "failed")
171+ return resp, err
172+ }
173+ addStep("env", "updated")
174+ }
175+
176+ for service, env := range req.AttachmentEnv {
177+ if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
178+ addStep("attachment_env:"+service, "failed")
179+ return resp, err
180+ }
181+ addStep("attachment_env:"+service, "updated")
182+ }
183+
184+ return resp, nil
185+}
186+
125 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {187 func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
126 if l.secretSvc == nil {188 if l.secretSvc == nil {
127 return nil, fmt.Errorf("local secret service unavailable")189 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
4444 return r.client.RemoveRoute(ctx, routeDomain)
4545 }
4646
47+func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
48+ return r.client.Bootstrap(ctx, req)
49+}
50+
4751 func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
4852 return r.client.ListSecretsWithAttachments(ctx, secretDomain)
4953 }
@@ -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/push_test.go +4 -0
@@ -52,6 +52,10 @@ func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string)
5252 panic("unexpected call")
5353 }
5454
55+func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
56+ panic("unexpected call")
57+}
58+
5559 func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
5660 panic("unexpected call")
5761 }
@@ -52,6 +52,10 @@ func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string)
52 panic("unexpected call")52 panic("unexpected call")
53 }53 }
54 54
55+func (c *resolveFromImageTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
56+ panic("unexpected call")
57+}
58+
55 func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {59 func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) {
56 panic("unexpected call")60 panic("unexpected call")
57 }61 }
M internal/adapters/in/cli/remote/client.go +12 -0
@@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
407407 return parseResponse(resp, nil)
408408 }
409409
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+
410422 // Secrets API
411423
412424 // 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 API422 // 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/root.go +4 -0
@@ -125,6 +125,10 @@ Commands are organized by where they run:
125125 imagesCmd.GroupID = groupManage
126126 rootCmd.AddCommand(imagesCmd)
127127
128+ bootstrapCmd := newBootstrapCmd()
129+ bootstrapCmd.GroupID = groupManage
130+ rootCmd.AddCommand(bootstrapCmd)
131+
128132 // Client-only commands (no server needed)
129133 remotesCmd := newRemotesCmd()
130134 remotesCmd.GroupID = groupClient
@@ -125,6 +125,10 @@ Commands are organized by where they run:
125 imagesCmd.GroupID = groupManage125 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 = groupClient134 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/handler.go +88 -0
@@ -201,6 +201,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201201 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
202202 "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
203203 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
204+ "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
204205 "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
205206 "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
206207 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
@@ -277,6 +278,93 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat
277278 })
278279 }
279280
281+func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
282+ if r.Method != http.MethodPost {
283+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
284+ return
285+ }
286+
287+ ctx := r.Context()
288+ log := zerowrap.FromCtx(ctx)
289+
290+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
291+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
292+ return
293+ }
294+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
295+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
296+ return
297+ }
298+
299+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
300+
301+ var req dto.BootstrapRequest
302+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
303+ log.Warn().Err(err).Msg("invalid bootstrap JSON")
304+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
305+ return
306+ }
307+
308+ resp := dto.BootstrapResponse{
309+ Domain: req.Domain,
310+ Image: req.Image,
311+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
312+ }
313+ addStep := func(name, status string) {
314+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
315+ }
316+
317+ err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
318+ switch {
319+ case err == nil:
320+ addStep("route", "created")
321+ case errors.Is(err, domain.ErrRouteExists):
322+ addStep("route", "noop")
323+ default:
324+ addStep("route", "failed")
325+ log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
326+ h.sendJSON(w, http.StatusInternalServerError, resp)
327+ return
328+ }
329+
330+ for _, attachment := range req.Attachments {
331+ err = h.configSvc.AddAttachment(ctx, req.Domain, attachment)
332+ switch {
333+ case err == nil:
334+ addStep("attachment:"+attachment, "created")
335+ case errors.Is(err, domain.ErrAttachmentExists):
336+ addStep("attachment:"+attachment, "noop")
337+ default:
338+ addStep("attachment:"+attachment, "failed")
339+ log.Error().Err(err).Str("domain", req.Domain).Str("image", attachment).Msg("failed to bootstrap attachment")
340+ h.sendJSON(w, http.StatusInternalServerError, resp)
341+ return
342+ }
343+ }
344+
345+ if len(req.Env) > 0 {
346+ if err := h.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
347+ addStep("env", "failed")
348+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
349+ h.sendJSON(w, http.StatusInternalServerError, resp)
350+ return
351+ }
352+ addStep("env", "updated")
353+ }
354+
355+ for service, env := range req.AttachmentEnv {
356+ if err := h.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
357+ addStep("attachment_env:"+service, "failed")
358+ log.Error().Err(err).Str("domain", req.Domain).Str("service", service).Msg("failed to bootstrap attachment env")
359+ h.sendJSON(w, http.StatusInternalServerError, resp)
360+ return
361+ }
362+ addStep("attachment_env:"+service, "updated")
363+ }
364+
365+ h.sendJSON(w, http.StatusOK, resp)
366+}
367+
280368 // sendJSON sends a JSON response.
281369 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
282370 w.Header().Set("Content-Type", "application/json")
@@ -201,6 +201,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201 "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },201 "/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) },202 "/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) },203 "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
204+ "/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) },205 "/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) },206 "/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) },207 "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
@@ -277,6 +278,93 @@ func (h *Handler) handleDeployIntent(w http.ResponseWriter, r *http.Request, pat
277 })278 })
278 }279 }
279 280
281+func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
282+ if r.Method != http.MethodPost {
283+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
284+ return
285+ }
286+
287+ ctx := r.Context()
288+ log := zerowrap.FromCtx(ctx)
289+
290+ if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionWrite) {
291+ h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:write")
292+ return
293+ }
294+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
295+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
296+ return
297+ }
298+
299+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
300+
301+ var req dto.BootstrapRequest
302+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
303+ log.Warn().Err(err).Msg("invalid bootstrap JSON")
304+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
305+ return
306+ }
307+
308+ resp := dto.BootstrapResponse{
309+ Domain: req.Domain,
310+ Image: req.Image,
311+ Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
312+ }
313+ addStep := func(name, status string) {
314+ resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
315+ }
316+
317+ err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
318+ switch {
319+ case err == nil:
320+ addStep("route", "created")
321+ case errors.Is(err, domain.ErrRouteExists):
322+ addStep("route", "noop")
323+ default:
324+ addStep("route", "failed")
325+ log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
326+ h.sendJSON(w, http.StatusInternalServerError, resp)
327+ return
328+ }
329+
330+ for _, attachment := range req.Attachments {
331+ err = h.configSvc.AddAttachment(ctx, req.Domain, attachment)
332+ switch {
333+ case err == nil:
334+ addStep("attachment:"+attachment, "created")
335+ case errors.Is(err, domain.ErrAttachmentExists):
336+ addStep("attachment:"+attachment, "noop")
337+ default:
338+ addStep("attachment:"+attachment, "failed")
339+ log.Error().Err(err).Str("domain", req.Domain).Str("image", attachment).Msg("failed to bootstrap attachment")
340+ h.sendJSON(w, http.StatusInternalServerError, resp)
341+ return
342+ }
343+ }
344+
345+ if len(req.Env) > 0 {
346+ if err := h.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
347+ addStep("env", "failed")
348+ log.Error().Err(err).Str("domain", req.Domain).Msg("failed to bootstrap env")
349+ h.sendJSON(w, http.StatusInternalServerError, resp)
350+ return
351+ }
352+ addStep("env", "updated")
353+ }
354+
355+ for service, env := range req.AttachmentEnv {
356+ if err := h.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
357+ addStep("attachment_env:"+service, "failed")
358+ log.Error().Err(err).Str("domain", req.Domain).Str("service", service).Msg("failed to bootstrap attachment env")
359+ h.sendJSON(w, http.StatusInternalServerError, resp)
360+ return
361+ }
362+ addStep("attachment_env:"+service, "updated")
363+ }
364+
365+ h.sendJSON(w, http.StatusOK, resp)
366+}
367+
280 // sendJSON sends a JSON response.368 // sendJSON sends a JSON response.
281 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {369 func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) {
282 w.Header().Set("Content-Type", "application/json")370 w.Header().Set("Content-Type", "application/json")
M internal/adapters/in/http/admin/handler_test.go +83 -0
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/assert"
1616 "github.com/stretchr/testify/mock"
1717
18+ "github.com/bnema/gordon/internal/adapters/dto"
1819 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1920 "github.com/bnema/gordon/internal/domain"
2021 )
@@ -507,6 +508,88 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
507508 assert.Equal(t, "deleted", response.Status)
508509 }
509510
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"))
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: "created"},
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+ wantStatus int
570+ }{
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},
574+ }
575+
576+ for _, tt := range tests {
577+ t.Run(tt.name, func(t *testing.T) {
578+ if tt.wantStatus == http.StatusOK {
579+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
580+ }
581+
582+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
583+ req = req.WithContext(ctxWithScopes(tt.scopes...))
584+ rec := httptest.NewRecorder()
585+
586+ handler.ServeHTTP(rec, req)
587+
588+ assert.Equal(t, tt.wantStatus, rec.Code)
589+ })
590+ }
591+}
592+
510593 // Status endpoint tests
511594
512595 func TestHandler_Status_RequiresReadScope(t *testing.T) {
@@ -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 )
@@ -507,6 +508,88 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
507 assert.Equal(t, "deleted", response.Status)508 assert.Equal(t, "deleted", response.Status)
508 }509 }
509 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"))
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: "created"},
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+ wantStatus int
570+ }{
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},
574+ }
575+
576+ for _, tt := range tests {
577+ t.Run(tt.name, func(t *testing.T) {
578+ if tt.wantStatus == http.StatusOK {
579+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
580+ }
581+
582+ req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
583+ req = req.WithContext(ctxWithScopes(tt.scopes...))
584+ rec := httptest.NewRecorder()
585+
586+ handler.ServeHTTP(rec, req)
587+
588+ assert.Equal(t, tt.wantStatus, rec.Code)
589+ })
590+ }
591+}
592+
510 // Status endpoint tests593 // Status endpoint tests
511 594
512 func TestHandler_Status_RequiresReadScope(t *testing.T) {595 func TestHandler_Status_RequiresReadScope(t *testing.T) {

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