v2.7.0 fac7705

secrets.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	"gordon/internal/adapters/in/cli/remote"
9	"gordon/internal/adapters/in/cli/ui/components"
10	"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.
21
22Secrets are stored per-domain and injected into containers as environment variables.
23
24When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
25these commands operate on the remote server.`,
26	}
27
28	cmd.AddCommand(newSecretsListCmd())
29	cmd.AddCommand(newSecretsSetCmd())
30	cmd.AddCommand(newSecretsRemoveCmd())
31
32	return cmd
33}
34
35// newSecretsListCmd creates the secrets list command.
36func newSecretsListCmd() *cobra.Command {
37	return &cobra.Command{
38		Use:   "list <domain>",
39		Short: "List secrets for a domain",
40		Long: `List all secret keys configured for a domain.
41
42Note: Only secret keys are shown, not values (for security).
43Attachment secrets (for services like databases) are also displayed.
44
45Examples:
46  gordon secrets list app.mydomain.com
47  gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
48		Args: cobra.ExactArgs(1),
49		RunE: runSecretsListCmd,
50	}
51}
52
53// runSecretsListCmd executes the secrets list command.
54func runSecretsListCmd(_ *cobra.Command, args []string) error {
55	ctx := context.Background()
56	secretDomain := args[0]
57
58	keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain)
59	if err != nil {
60		return err
61	}
62
63	totalSecrets := len(keys)
64	for _, att := range attachments {
65		totalSecrets += len(att.Keys)
66	}
67
68	if totalSecrets == 0 {
69		fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
70		return nil
71	}
72
73	title := fmt.Sprintf("Secrets for %s", secretDomain)
74	if !isRemote {
75		title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
76	}
77	fmt.Println(styles.Theme.Title.Render(title))
78	fmt.Println()
79
80	rows := buildSecretsTableRows(keys, attachments)
81
82	table := components.NewTable(
83		components.WithColumns([]components.TableColumn{
84			{Title: "Key", Width: 45},
85			{Title: "Value", Width: 10},
86		}),
87		components.WithRows(rows),
88	)
89
90	fmt.Println(table.View())
91	return nil
92}
93
94// fetchSecretsWithAttachments retrieves secrets from remote or local source.
95func fetchSecretsWithAttachments(ctx context.Context, secretDomain string) ([]string, []remote.AttachmentSecrets, bool, error) {
96	client, isRemote := GetRemoteClient()
97	if isRemote {
98		result, err := client.ListSecretsWithAttachments(ctx, secretDomain)
99		if err != nil {
100			return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err)
101		}
102		return result.Keys, result.Attachments, isRemote, nil
103	}
104
105	local, err := GetLocalServices(configPath)
106	if err != nil {
107		return nil, nil, isRemote, fmt.Errorf("failed to initialize local services: %w", err)
108	}
109
110	keys, domainAttachments, err := local.GetSecretService().ListKeysWithAttachments(ctx, secretDomain)
111	if err != nil {
112		return nil, nil, isRemote, fmt.Errorf("failed to list secrets: %w", err)
113	}
114
115	// Convert to remote.AttachmentSecrets format
116	var attachments []remote.AttachmentSecrets
117	for _, att := range domainAttachments {
118		attachments = append(attachments, remote.AttachmentSecrets{
119			Service: att.Service,
120			Keys:    att.Keys,
121		})
122	}
123
124	return keys, attachments, isRemote, nil
125}
126
127// buildSecretsTableRows builds table rows with tree structure for attachments.
128func buildSecretsTableRows(keys []string, attachments []remote.AttachmentSecrets) [][]string {
129	var rows [][]string
130
131	// Domain secrets first
132	for _, key := range keys {
133		rows = append(rows, []string{key, styles.Theme.Muted.Render("(hidden)")})
134	}
135
136	// Attachment secrets with tree structure
137	for i, att := range attachments {
138		isLastAttachment := i == len(attachments)-1
139		rows = append(rows, buildAttachmentRows(att, isLastAttachment)...)
140	}
141
142	return rows
143}
144
145// buildAttachmentRows builds table rows for a single attachment with tree structure.
146func buildAttachmentRows(att remote.AttachmentSecrets, isLastAttachment bool) [][]string {
147	var rows [][]string
148
149	// Attachment header with tree prefix
150	prefix := styles.IconTreeBranch + styles.IconTreeLine
151	if isLastAttachment {
152		prefix = styles.IconTreeLast + styles.IconTreeLine
153	}
154
155	serviceName := extractServiceName(att.Service)
156	attachmentHeader := fmt.Sprintf("%s %s", prefix, styles.Theme.Muted.Render(fmt.Sprintf("[%s]", serviceName)))
157	rows = append(rows, []string{attachmentHeader, ""})
158
159	// Keys for this attachment with nested tree structure
160	for j, key := range att.Keys {
161		isLastKey := j == len(att.Keys)-1
162		keyPrefix := getKeyPrefix(isLastAttachment, isLastKey)
163		rows = append(rows, []string{keyPrefix + " " + key, styles.Theme.Muted.Render("(hidden)")})
164	}
165
166	return rows
167}
168
169// extractServiceName extracts a short service name from a container name.
170// e.g., "gordon-git-bnema-dev-gitea-postgres" → "gitea-postgres"
171func extractServiceName(containerName string) string {
172	if !strings.HasPrefix(containerName, "gordon-") {
173		return containerName
174	}
175
176	parts := strings.SplitN(containerName, "-", 2)
177	if len(parts) <= 1 {
178		return containerName
179	}
180
181	serviceName := parts[1]
182	allParts := strings.Split(serviceName, "-")
183
184	// Handle service names based on the number of segments explicitly
185	if len(allParts) < 2 {
186		// No additional segments; use the service name as-is.
187		return serviceName
188	}
189	if len(allParts) == 2 {
190		// Exactly two segments; the service name is already in the desired form.
191		return strings.Join(allParts, "-")
192	}
193
194	// More than two segments: take the last two as the short service name (e.g., "gitea-postgres").
195	return strings.Join(allParts[len(allParts)-2:], "-")
196}
197
198// getKeyPrefix returns the tree prefix for a key based on its position.
199func getKeyPrefix(isLastAttachment, isLastKey bool) string {
200	if isLastAttachment {
201		// Parent is last, use space continuation
202		if isLastKey {
203			return "   " + styles.IconTreeLast + styles.IconTreeLine
204		}
205		return "   " + styles.IconTreeBranch + styles.IconTreeLine
206	}
207	// Parent has siblings, use vertical line continuation
208	if isLastKey {
209		return styles.IconTreeVert + "  " + styles.IconTreeLast + styles.IconTreeLine
210	}
211	return styles.IconTreeVert + "  " + styles.IconTreeBranch + styles.IconTreeLine
212}
213
214// newSecretsSetCmd creates the secrets set command.
215func newSecretsSetCmd() *cobra.Command {
216	return &cobra.Command{
217		Use:   "set <domain> <KEY=value>...",
218		Short: "Set secrets for a domain",
219		Long: `Set one or more secrets for a domain.
220
221Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
222
223Examples:
224  gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
225  gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
226  gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
227		Args: cobra.MinimumNArgs(2),
228		RunE: func(cmd *cobra.Command, args []string) error {
229			ctx := context.Background()
230			secretDomain := args[0]
231			pairs := args[1:]
232
233			// Parse KEY=value pairs
234			secrets := make(map[string]string)
235			for _, pair := range pairs {
236				parts := strings.SplitN(pair, "=", 2)
237				if len(parts) != 2 {
238					fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
239					return nil
240				}
241				secrets[parts[0]] = parts[1]
242			}
243
244			client, isRemote := GetRemoteClient()
245			if isRemote {
246				if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
247					return fmt.Errorf("failed to set secrets: %w", err)
248				}
249			} else {
250				local, err := GetLocalServices(configPath)
251				if err != nil {
252					return fmt.Errorf("failed to initialize local services: %w", err)
253				}
254				if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
255					return fmt.Errorf("failed to set secrets: %w", err)
256				}
257			}
258
259			if len(secrets) == 1 {
260				for key := range secrets {
261					fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
262				}
263			} else {
264				fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))
265			}
266
267			return nil
268		},
269	}
270}
271
272// newSecretsRemoveCmd creates the secrets remove command.
273func newSecretsRemoveCmd() *cobra.Command {
274	var force bool
275
276	cmd := &cobra.Command{
277		Use:   "remove <domain> <key>",
278		Short: "Remove a secret",
279		Long: `Remove a secret from a domain.
280
281Examples:
282  gordon secrets remove app.mydomain.com OLD_API_KEY
283  gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
284		Args: cobra.ExactArgs(2),
285		RunE: func(cmd *cobra.Command, args []string) error {
286			ctx := context.Background()
287			secretDomain := args[0]
288			key := args[1]
289
290			// Confirm unless --force
291			if !force {
292				confirmed, err := components.RunConfirm(
293					fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),
294				)
295				if err != nil {
296					return err
297				}
298				if !confirmed {
299					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
300					return nil
301				}
302			}
303
304			client, isRemote := GetRemoteClient()
305			if isRemote {
306				if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
307					return fmt.Errorf("failed to remove secret: %w", err)
308				}
309			} else {
310				local, err := GetLocalServices(configPath)
311				if err != nil {
312					return fmt.Errorf("failed to initialize local services: %w", err)
313				}
314				if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
315					return fmt.Errorf("failed to remove secret: %w", err)
316				}
317			}
318
319			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
320			return nil
321		},
322	}
323
324	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
325
326	return cmd
327}

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