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 := context.Background()
65 secretDomain := args[0]
66
67 keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain)
68 if err != nil {
69 return err
70 }
71
72 totalSecrets := len(keys)
73 for _, att := range attachments {
74 totalSecrets += len(att.Keys)
75 }
76
77 if totalSecrets == 0 {
78 if jsonOut {
79 return writeJSON(cmd.OutOrStdout(), map[string]any{
80 "domain": secretDomain,
81 "keys": []string{},
82 "attachments": []any{},
83 })
84 }
85 fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
86 return nil
87 }
88
89 if jsonOut {
90 if attachments == nil {
91 attachments = []remote.AttachmentSecrets{}
92 }
93 return writeJSON(cmd.OutOrStdout(), map[string]any{
94 "domain": secretDomain,
95 "keys": keys,
96 "attachments": attachments,
97 })
98 }
99
100 title := fmt.Sprintf("Secrets for %s", secretDomain)
101 if !isRemote {
102 title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
103 }
104 fmt.Println(styles.Theme.Title.Render(title))
105 fmt.Println()
106
107 rows := buildSecretsTableRows(keys, attachments)
108
109 table := components.NewTable(
110 components.WithColumns([]components.TableColumn{
111 {Title: "Key", Width: 45},
112 {Title: "Value", Width: 10},
113 }),
114 components.WithRows(rows),
115 )
116
117 fmt.Println(table.View())
118 return nil
119}
120
121// fetchSecretsWithAttachments retrieves secrets from remote or local source.
122func fetchSecretsWithAttachments(ctx context.Context, secretDomain string) ([]string, []remote.AttachmentSecrets, bool, error) {
123 client, isRemote := GetRemoteClient()
124 if isRemote {
125 result, err := client.ListSecretsWithAttachments(ctx, secretDomain)
126 if err != nil {
127 return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err)
128 }
129 return result.Keys, result.Attachments, isRemote, nil
130 }
131
132 local, err := GetLocalServices(configPath)
133 if err != nil {
134 return nil, nil, isRemote, fmt.Errorf("failed to initialize local services: %w", err)
135 }
136
137 keys, domainAttachments, err := local.GetSecretService().ListKeysWithAttachments(ctx, secretDomain)
138 if err != nil {
139 return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err)
140 }
141
142 // Convert to remote.AttachmentSecrets format
143 var attachments []remote.AttachmentSecrets
144 for _, att := range domainAttachments {
145 attachments = append(attachments, remote.AttachmentSecrets{
146 Service: att.Service,
147 Keys: att.Keys,
148 })
149 }
150
151 return keys, attachments, isRemote, nil
152}
153
154// buildSecretsTableRows builds table rows with tree structure for attachments.
155func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string {
156 var rows [][]string
157
158 // Domain secrets first
159 for _, key := range keys {
160 rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
161 }
162
163 // Attachment secrets with tree structure
164 for i, att := range attachments {
165 isLastAttachment := i == len(attachments)-1
166 rows = append(rows, buildAttachmentRows(att, isLastAttachment)...)
167 }
168
169 return rows
170}
171
172// buildAttachmentRows builds table rows for a single attachment with tree structure.
173func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string {
174 var rows [][]string
175
176 // Attachment header with tree prefix
177 prefix := styles.IconTreeBranch + styles.IconTreeLine
178 if isLastAttachment {
179 prefix = styles.IconTreeLast + styles.IconTreeLine
180 }
181
182 serviceName := extractServiceName(att.Service)
183 attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName)))
184 rows = append(rows, []string{attachmentHeader, ""})
185
186 // Keys for this attachment with nested tree structure
187 for j, key := range att.Keys {
188 isLastKey := j == len(att.Keys)-1
189 keyPrefix := getKeyPrefix(isLastAttachment, isLastKey)
190 rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")})
191 }
192
193 return rows
194}
195
196// extractServiceName extracts a short service name from a container name.
197// e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres"
198func extractServiceName(containerName string) string {
199 if !strings.HasPrefix(containerName, "gordon-") {
200 return containerName
201 }
202
203 parts := strings.SplitN(containerName, "-", 2)
204 if len(parts) <= 1 {
205 return containerName
206 }
207
208 serviceName := parts[1]
209 allParts := strings.Split(serviceName, "-")
210
211 // Handle service names based on the number of segments explicitly
212 if len(allParts) < 2 {
213 // No additional segments; use the service name as-is.
214 return serviceName
215 }
216 if len(allParts) == 2 {
217 // Exactly two segments; the service name is already in the desired form.
218 return strings.Join(allParts, "-")
219 }
220
221 // More than two segments: take the last two as the short service name (e.g., "gitea-postgres").
222 return strings.Join(allParts[len(allParts)-2:], "-")
223}
224
225// getKeyPrefix returns the tree prefix for a key based on its position.
226func getKeyPrefix(isLastAttachment, isLastKey bool) string {
227 if isLastAttachment {
228 // Parent is last, use space continuation
229 if isLastKey {
230 return " " + styles.IconTreeLast + styles.IconTreeLine
231 }
232 return " " + styles.IconTreeBranch + styles.IconTreeLine
233 }
234 // Parent has siblings, use vertical line continuation
235 if isLastKey {
236 return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine
237 }
238 return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
239}
240
241// newSecretsSetCmd creates the secrets set command.
242func newSecretsSetCmd() *cobra.Command {
243 var attachment string
244
245 cmd := &cobra.Command{
246 Use: "set <domain> <KEY=value>...",
247 Short: "Set secrets for a domain or attachment",
248 Long: `Set one or more secrets for a domain or an attachment container.
249
250Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
251
252Use --attachment to target an attachment service (e.g., postgres, redis) instead
253of the main domain container.
254
255Examples:
256 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
257 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
258 gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
259 gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
260 Args: cobra.MinimumNArgs(2),
261 RunE: func(cmd *cobra.Command, args []string) error {
262 ctx := context.Background()
263 secretDomain := args[0]
264 pairs := args[1:]
265
266 // Parse KEY=value pairs
267 secrets := make(map[string]string)
268 for _, pair := range pairs {
269 parts := strings.SplitN(pair, "=", 2)
270 if len(parts) != 2 {
271 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
272 }
273 secrets[parts[0]] = parts[1]
274 }
275
276 client, isRemote := GetRemoteClient()
277 if isRemote {
278 if attachment != "" {
279 if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
280 return fmt.Errorf("failed to set secrets: %w", err)
281 }
282 } else {
283 if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
284 return fmt.Errorf("failed to set secrets: %w", err)
285 }
286 }
287 } else {
288 local, err := GetLocalServices(configPath)
289 if err != nil {
290 return fmt.Errorf("failed to initialize local services: %w", err)
291 }
292 if attachment != "" {
293 if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil {
294 return fmt.Errorf("failed to set secrets: %w", err)
295 }
296 } else {
297 if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
298 return fmt.Errorf("failed to set secrets: %w", err)
299 }
300 }
301 }
302
303 target := secretDomain
304 if attachment != "" {
305 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
306 }
307
308 if len(secrets) == 1 {
309 for key := range secrets {
310 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
311 }
312 } else {
313 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
314 }
315
316 return nil
317 },
318 }
319
320 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
321
322 return cmd
323}
324
325// newSecretsRemoveCmd creates the secrets remove command.
326func newSecretsRemoveCmd() *cobra.Command {
327 var (
328 force bool
329 attachment string
330 )
331
332 cmd := &cobra.Command{
333 Use: "remove <domain> <key>",
334 Short: "Remove a secret",
335 Long: `Remove a secret from a domain or an attachment container.
336
337Use --attachment to target an attachment service (e.g., postgres, redis) instead
338of the main domain container.
339
340Examples:
341 gordon secrets remove app.mydomain.com OLD_API_KEY
342 gordon secrets remove app.mydomain.com OLD_API_KEY --force
343 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
344 Args: cobra.ExactArgs(2),
345 RunE: func(cmd *cobra.Command, args []string) error {
346 ctx := context.Background()
347 secretDomain := args[0]
348 key := args[1]
349
350 target := secretDomain
351 if attachment != "" {
352 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
353 }
354
355 // Confirm unless --force
356 if !force {
357 confirmed, err := components.RunConfirm(
358 fmt.Sprintf("Remove secret '%s' from %s?", key, target),
359 )
360 if err != nil {
361 return err
362 }
363 if !confirmed {
364 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
365 return nil
366 }
367 }
368
369 client, isRemote := GetRemoteClient()
370 if isRemote {
371 if attachment != "" {
372 if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
373 return fmt.Errorf("failed to remove secret: %w", err)
374 }
375 } else {
376 if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
377 return fmt.Errorf("failed to remove secret: %w", err)
378 }
379 }
380 } else {
381 local, err := GetLocalServices(configPath)
382 if err != nil {
383 return fmt.Errorf("failed to initialize local services: %w", err)
384 }
385 if attachment != "" {
386 if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil {
387 return fmt.Errorf("failed to remove secret: %w", err)
388 }
389 } else {
390 if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
391 return fmt.Errorf("failed to remove secret: %w", err)
392 }
393 }
394 }
395
396 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
397 return nil
398 },
399 }
400
401 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
402 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
403
404 return cmd
405}