v2.31.0-dev21 b59533b

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 := 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}

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