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