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 { | |||
| 20 | 20 | AddRoute(ctx context.Context, route domain.Route) error | |
| 21 | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 22 | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | |
| 23 | + | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |
| 23 | 24 | ||
| 24 | 25 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | |
| 25 | 26 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | |
| @@ -20,6 +20,7 @@ type ControlPlane interface { | |||||
| 20 | AddRoute(ctx context.Context, route domain.Route) error | 20 | AddRoute(ctx context.Context, route domain.Route) error | ||
| 21 | UpdateRoute(ctx context.Context, route domain.Route) error | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | ||
| 22 | RemoveRoute(ctx context.Context, routeDomain string) error | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | ||
| 23 | + | Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) | |||
| 23 | 24 | ||||
| 24 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | 25 | ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) | ||
| 25 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | 26 | SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error | ||
M internal/adapters/in/cli/controlplane_local.go +62 -0
| @@ -2,6 +2,7 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "time" | |
| 7 | 8 | ||
| @@ -122,6 +123,67 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) | |||
| 122 | 123 | return l.configSvc.RemoveRoute(ctx, routeDomain) | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 127 | + | resp := &dto.BootstrapResponse{ | |
| 128 | + | Domain: req.Domain, | |
| 129 | + | Image: req.Image, | |
| 130 | + | Next: fmt.Sprintf("push %s to trigger deployment", req.Image), | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | if l.configSvc == nil { | |
| 134 | + | return resp, fmt.Errorf("local config service unavailable") | |
| 135 | + | } | |
| 136 | + | 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 | 187 | func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 126 | 188 | if l.secretSvc == nil { | |
| 127 | 189 | 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 | |||
| 44 | 44 | return r.client.RemoveRoute(ctx, routeDomain) | |
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | + | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 48 | + | return r.client.Bootstrap(ctx, req) | |
| 49 | + | } | |
| 50 | + | ||
| 47 | 51 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | |
| 48 | 52 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | |
| 49 | 53 | } | |
| @@ -44,6 +44,10 @@ func (r *remoteControlPlane) RemoveRoute(ctx context.Context, routeDomain string | |||||
| 44 | return r.client.RemoveRoute(ctx, routeDomain) | 44 | return r.client.RemoveRoute(ctx, routeDomain) | ||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | + | func (r *remoteControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 48 | + | return r.client.Bootstrap(ctx, req) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 47 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | 51 | func (r *remoteControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) { | ||
| 48 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | 52 | return r.client.ListSecretsWithAttachments(ctx, secretDomain) | ||
| 49 | } | 53 | } | ||
M internal/adapters/in/cli/push_test.go +4 -0
| @@ -52,6 +52,10 @@ func (c *resolveFromImageTestControlPlane) RemoveRoute(context.Context, string) | |||
| 52 | 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 | 59 | func (c *resolveFromImageTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |
| 56 | 60 | panic("unexpected call") | |
| 57 | 61 | } | |
| @@ -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 { | |||
| 407 | 407 | return parseResponse(resp, nil) | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | + | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 411 | + | resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req) | |
| 412 | + | if err != nil { | |
| 413 | + | return nil, err | |
| 414 | + | } | |
| 415 | + | var result dto.BootstrapResponse | |
| 416 | + | if err := parseResponse(resp, &result); err != nil { | |
| 417 | + | return nil, err | |
| 418 | + | } | |
| 419 | + | return &result, nil | |
| 420 | + | } | |
| 421 | + | ||
| 410 | 422 | // Secrets API | |
| 411 | 423 | ||
| 412 | 424 | // AttachmentSecrets represents secrets for an attachment container. | |
| @@ -407,6 +407,18 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error { | |||||
| 407 | return parseResponse(resp, nil) | 407 | return parseResponse(resp, nil) | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | + | func (c *Client) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |||
| 411 | + | resp, err := c.request(ctx, http.MethodPost, "/bootstrap", req) | |||
| 412 | + | if err != nil { | |||
| 413 | + | return nil, err | |||
| 414 | + | } | |||
| 415 | + | var result dto.BootstrapResponse | |||
| 416 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 417 | + | return nil, err | |||
| 418 | + | } | |||
| 419 | + | return &result, nil | |||
| 420 | + | } | |||
| 421 | + | ||||
| 410 | // Secrets API | 422 | // Secrets API | ||
| 411 | 423 | ||||
| 412 | // AttachmentSecrets represents secrets for an attachment container. | 424 | // AttachmentSecrets represents secrets for an attachment container. | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -125,6 +125,10 @@ Commands are organized by where they run: | |||
| 125 | 125 | imagesCmd.GroupID = groupManage | |
| 126 | 126 | rootCmd.AddCommand(imagesCmd) | |
| 127 | 127 | ||
| 128 | + | bootstrapCmd := newBootstrapCmd() | |
| 129 | + | bootstrapCmd.GroupID = groupManage | |
| 130 | + | rootCmd.AddCommand(bootstrapCmd) | |
| 131 | + | ||
| 128 | 132 | // Client-only commands (no server needed) | |
| 129 | 133 | remotesCmd := newRemotesCmd() | |
| 130 | 134 | remotesCmd.GroupID = groupClient | |
| @@ -125,6 +125,10 @@ Commands are organized by where they run: | |||||
| 125 | imagesCmd.GroupID = groupManage | 125 | imagesCmd.GroupID = groupManage | ||
| 126 | rootCmd.AddCommand(imagesCmd) | 126 | rootCmd.AddCommand(imagesCmd) | ||
| 127 | 127 | ||||
| 128 | + | bootstrapCmd := newBootstrapCmd() | |||
| 129 | + | bootstrapCmd.GroupID = groupManage | |||
| 130 | + | rootCmd.AddCommand(bootstrapCmd) | |||
| 131 | + | ||||
| 128 | // Client-only commands (no server needed) | 132 | // Client-only commands (no server needed) | ||
| 129 | remotesCmd := newRemotesCmd() | 133 | remotesCmd := newRemotesCmd() | ||
| 130 | remotesCmd.GroupID = groupClient | 134 | remotesCmd.GroupID = groupClient | ||
M internal/adapters/in/http/admin/handler.go +88 -0
| @@ -201,6 +201,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 201 | 201 | "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) }, | |
| 202 | 202 | "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) }, | |
| 203 | 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 | 205 | "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) }, | |
| 205 | 206 | "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) }, | |
| 206 | 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 | 368 | // sendJSON sends a JSON response. | |
| 281 | 369 | func (h *Handler) sendJSON(w http.ResponseWriter, status int, data any) { | |
| 282 | 370 | 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 ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/mock" | |
| 17 | 17 | ||
| 18 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 18 | 19 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 19 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 20 | 21 | ) | |
| @@ -507,6 +508,88 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||
| 507 | 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 | 593 | // Status endpoint tests | |
| 511 | 594 | ||
| 512 | 595 | 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 tests | 593 | // Status endpoint tests | ||
| 511 | 594 | ||||
| 512 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | 595 | func TestHandler_Status_RequiresReadScope(t *testing.T) { | ||