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, fromFile string
226 var jsonOut bool
227
228 cmd := &cobra.Command{
229 Use: "set <domain> --from-file <path>",
230 Short: "Set secrets for a domain or attachment",
231 Long: `Set one or more secrets for a domain or an attachment container.
232
233Secrets are read from a mode 0600 file containing one KEY=value pair per line.
234
235Use --attachment to target an attachment service (e.g., postgres, redis) instead
236of the main domain container.
237
238Examples:
239 gordon secrets set app.mydomain.com --from-file ./app.env
240 gordon secrets set app.mydomain.com --attachment postgres --from-file ./postgres.env`,
241 Args: cobra.ExactArgs(1),
242 RunE: func(cmd *cobra.Command, args []string) error {
243 ctx := cmd.Context()
244 secretDomain := args[0]
245 content, err := readProtectedSecretFile(fromFile)
246 if err != nil {
247 return fmt.Errorf("read secrets: %w", err)
248 }
249
250 secrets := make(map[string]string)
251 for _, pair := range strings.Split(content, "\n") {
252 parts := strings.SplitN(pair, "=", 2)
253 if len(parts) != 2 {
254 return fmt.Errorf("invalid format: %s (expected KEY=value)", pair)
255 }
256 secrets[parts[0]] = parts[1]
257 }
258
259 handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
260 if err != nil {
261 return err
262 }
263 defer handle.close()
264 if attachment != "" {
265 if err := handle.plane.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
266 return fmt.Errorf("failed to set secrets: %w", err)
267 }
268 } else {
269 if err := handle.plane.SetSecrets(ctx, secretDomain, secrets); err != nil {
270 return fmt.Errorf("failed to set secrets: %w", err)
271 }
272 }
273
274 target := secretDomain
275 if attachment != "" {
276 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
277 }
278
279 if jsonOut {
280 return writeJSON(cmd.OutOrStdout(), map[string]any{
281 "domain": secretDomain, "attachment": attachment, "count": len(secrets), "updated": true,
282 })
283 }
284 if len(secrets) == 1 {
285 for key := range secrets {
286 return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
287 }
288 }
289 return cliWriteLine(cmd.OutOrStdout(), styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
290 },
291 }
292
293 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
294 cmd.Flags().StringVar(&fromFile, "from-file", "", "Read KEY=value lines from a mode 0600 file")
295 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
296 _ = cmd.MarkFlagRequired("from-file")
297
298 return cmd
299}
300
301// newSecretsRemoveCmd creates the secrets remove command.
302func newSecretsRemoveCmd() *cobra.Command {
303 var (
304 force bool
305 attachment string
306 )
307
308 cmd := &cobra.Command{
309 Use: "remove <domain> <key>",
310 Short: "Remove a secret",
311 Long: `Remove a secret from a domain or an attachment container.
312
313Use --attachment to target an attachment service (e.g., postgres, redis) instead
314of the main domain container.
315
316Examples:
317 gordon secrets remove app.mydomain.com OLD_API_KEY
318 gordon secrets remove app.mydomain.com OLD_API_KEY --force
319 gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
320 Args: cobra.ExactArgs(2),
321 RunE: func(cmd *cobra.Command, args []string) error {
322 ctx := cmd.Context()
323 secretDomain := args[0]
324 key := args[1]
325
326 target := secretDomain
327 if attachment != "" {
328 target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
329 }
330
331 // Confirm unless --force
332 if !force {
333 confirmed, err := components.RunConfirm(
334 fmt.Sprintf("Remove secret '%s' from %s?", key, target),
335 )
336 if err != nil {
337 return err
338 }
339 if !confirmed {
340 fmt.Println(styles.Theme.Muted.Render("Cancelled"))
341 return nil
342 }
343 }
344
345 handle, err := resolveControlPlaneForRouteDomain(ctx, secretDomain)
346 if err != nil {
347 return err
348 }
349 defer handle.close()
350 if attachment != "" {
351 if err := handle.plane.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
352 return fmt.Errorf("failed to remove secret: %w", err)
353 }
354 } else {
355 if err := handle.plane.DeleteSecret(ctx, secretDomain, key); err != nil {
356 return fmt.Errorf("failed to remove secret: %w", err)
357 }
358 }
359
360 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
361 return nil
362 },
363 }
364
365 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
366 cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
367
368 return cmd
369}