v2.31.1 f37fe67

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, 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}

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