v2.31.0-dev2 4b72742

secrets.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close