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