1package cli
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
9 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
11
12 "github.com/spf13/cobra"
13)
14
15// newSecretsCmd creates the secrets command group.
16func newSecretsCmd() *cobra.Command {
17 cmd := &cobra.Command{
18 Use: "secrets",
19 Short: "Manage secrets",
20 Long: `Manage secrets (environment variables) for routes and attachments.
21
22Secrets are stored per-domain and injected into containers as environment variables.
23Use --attachment to target attachment containers (databases, caches, etc.).
24
25When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
26these commands operate on the remote server.`,
27 }
28
29 cmd.AddCommand(newSecretsListCmd())
30 cmd.AddCommand(newSecretsSetCmd())
31 cmd.AddCommand(newSecretsRemoveCmd())
32
33 return cmd
34}
35
36// newSecretsListCmd creates the secrets list command.
37func newSecretsListCmd() *cobra.Command {
38 var jsonOut bool
39
40 cmd := &cobra.Command{
41 Use: "list <domain>",
42 Short: "List secrets for a domain",
43 Long: `List all secret keys configured for a domain.
44
45Note: Only secret keys are shown, not values (for security).
46Attachment secrets (for services like databases) are also displayed.
47
48Examples:
49 gordon secrets list app.mydomain.com
50 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
51 Args: cobra.ExactArgs(1),
52 RunE: func(cmd *cobra.Command, args []string) error {
53 return runSecretsListCmd(cmd, args, jsonOut)
54 },
55 }
56
57 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
58
59 return cmd
60}
61
62// runSecretsListCmd executes the secrets list command.
63func runSecretsListCmd(cmd *cobra.Command, args []string, jsonOut bool) error {
64 ctx := cmd.Context()
65 secretDomain := args[0]
66
67 handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
68 if err != nil {
69 return err
70 }
71 defer handle.close()
72
73 keys, attachments, err := fetchSecretsWithAttachments(ctx, handle.plane, secretDomain)
74 if err != nil {
75 return err
76 }
77
78 totalSecrets := len(keys)
79 for _, att := range attachments {
80 totalSecrets += len(att.Keys)
81 }
82
83 if totalSecrets == 0 {
84 if jsonOut {
85 return writeJSON(cmd.OutOrStdout(), map[string]any{
86 "domain": secretDomain,
87 "keys": []string{},
88 "attachments": []any{},
89 })
90 }
91 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
92 return nil
93 }
94
95 if jsonOut {
96 if attachments == nil {
97 attachments = []remote.AttachmentSecrets{}
98 }
99 return writeJSON(cmd.OutOrStdout(), map[string]any{
100 "domain": secretDomain,
101 "keys": keys,
102 "attachments": attachments,
103 })
104 }
105
106 title := fmt.Sprintf("Secrets for %s", secretDomain)
107 if !handle.isRemote {
108 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
109 }
110 fmt.Println(styles.Theme.Title.Render(title))
111 fmt.Println()
112
113 rows := buildSecretsTableRows(keys, attachments)
114
115 table := components.NewTable(
116 components.WithColumns([]components.TableColumn{
117 {Title: "Key", Width: 45},
118 {Title: "Value", Width: 10},
119 }),
120 components.WithRows(rows),
121 )
122
123 fmt.Println(table.View())
124 return nil
125}
126
127// fetchSecretsWithAttachments retrieves secrets from the selected control plane.
128func fetchSecretsWithAttachments(ctx context.Context, cp ControlPlane, secretDomain string) ([]string, []remote.AttachmentSecrets, error) {
129 result, err := cp.ListSecretsWithAttachments(ctx, secretDomain)
130 if err != nil {
131 return nil, nil, fmt.Errorf("failed to list secrets: %w", err)
132 }
133 return result.Keys, result.Attachments, nil
134}
135
136// buildSecretsTableRows builds table rows with tree structure for attachments.
137func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string {
138 var rows [][]string
139
140 // Domain secrets first
141 for _, key := range keys {
142 rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
143 }
144
145 // Attachment secrets with tree structure
146 for i, att := range attachments {
147 isLastAttachment := i == len(attachments)-1
148 rows = append(rows, buildAttachmentRows(att, isLastAttachment)...)
149 }
150
151 return rows
152}
153
154// buildAttachmentRows builds table rows for a single attachment with tree structure.
155func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string {
156 var rows [][]string
157
158 // Attachment header with tree prefix
159 prefix := styles.IconTreeBranch + styles.IconTreeLine
160 if isLastAttachment {
161 prefix = styles.IconTreeLast + styles.IconTreeLine
162 }
163
164 serviceName := extractServiceName(att.Service)
165 attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName)))
166 rows = append(rows, []string{attachmentHeader, ""})
167
168 // Keys for this attachment with nested tree structure
169 for j, key := range att.Keys {
170 isLastKey := j == len(att.Keys)-1
171 keyPrefix := getKeyPrefix(isLastAttachment, isLastKey)
172 rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")})
173 }
174
175 return rows
176}
177
178// extractServiceName extracts a short service name from a container name.
179// e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres"
180func extractServiceName(containerName string) string {
181 if !strings.HasPrefix(containerName, "gordon-") {
182 return containerName
183 }
184
185 parts := strings.SplitN(containerName, "-", 2)
186 if len(parts) <= 1 {
187 return containerName
188 }
189
190 serviceName := parts[1]
191 allParts := strings.Split(serviceName, "-")
192
193 // Handle service names based on the number of segments explicitly
194 if len(allParts) < 2 {
195 // No additional segments; use the service name as-is.
196 return serviceName
197 }
198 if len(allParts) == 2 {
199 // Exactly two segments; the service name is already in the desired form.
200 return strings.Join(allParts, "-")
201 }
202
203 // More than two segments: take the last two as the short service name (e.g., "gitea-postgres").
204 return strings.Join(allParts[len(allParts)-2:], "-")
205}
206
207// getKeyPrefix returns the tree prefix for a key based on its position.
208func getKeyPrefix(isLastAttachment, isLastKey bool) string {
209 if isLastAttachment {
210 // Parent is last, use space continuation
211 if isLastKey {
212 return " " + styles.IconTreeLast + styles.IconTreeLine
213 }
214 return " " + styles.IconTreeBranch + styles.IconTreeLine
215 }
216 // Parent has siblings, use vertical line continuation
217 if isLastKey {
218 return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine
219 }
220 return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
221}
222
223// newSecretsSetCmd creates the secrets set command.
224func newSecretsSetCmd() *cobra.Command {
225 var attachment string
226
227 cmd := &cobra.Command{
228 Use: "set <domain> <KEY=value>...",
229 Short: "Set secrets for a domain or attachment",
230 Long: `Set one or more secrets for a domain or an attachment container.
231
232Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
233
234Use --attachment to target an attachment service (e.g., postgres, redis) instead
235of the main domain container.
236
237Examples:
238 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
239 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
240 gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
241 gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
242 Args: cobra.MinimumNArgs(2),
243 RunE: func(cmd *cobra.Command, args []string) error {
244 ctx := cmd.Context()
245 secretDomain := args[0]
246 pairs := args[1:]
247
248 // Parse KEY=value pairs
249 secrets := make(map[string]string)
250 for _, pair := range pairs {
251 parts := strings.SplitN(pair, "=", 2)
252 if len(parts) != 2 {
253 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
254 }
255 secrets[parts[0]] = parts[1]
256 }
257
258 handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
259 if err != nil {
260 return err
261 }
262 defer handle.close()
263 if attachment != "" {
264 if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
265 return fmt.Errorf("failed to set secrets: %w", err)
266 }
267 } else {
268 if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
269 return fmt.Errorf("failed to set secrets: %w", err)
270 }
271 }
272
273 target := secretDomain
274 if attachment != "" {
275 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
276 }
277
278 if len(secrets) == 1 {
279 for key := range secrets {
280 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
281 }
282 } else {
283 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
284 }
285
286 return nil
287 },
288 }
289
290 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
291
292 return cmd
293}
294
295// newSecretsRemoveCmd creates the secrets remove command.
296func newSecretsRemoveCmd() *cobra.Command {
297 var (
298 force bool
299 attachment string
300 )
301
302 cmd := &cobra.Command{
303 Use: "remove <domain> <key>",
304 Short: "Remove a secret",
305 Long: `Remove a secret from a domain or an attachment container.
306
307Use --attachment to target an attachment service (e.g., postgres, redis) instead
308of the main domain container.
309
310Examples:
311 gordon secrets remove app.mydomain.com OLD_API_KEY
312 gordon secrets remove app.mydomain.com OLD_API_KEY --force
313 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
314 Args: cobra.ExactArgs(2),
315 RunE: func(cmd *cobra.Command, args []string) error {
316 ctx := cmd.Context()
317 secretDomain := args[0]
318 key := args[1]
319
320 target := secretDomain
321 if attachment != "" {
322 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
323 }
324
325 // Confirm unless --force
326 if !force {
327 confirmed, err := components.RunConfirm(
328 fmt.Sprintf("Remove secret '%s' from %s?", key, target),
329 )
330 if err != nil {
331 return err
332 }
333 if !confirmed {
334 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
335 return nil
336 }
337 }
338
339 handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
340 if err != nil {
341 return err
342 }
343 defer handle.close()
344 if attachment != "" {
345 if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
346 return fmt.Errorf("failed to remove secret: %w", err)
347 }
348 } else {
349 if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
350 return fmt.Errorf("failed to remove secret: %w", err)
351 }
352 }
353
354 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
355 return nil
356 },
357 }
358
359 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
360 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
361
362 return cmd
363}