v2.15.0 cf76057

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

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