detached 1aad6fd

feat(secrets): auto-discover and display attachment secrets

When listing secrets for a domain, now also discovers and displays
secrets for attachment containers (e.g., postgres databases).

- Add AttachmentSecrets struct and ListAttachmentKeys to DomainSecretStore
- Implement auto-discovery of attachment env files (gordon-{domain}-*.env)
- Add ListKeysWithAttachments to SecretService interface
- Update admin handler to include attachments in response
- Update CLI to display attachments with tree structure
- Refactor CLI to reduce cyclomatic complexity
- Regenerate mocks
commit
1aad6fd9f836ac1c01429290c91ff8ff34e0526e
author
brice · · 2026-01-18 08:02 UTC
parent
ea678ca
signature
unsigned
9 files · +481 -52
M internal/adapters/dto/admin_secrets.go +9 -2
@@ -1,9 +1,16 @@
11 package dto
22
3+// AttachmentSecretsResponse represents secrets for an attachment container.
4+type AttachmentSecretsResponse struct {
5+ Service string `json:"service"`
6+ Keys []string `json:"keys"`
7+}
8+
39 // SecretsListResponse represents a list of secret keys for a domain.
410 type SecretsListResponse struct {
5- Domain string `json:"domain"`
6- Keys []string `json:"keys"`
11+ Domain string `json:"domain"`
12+ Keys []string `json:"keys"`
13+ Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"`
714 }
815
916 // SecretsStatusResponse represents a secrets write response.
@@ -1,9 +1,16 @@
1 package dto1 package dto
2 2
3+// AttachmentSecretsResponse represents secrets for an attachment container.
4+type AttachmentSecretsResponse struct {
5+ Service string `json:"service"`
6+ Keys []string `json:"keys"`
7+}
8+
3 // SecretsListResponse represents a list of secret keys for a domain.9 // SecretsListResponse represents a list of secret keys for a domain.
4 type SecretsListResponse struct {10 type SecretsListResponse struct {
5- Domain string `json:"domain"`11+ Domain string `json:"domain"`
6- Keys []string `json:"keys"`12+ Keys []string `json:"keys"`
13+ Attachments []AttachmentSecretsResponse `json:"attachments,omitempty"`
7 }14 }
8 15
9 // SecretsStatusResponse represents a secrets write response.16 // SecretsStatusResponse represents a secrets write response.
M internal/adapters/in/cli/remote/client.go +24 -5
@@ -240,22 +240,41 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
240240
241241 // Secrets API
242242
243+// AttachmentSecrets represents secrets for an attachment container.
244+type AttachmentSecrets struct {
245+ Service string `json:"service"`
246+ Keys []string `json:"keys"`
247+}
248+
249+// SecretsListResult contains domain secrets and any attachment secrets.
250+type SecretsListResult struct {
251+ Domain string `json:"domain"`
252+ Keys []string `json:"keys"`
253+ Attachments []AttachmentSecrets `json:"attachments,omitempty"`
254+}
255+
243256 // ListSecrets returns the list of secret keys for a domain.
244257 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
245- resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
258+ result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
246259 if err != nil {
247260 return nil, err
248261 }
262+ return result.Keys, nil
263+}
249264
250- var result struct {
251- Domain string `json:"domain"`
252- Keys []string `json:"keys"`
265+// ListSecretsWithAttachments returns domain secrets and attachment secrets.
266+func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
267+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
268+ if err != nil {
269+ return nil, err
253270 }
271+
272+ var result SecretsListResult
254273 if err := parseResponse(resp, &result); err != nil {
255274 return nil, err
256275 }
257276
258- return result.Keys, nil
277+ return &result, nil
259278 }
260279
261280 // SetSecrets sets secrets for a domain.
@@ -240,22 +240,41 @@ func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
240 240
241 // Secrets API241 // Secrets API
242 242
243+// AttachmentSecrets represents secrets for an attachment container.
244+type AttachmentSecrets struct {
245+ Service string `json:"service"`
246+ Keys []string `json:"keys"`
247+}
248+
249+// SecretsListResult contains domain secrets and any attachment secrets.
250+type SecretsListResult struct {
251+ Domain string `json:"domain"`
252+ Keys []string `json:"keys"`
253+ Attachments []AttachmentSecrets `json:"attachments,omitempty"`
254+}
255+
243 // ListSecrets returns the list of secret keys for a domain.256 // ListSecrets returns the list of secret keys for a domain.
244 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {257 func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
245- resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)258+ result, err := c.ListSecretsWithAttachments(ctx, secretDomain)
246 if err != nil {259 if err != nil {
247 return nil, err260 return nil, err
248 }261 }
262+ return result.Keys, nil
263+}
249 264
250- var result struct {265+// ListSecretsWithAttachments returns domain secrets and attachment secrets.
251- Domain string `json:"domain"`266+func (c *Client) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*SecretsListResult, error) {
252- Keys []string `json:"keys"`267+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+url.PathEscape(secretDomain), nil)
268+ if err != nil {
269+ return nil, err
253 }270 }
271+
272+ var result SecretsListResult
254 if err := parseResponse(resp, &result); err != nil {273 if err := parseResponse(resp, &result); err != nil {
255 return nil, err274 return nil, err
256 }275 }
257 276
258- return result.Keys, nil277+ return &result, nil
259 }278 }
260 279
261 // SetSecrets sets secrets for a domain.280 // SetSecrets sets secrets for a domain.
M internal/adapters/in/cli/secrets.go +148 -44
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "strings"
77
8+ "gordon/internal/adapters/in/cli/remote"
89 "gordon/internal/adapters/in/cli/ui/components"
910 "gordon/internal/adapters/in/cli/ui/styles"
1011
@@ -39,65 +40,168 @@ func newSecretsListCmd() *cobra.Command {
3940 Long: `List all secret keys configured for a domain.
4041
4142 Note: Only secret keys are shown, not values (for security).
43+Attachment secrets (for services like databases) are also displayed.
4244
4345 Examples:
4446 gordon secrets list app.mydomain.com
4547 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
4648 Args: cobra.ExactArgs(1),
47- RunE: func(cmd *cobra.Command, args []string) error {
48- ctx := context.Background()
49- secretDomain := args[0]
49+ RunE: runSecretsListCmd,
50+ }
51+}
5052
51- var keys []string
52- var err error
53+// runSecretsListCmd executes the secrets list command.
54+func runSecretsListCmd(_ *cobra.Command, args []string) error {
55+ ctx := context.Background()
56+ secretDomain := args[0]
5357
54- client, isRemote := GetRemoteClient()
55- if isRemote {
56- keys, err = client.ListSecrets(ctx, secretDomain)
57- } else {
58- local, localErr := GetLocalServices(configPath)
59- if localErr != nil {
60- return fmt.Errorf("failed to initialize local services: %w", localErr)
61- }
62- keys, err = local.GetSecretService().ListKeys(ctx, secretDomain)
63- }
58+ keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain)
59+ if err != nil {
60+ return err
61+ }
6462
65- if err != nil {
66- return fmt.Errorf("failed to list secrets: %w", err)
67- }
63+ totalSecrets := len(keys)
64+ for _, att := range attachments {
65+ totalSecrets += len(att.Keys)
66+ }
6867
69- if len(keys) == 0 {
70- fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
71- return nil
72- }
68+ if totalSecrets == 0 {
69+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
70+ return nil
71+ }
7372
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()
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()
8079
81- // Build table rows
82- rows := make([][]string, len(keys))
83- for i, key := range keys {
84- rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
85- }
80+ rows := buildSecretsTableRows(keys, attachments)
8681
87- // Render table
88- table := components.NewTable(
89- components.WithColumns([]components.TableColumn{
90- {Title: "Key", Width: 30},
91- {Title: "Value", Width: 20},
92- }),
93- components.WithRows(rows),
94- )
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+ )
9589
96- fmt.Println(table.View())
90+ fmt.Println(table.View())
91+ return nil
92+}
9793
98- return nil
99- },
94+// fetchSecretsWithAttachments retrieves secrets from remote or local source.
95+func 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.
128+func 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.
146+func 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"
171+func 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+ if len(allParts) > 2 {
184+ // Take last 2 parts as service name (e.g., "gitea-postgres")
185+ return strings.Join(allParts[len(allParts)-2:], "-")
186+ }
187+
188+ return serviceName
189+}
190+
191+// getKeyPrefix returns the tree prefix for a key based on its position.
192+func getKeyPrefix(isLastAttachment, isLastKey bool) string {
193+ if isLastAttachment {
194+ // Parent is last, use space continuation
195+ if isLastKey {
196+ return " " + styles.IconTreeLast + styles.IconTreeLine
197+ }
198+ return " " + styles.IconTreeBranch + styles.IconTreeLine
199+ }
200+ // Parent has siblings, use vertical line continuation
201+ if isLastKey {
202+ return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine
100203 }
204+ return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
101205 }
102206
103207 // newSecretsSetCmd creates the secrets set command.
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7 7
8+ "gordon/internal/adapters/in/cli/remote"
8 "gordon/internal/adapters/in/cli/ui/components"9 "gordon/internal/adapters/in/cli/ui/components"
9 "gordon/internal/adapters/in/cli/ui/styles"10 "gordon/internal/adapters/in/cli/ui/styles"
10 11
@@ -39,65 +40,168 @@ func newSecretsListCmd() *cobra.Command {
39 Long: `List all secret keys configured for a domain.40 Long: `List all secret keys configured for a domain.
40 41
41 Note: Only secret keys are shown, not values (for security).42 Note: Only secret keys are shown, not values (for security).
43+Attachment secrets (for services like databases) are also displayed.
42 44
43 Examples:45 Examples:
44 gordon secrets list app.mydomain.com46 gordon secrets list app.mydomain.com
45 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,47 gordon --remote https://gordon.mydomain.com secrets list api.mydomain.com`,
46 Args: cobra.ExactArgs(1),48 Args: cobra.ExactArgs(1),
47- RunE: func(cmd *cobra.Command, args []string) error {49+ RunE: runSecretsListCmd,
48- ctx := context.Background()50+ }
49- secretDomain := args[0]51+}
50 52
51- var keys []string53+// runSecretsListCmd executes the secrets list command.
52- var err error54+func runSecretsListCmd(_ *cobra.Command, args []string) error {
55+ ctx := context.Background()
56+ secretDomain := args[0]
53 57
54- client, isRemote := GetRemoteClient()58+ keys, attachments, isRemote, err := fetchSecretsWithAttachments(ctx, secretDomain)
55- if isRemote {59+ if err != nil {
56- keys, err = client.ListSecrets(ctx, secretDomain)60+ return err
57- } else {61+ }
58- local, localErr := GetLocalServices(configPath)
59- if localErr != nil {
60- return fmt.Errorf("failed to initialize local services: %w", localErr)
61- }
62- keys, err = local.GetSecretService().ListKeys(ctx, secretDomain)
63- }
64 62
65- if err != nil {63+ totalSecrets := len(keys)
66- return fmt.Errorf("failed to list secrets: %w", err)64+ for _, att := range attachments {
67- }65+ totalSecrets += len(att.Keys)
66+ }
68 67
69- if len(keys) == 0 {68+ if totalSecrets == 0 {
70- fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))69+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", secretDomain)))
71- return nil70+ return nil
72- }71+ }
73 72
74- title := fmt.Sprintf("Secrets for %s", secretDomain)73+ title := fmt.Sprintf("Secrets for %s", secretDomain)
75- if !isRemote {74+ if !isRemote {
76- title = fmt.Sprintf("Secrets for %s (local)", secretDomain)75+ title = fmt.Sprintf("Secrets for %s (local)", secretDomain)
77- }76+ }
78- fmt.Println(styles.Theme.Title.Render(title))77+ fmt.Println(styles.Theme.Title.Render(title))
79- fmt.Println()78+ fmt.Println()
80 79
81- // Build table rows80+ rows := buildSecretsTableRows(keys, attachments)
82- rows := make([][]string, len(keys))
83- for i, key := range keys {
84- rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
85- }
86 81
87- // Render table82+ table := components.NewTable(
88- table := components.NewTable(83+ components.WithColumns([]components.TableColumn{
89- components.WithColumns([]components.TableColumn{84+ {Title: "Key", Width: 45},
90- {Title: "Key", Width: 30},85+ {Title: "Value", Width: 10},
91- {Title: "Value", Width: 20},86+ }),
92- }),87+ components.WithRows(rows),
93- components.WithRows(rows),88+ )
94- )
95 89
96- fmt.Println(table.View())90+ fmt.Println(table.View())
91+ return nil
92+}
97 93
98- return nil94+// fetchSecretsWithAttachments retrieves secrets from remote or local source.
99- },95+func 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.
128+func 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.
146+func 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"
171+func 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+ if len(allParts) > 2 {
184+ // Take last 2 parts as service name (e.g., "gitea-postgres")
185+ return strings.Join(allParts[len(allParts)-2:], "-")
186+ }
187+
188+ return serviceName
189+}
190+
191+// getKeyPrefix returns the tree prefix for a key based on its position.
192+func getKeyPrefix(isLastAttachment, isLastKey bool) string {
193+ if isLastAttachment {
194+ // Parent is last, use space continuation
195+ if isLastKey {
196+ return " " + styles.IconTreeLast + styles.IconTreeLine
197+ }
198+ return " " + styles.IconTreeBranch + styles.IconTreeLine
199+ }
200+ // Parent has siblings, use vertical line continuation
201+ if isLastKey {
202+ return styles.IconTreeVert + " " + styles.IconTreeLast + styles.IconTreeLine
100 }203 }
204+ return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
101 }205 }
102 206
103 // newSecretsSetCmd creates the secrets set command.207 // newSecretsSetCmd creates the secrets set command.
M internal/adapters/out/domainsecrets/store.go +100 -0
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/zerowrap"
1313
14+ "gordon/internal/boundaries/out"
1415 "gordon/internal/domain"
1516 )
1617
@@ -281,3 +282,102 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
281282 }
282283 return path, nil
283284 }
285+
286+// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
287+// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
288+func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
289+ // Sanitize domain the same way container service does
290+ sanitized := sanitizeDomainForContainer(domainName)
291+ prefix := "gordon-" + sanitized + "-"
292+
293+ // List all files in env directory
294+ entries, err := os.ReadDir(s.envDir)
295+ if err != nil {
296+ if os.IsNotExist(err) {
297+ return nil, nil
298+ }
299+ return nil, fmt.Errorf("failed to read env directory: %w", err)
300+ }
301+
302+ var results []out.AttachmentSecrets
303+ for _, entry := range entries {
304+ if entry.IsDir() {
305+ continue
306+ }
307+ name := entry.Name()
308+ // Check if this is an attachment file for the domain
309+ if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
310+ // Extract service name from filename
311+ // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
312+ serviceName := strings.TrimPrefix(name, prefix)
313+ serviceName = strings.TrimSuffix(serviceName, ".env")
314+ if serviceName == "" {
315+ continue
316+ }
317+
318+ // The full container name is the filename without .env
319+ containerName := strings.TrimSuffix(name, ".env")
320+
321+ // Read keys from this file using existing method
322+ // Note: We use the container name directly since it matches the env file naming
323+ keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
324+ if err != nil {
325+ s.log.Warn().
326+ Err(err).
327+ Str("file", name).
328+ Str("domain", domainName).
329+ Msg("failed to read attachment secrets file")
330+ continue
331+ }
332+
333+ if len(keys) > 0 {
334+ results = append(results, out.AttachmentSecrets{
335+ Service: containerName,
336+ Keys: keys,
337+ })
338+ }
339+ }
340+ }
341+
342+ return results, nil
343+}
344+
345+// sanitizeDomainForContainer matches the sanitizeName function in container service.
346+func sanitizeDomainForContainer(domain string) string {
347+ result := strings.ReplaceAll(domain, ".", "-")
348+ result = strings.ReplaceAll(result, ":", "-")
349+ result = strings.ReplaceAll(result, "/", "-")
350+ return result
351+}
352+
353+// listKeysFromFile reads secret keys from a specific env file path.
354+func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
355+ file, err := os.Open(filePath)
356+ if err != nil {
357+ if os.IsNotExist(err) {
358+ return []string{}, nil
359+ }
360+ return nil, fmt.Errorf("failed to open env file: %w", err)
361+ }
362+ defer file.Close()
363+
364+ var keys []string
365+ scanner := bufio.NewScanner(file)
366+ for scanner.Scan() {
367+ line := strings.TrimSpace(scanner.Text())
368+ // Skip empty lines and comments
369+ if line == "" || strings.HasPrefix(line, "#") {
370+ continue
371+ }
372+ // Extract key from KEY=value
373+ if idx := strings.Index(line, "="); idx > 0 {
374+ keys = append(keys, line[:idx])
375+ }
376+ }
377+
378+ if err := scanner.Err(); err != nil {
379+ return nil, fmt.Errorf("failed to read env file: %w", err)
380+ }
381+
382+ return keys, nil
383+}
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
13 13
14+ "gordon/internal/boundaries/out"
14 "gordon/internal/domain"15 "gordon/internal/domain"
15 )16 )
16 17
@@ -281,3 +282,102 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
281 }282 }
282 return path, nil283 return path, nil
283 }284 }
285+
286+// ListAttachmentKeys finds attachment env files for a domain and returns their keys.
287+// Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
288+func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
289+ // Sanitize domain the same way container service does
290+ sanitized := sanitizeDomainForContainer(domainName)
291+ prefix := "gordon-" + sanitized + "-"
292+
293+ // List all files in env directory
294+ entries, err := os.ReadDir(s.envDir)
295+ if err != nil {
296+ if os.IsNotExist(err) {
297+ return nil, nil
298+ }
299+ return nil, fmt.Errorf("failed to read env directory: %w", err)
300+ }
301+
302+ var results []out.AttachmentSecrets
303+ for _, entry := range entries {
304+ if entry.IsDir() {
305+ continue
306+ }
307+ name := entry.Name()
308+ // Check if this is an attachment file for the domain
309+ if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
310+ // Extract service name from filename
311+ // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
312+ serviceName := strings.TrimPrefix(name, prefix)
313+ serviceName = strings.TrimSuffix(serviceName, ".env")
314+ if serviceName == "" {
315+ continue
316+ }
317+
318+ // The full container name is the filename without .env
319+ containerName := strings.TrimSuffix(name, ".env")
320+
321+ // Read keys from this file using existing method
322+ // Note: We use the container name directly since it matches the env file naming
323+ keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
324+ if err != nil {
325+ s.log.Warn().
326+ Err(err).
327+ Str("file", name).
328+ Str("domain", domainName).
329+ Msg("failed to read attachment secrets file")
330+ continue
331+ }
332+
333+ if len(keys) > 0 {
334+ results = append(results, out.AttachmentSecrets{
335+ Service: containerName,
336+ Keys: keys,
337+ })
338+ }
339+ }
340+ }
341+
342+ return results, nil
343+}
344+
345+// sanitizeDomainForContainer matches the sanitizeName function in container service.
346+func sanitizeDomainForContainer(domain string) string {
347+ result := strings.ReplaceAll(domain, ".", "-")
348+ result = strings.ReplaceAll(result, ":", "-")
349+ result = strings.ReplaceAll(result, "/", "-")
350+ return result
351+}
352+
353+// listKeysFromFile reads secret keys from a specific env file path.
354+func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
355+ file, err := os.Open(filePath)
356+ if err != nil {
357+ if os.IsNotExist(err) {
358+ return []string{}, nil
359+ }
360+ return nil, fmt.Errorf("failed to open env file: %w", err)
361+ }
362+ defer file.Close()
363+
364+ var keys []string
365+ scanner := bufio.NewScanner(file)
366+ for scanner.Scan() {
367+ line := strings.TrimSpace(scanner.Text())
368+ // Skip empty lines and comments
369+ if line == "" || strings.HasPrefix(line, "#") {
370+ continue
371+ }
372+ // Extract key from KEY=value
373+ if idx := strings.Index(line, "="); idx > 0 {
374+ keys = append(keys, line[:idx])
375+ }
376+ }
377+
378+ if err := scanner.Err(); err != nil {
379+ return nil, fmt.Errorf("failed to read env file: %w", err)
380+ }
381+
382+ return keys, nil
383+}
M internal/boundaries/in/mocks/mock_secret_service.go +77 -0
@@ -6,6 +6,7 @@ package mocks
66
77 import (
88 "context"
9+ "gordon/internal/boundaries/out"
910
1011 mock "github.com/stretchr/testify/mock"
1112 )
@@ -236,6 +237,82 @@ func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Con
236237 return _c
237238 }
238239
240+// ListKeysWithAttachments provides a mock function for the type MockSecretService
241+func (_mock *MockSecretService) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
242+ ret := _mock.Called(ctx, domain)
243+
244+ if len(ret) == 0 {
245+ panic("no return value specified for ListKeysWithAttachments")
246+ }
247+
248+ var r0 []string
249+ var r1 []out.AttachmentSecrets
250+ var r2 error
251+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, []out.AttachmentSecrets, error)); ok {
252+ return returnFunc(ctx, domain)
253+ }
254+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
255+ r0 = returnFunc(ctx, domain)
256+ } else {
257+ if ret.Get(0) != nil {
258+ r0 = ret.Get(0).([]string)
259+ }
260+ }
261+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) []out.AttachmentSecrets); ok {
262+ r1 = returnFunc(ctx, domain)
263+ } else {
264+ if ret.Get(1) != nil {
265+ r1 = ret.Get(1).([]out.AttachmentSecrets)
266+ }
267+ }
268+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
269+ r2 = returnFunc(ctx, domain)
270+ } else {
271+ r2 = ret.Error(2)
272+ }
273+ return r0, r1, r2
274+}
275+
276+// MockSecretService_ListKeysWithAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeysWithAttachments'
277+type MockSecretService_ListKeysWithAttachments_Call struct {
278+ *mock.Call
279+}
280+
281+// ListKeysWithAttachments is a helper method to define mock.On call
282+// - ctx context.Context
283+// - domain string
284+func (_e *MockSecretService_Expecter) ListKeysWithAttachments(ctx interface{}, domain interface{}) *MockSecretService_ListKeysWithAttachments_Call {
285+ return &MockSecretService_ListKeysWithAttachments_Call{Call: _e.mock.On("ListKeysWithAttachments", ctx, domain)}
286+}
287+
288+func (_c *MockSecretService_ListKeysWithAttachments_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeysWithAttachments_Call {
289+ _c.Call.Run(func(args mock.Arguments) {
290+ var arg0 context.Context
291+ if args[0] != nil {
292+ arg0 = args[0].(context.Context)
293+ }
294+ var arg1 string
295+ if args[1] != nil {
296+ arg1 = args[1].(string)
297+ }
298+ run(
299+ arg0,
300+ arg1,
301+ )
302+ })
303+ return _c
304+}
305+
306+func (_c *MockSecretService_ListKeysWithAttachments_Call) Return(strings []string, attachmentSecretss []out.AttachmentSecrets, err error) *MockSecretService_ListKeysWithAttachments_Call {
307+ _c.Call.Return(strings, attachmentSecretss, err)
308+ return _c
309+}
310+
311+func (_c *MockSecretService_ListKeysWithAttachments_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error)) *MockSecretService_ListKeysWithAttachments_Call {
312+ _c.Call.Return(run)
313+ return _c
314+}
315+
239316 // Set provides a mock function for the type MockSecretService
240317 func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error {
241318 ret := _mock.Called(ctx, domain, secrets)
@@ -6,6 +6,7 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9+ "gordon/internal/boundaries/out"
9 10
10 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
11 )12 )
@@ -236,6 +237,82 @@ func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Con
236 return _c237 return _c
237 }238 }
238 239
240+// ListKeysWithAttachments provides a mock function for the type MockSecretService
241+func (_mock *MockSecretService) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
242+ ret := _mock.Called(ctx, domain)
243+
244+ if len(ret) == 0 {
245+ panic("no return value specified for ListKeysWithAttachments")
246+ }
247+
248+ var r0 []string
249+ var r1 []out.AttachmentSecrets
250+ var r2 error
251+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, []out.AttachmentSecrets, error)); ok {
252+ return returnFunc(ctx, domain)
253+ }
254+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok {
255+ r0 = returnFunc(ctx, domain)
256+ } else {
257+ if ret.Get(0) != nil {
258+ r0 = ret.Get(0).([]string)
259+ }
260+ }
261+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) []out.AttachmentSecrets); ok {
262+ r1 = returnFunc(ctx, domain)
263+ } else {
264+ if ret.Get(1) != nil {
265+ r1 = ret.Get(1).([]out.AttachmentSecrets)
266+ }
267+ }
268+ if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok {
269+ r2 = returnFunc(ctx, domain)
270+ } else {
271+ r2 = ret.Error(2)
272+ }
273+ return r0, r1, r2
274+}
275+
276+// MockSecretService_ListKeysWithAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeysWithAttachments'
277+type MockSecretService_ListKeysWithAttachments_Call struct {
278+ *mock.Call
279+}
280+
281+// ListKeysWithAttachments is a helper method to define mock.On call
282+// - ctx context.Context
283+// - domain string
284+func (_e *MockSecretService_Expecter) ListKeysWithAttachments(ctx interface{}, domain interface{}) *MockSecretService_ListKeysWithAttachments_Call {
285+ return &MockSecretService_ListKeysWithAttachments_Call{Call: _e.mock.On("ListKeysWithAttachments", ctx, domain)}
286+}
287+
288+func (_c *MockSecretService_ListKeysWithAttachments_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeysWithAttachments_Call {
289+ _c.Call.Run(func(args mock.Arguments) {
290+ var arg0 context.Context
291+ if args[0] != nil {
292+ arg0 = args[0].(context.Context)
293+ }
294+ var arg1 string
295+ if args[1] != nil {
296+ arg1 = args[1].(string)
297+ }
298+ run(
299+ arg0,
300+ arg1,
301+ )
302+ })
303+ return _c
304+}
305+
306+func (_c *MockSecretService_ListKeysWithAttachments_Call) Return(strings []string, attachmentSecretss []out.AttachmentSecrets, err error) *MockSecretService_ListKeysWithAttachments_Call {
307+ _c.Call.Return(strings, attachmentSecretss, err)
308+ return _c
309+}
310+
311+func (_c *MockSecretService_ListKeysWithAttachments_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error)) *MockSecretService_ListKeysWithAttachments_Call {
312+ _c.Call.Return(run)
313+ return _c
314+}
315+
239 // Set provides a mock function for the type MockSecretService316 // Set provides a mock function for the type MockSecretService
240 func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error {317 func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error {
241 ret := _mock.Called(ctx, domain, secrets)318 ret := _mock.Called(ctx, domain, secrets)
M internal/boundaries/in/secrets.go +9 -1
@@ -1,6 +1,10 @@
11 package in
22
3-import "context"
3+import (
4+ "context"
5+
6+ "gordon/internal/boundaries/out"
7+)
48
59 // SecretService defines the contract for managing domain-scoped secrets.
610 // These are environment variables stored per-domain for container injection.
@@ -9,6 +13,10 @@ type SecretService interface {
913 // Returns an error if the domain is invalid.
1014 ListKeys(ctx context.Context, domain string) ([]string, error)
1115
16+ // ListKeysWithAttachments returns the list of secret keys for a domain
17+ // along with any attachment secrets for containers associated with the domain.
18+ ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error)
19+
1220 // GetAll returns all secrets for a domain as a key-value map.
1321 // Returns an error if the domain is invalid.
1422 GetAll(ctx context.Context, domain string) (map[string]string, error)
@@ -1,6 +1,10 @@
1 package in1 package in
2 2
3-import "context"3+import (
4+ "context"
5+
6+ "gordon/internal/boundaries/out"
7+)
4 8
5 // SecretService defines the contract for managing domain-scoped secrets.9 // SecretService defines the contract for managing domain-scoped secrets.
6 // These are environment variables stored per-domain for container injection.10 // These are environment variables stored per-domain for container injection.
@@ -9,6 +13,10 @@ type SecretService interface {
9 // Returns an error if the domain is invalid.13 // Returns an error if the domain is invalid.
10 ListKeys(ctx context.Context, domain string) ([]string, error)14 ListKeys(ctx context.Context, domain string) ([]string, error)
11 15
16+ // ListKeysWithAttachments returns the list of secret keys for a domain
17+ // along with any attachment secrets for containers associated with the domain.
18+ ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error)
19+
12 // GetAll returns all secrets for a domain as a key-value map.20 // GetAll returns all secrets for a domain as a key-value map.
13 // Returns an error if the domain is invalid.21 // Returns an error if the domain is invalid.
14 GetAll(ctx context.Context, domain string) (map[string]string, error)22 GetAll(ctx context.Context, domain string) (map[string]string, error)
M internal/boundaries/out/domain_secrets.go +13 -0
@@ -1,5 +1,13 @@
11 package out
22
3+// AttachmentSecrets represents secrets for an attachment container.
4+type AttachmentSecrets struct {
5+ // Service is the attachment service name (e.g., "gitea-postgres")
6+ Service string
7+ // Keys is the list of secret keys for this attachment
8+ Keys []string
9+}
10+
311 // DomainSecretStore defines the contract for managing domain-scoped secrets.
412 // These are environment variables stored per-domain for container injection.
513 type DomainSecretStore interface {
@@ -14,4 +22,9 @@ type DomainSecretStore interface {
1422
1523 // Delete removes a specific secret key from a domain.
1624 Delete(domain, key string) error
25+
26+ // ListAttachmentKeys finds and returns secret keys for attachment containers
27+ // associated with the given domain. Returns a list of AttachmentSecrets, one
28+ // for each attachment that has secrets configured.
29+ ListAttachmentKeys(domain string) ([]AttachmentSecrets, error)
1730 }
@@ -1,5 +1,13 @@
1 package out1 package out
2 2
3+// AttachmentSecrets represents secrets for an attachment container.
4+type AttachmentSecrets struct {
5+ // Service is the attachment service name (e.g., "gitea-postgres")
6+ Service string
7+ // Keys is the list of secret keys for this attachment
8+ Keys []string
9+}
10+
3 // DomainSecretStore defines the contract for managing domain-scoped secrets.11 // DomainSecretStore defines the contract for managing domain-scoped secrets.
4 // These are environment variables stored per-domain for container injection.12 // These are environment variables stored per-domain for container injection.
5 type DomainSecretStore interface {13 type DomainSecretStore interface {
@@ -14,4 +22,9 @@ type DomainSecretStore interface {
14 22
15 // Delete removes a specific secret key from a domain.23 // Delete removes a specific secret key from a domain.
16 Delete(domain, key string) error24 Delete(domain, key string) error
25+
26+ // ListAttachmentKeys finds and returns secret keys for attachment containers
27+ // associated with the given domain. Returns a list of AttachmentSecrets, one
28+ // for each attachment that has secrets configured.
29+ ListAttachmentKeys(domain string) ([]AttachmentSecrets, error)
17 }30 }
M internal/boundaries/out/mocks/mock_domain_secret_store.go +64 -0
@@ -5,6 +5,8 @@
55 package mocks
66
77 import (
8+ "gordon/internal/boundaries/out"
9+
810 mock "github.com/stretchr/testify/mock"
911 )
1012
@@ -154,6 +156,68 @@ func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string
154156 return _c
155157 }
156158
159+// ListAttachmentKeys provides a mock function for the type MockDomainSecretStore
160+func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) {
161+ ret := _mock.Called(domain)
162+
163+ if len(ret) == 0 {
164+ panic("no return value specified for ListAttachmentKeys")
165+ }
166+
167+ var r0 []out.AttachmentSecrets
168+ var r1 error
169+ if returnFunc, ok := ret.Get(0).(func(string) ([]out.AttachmentSecrets, error)); ok {
170+ return returnFunc(domain)
171+ }
172+ if returnFunc, ok := ret.Get(0).(func(string) []out.AttachmentSecrets); ok {
173+ r0 = returnFunc(domain)
174+ } else {
175+ if ret.Get(0) != nil {
176+ r0 = ret.Get(0).([]out.AttachmentSecrets)
177+ }
178+ }
179+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
180+ r1 = returnFunc(domain)
181+ } else {
182+ r1 = ret.Error(1)
183+ }
184+ return r0, r1
185+}
186+
187+// MockDomainSecretStore_ListAttachmentKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachmentKeys'
188+type MockDomainSecretStore_ListAttachmentKeys_Call struct {
189+ *mock.Call
190+}
191+
192+// ListAttachmentKeys is a helper method to define mock.On call
193+// - domain string
194+func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain interface{}) *MockDomainSecretStore_ListAttachmentKeys_Call {
195+ return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}
196+}
197+
198+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListAttachmentKeys_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 string
201+ if args[0] != nil {
202+ arg0 = args[0].(string)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Return(attachmentSecretss []out.AttachmentSecrets, err error) *MockDomainSecretStore_ListAttachmentKeys_Call {
212+ _c.Call.Return(attachmentSecretss, err)
213+ return _c
214+}
215+
216+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) RunAndReturn(run func(domain string) ([]out.AttachmentSecrets, error)) *MockDomainSecretStore_ListAttachmentKeys_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
157221 // ListKeys provides a mock function for the type MockDomainSecretStore
158222 func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) {
159223 ret := _mock.Called(domain)
@@ -5,6 +5,8 @@
5 package mocks5 package mocks
6 6
7 import (7 import (
8+ "gordon/internal/boundaries/out"
9+
8 mock "github.com/stretchr/testify/mock"10 mock "github.com/stretchr/testify/mock"
9 )11 )
10 12
@@ -154,6 +156,68 @@ func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string
154 return _c156 return _c
155 }157 }
156 158
159+// ListAttachmentKeys provides a mock function for the type MockDomainSecretStore
160+func (_mock *MockDomainSecretStore) ListAttachmentKeys(domain string) ([]out.AttachmentSecrets, error) {
161+ ret := _mock.Called(domain)
162+
163+ if len(ret) == 0 {
164+ panic("no return value specified for ListAttachmentKeys")
165+ }
166+
167+ var r0 []out.AttachmentSecrets
168+ var r1 error
169+ if returnFunc, ok := ret.Get(0).(func(string) ([]out.AttachmentSecrets, error)); ok {
170+ return returnFunc(domain)
171+ }
172+ if returnFunc, ok := ret.Get(0).(func(string) []out.AttachmentSecrets); ok {
173+ r0 = returnFunc(domain)
174+ } else {
175+ if ret.Get(0) != nil {
176+ r0 = ret.Get(0).([]out.AttachmentSecrets)
177+ }
178+ }
179+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
180+ r1 = returnFunc(domain)
181+ } else {
182+ r1 = ret.Error(1)
183+ }
184+ return r0, r1
185+}
186+
187+// MockDomainSecretStore_ListAttachmentKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAttachmentKeys'
188+type MockDomainSecretStore_ListAttachmentKeys_Call struct {
189+ *mock.Call
190+}
191+
192+// ListAttachmentKeys is a helper method to define mock.On call
193+// - domain string
194+func (_e *MockDomainSecretStore_Expecter) ListAttachmentKeys(domain interface{}) *MockDomainSecretStore_ListAttachmentKeys_Call {
195+ return &MockDomainSecretStore_ListAttachmentKeys_Call{Call: _e.mock.On("ListAttachmentKeys", domain)}
196+}
197+
198+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListAttachmentKeys_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 string
201+ if args[0] != nil {
202+ arg0 = args[0].(string)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) Return(attachmentSecretss []out.AttachmentSecrets, err error) *MockDomainSecretStore_ListAttachmentKeys_Call {
212+ _c.Call.Return(attachmentSecretss, err)
213+ return _c
214+}
215+
216+func (_c *MockDomainSecretStore_ListAttachmentKeys_Call) RunAndReturn(run func(domain string) ([]out.AttachmentSecrets, error)) *MockDomainSecretStore_ListAttachmentKeys_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
157 // ListKeys provides a mock function for the type MockDomainSecretStore221 // ListKeys provides a mock function for the type MockDomainSecretStore
158 func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) {222 func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) {
159 ret := _mock.Called(domain)223 ret := _mock.Called(domain)
M internal/usecase/secrets/service.go +37 -0
@@ -57,6 +57,43 @@ func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error)
5757 return keys, nil
5858 }
5959
60+// ListKeysWithAttachments returns the list of secret keys for a domain
61+// along with any attachment secrets for containers associated with the domain.
62+func (s *Service) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
63+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64+ zerowrap.FieldLayer: "usecase",
65+ zerowrap.FieldUseCase: "ListKeysWithAttachments",
66+ "domain": domain,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ if err := ValidateDomain(domain); err != nil {
71+ log.Warn().Err(err).Msg("domain validation failed")
72+ return nil, nil, err
73+ }
74+
75+ // Get domain secrets
76+ keys, err := s.store.ListKeys(domain)
77+ if err != nil {
78+ log.Error().Err(err).Msg("failed to list secret keys")
79+ return nil, nil, err
80+ }
81+
82+ // Get attachment secrets
83+ attachments, err := s.store.ListAttachmentKeys(domain)
84+ if err != nil {
85+ log.Warn().Err(err).Msg("failed to list attachment secrets, continuing without them")
86+ attachments = nil // Don't fail, just return empty attachments
87+ }
88+
89+ log.Debug().
90+ Int("domain_keys", len(keys)).
91+ Int("attachments", len(attachments)).
92+ Msg("listed secret keys with attachments")
93+
94+ return keys, attachments, nil
95+}
96+
6097 // GetAll returns all secrets for a domain as a key-value map.
6198 func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
6299 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -57,6 +57,43 @@ func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error)
57 return keys, nil57 return keys, nil
58 }58 }
59 59
60+// ListKeysWithAttachments returns the list of secret keys for a domain
61+// along with any attachment secrets for containers associated with the domain.
62+func (s *Service) ListKeysWithAttachments(ctx context.Context, domain string) ([]string, []out.AttachmentSecrets, error) {
63+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64+ zerowrap.FieldLayer: "usecase",
65+ zerowrap.FieldUseCase: "ListKeysWithAttachments",
66+ "domain": domain,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ if err := ValidateDomain(domain); err != nil {
71+ log.Warn().Err(err).Msg("domain validation failed")
72+ return nil, nil, err
73+ }
74+
75+ // Get domain secrets
76+ keys, err := s.store.ListKeys(domain)
77+ if err != nil {
78+ log.Error().Err(err).Msg("failed to list secret keys")
79+ return nil, nil, err
80+ }
81+
82+ // Get attachment secrets
83+ attachments, err := s.store.ListAttachmentKeys(domain)
84+ if err != nil {
85+ log.Warn().Err(err).Msg("failed to list attachment secrets, continuing without them")
86+ attachments = nil // Don't fail, just return empty attachments
87+ }
88+
89+ log.Debug().
90+ Int("domain_keys", len(keys)).
91+ Int("attachments", len(attachments)).
92+ Msg("listed secret keys with attachments")
93+
94+ return keys, attachments, nil
95+}
96+
60 // GetAll returns all secrets for a domain as a key-value map.97 // GetAll returns all secrets for a domain as a key-value map.
61 func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {98 func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) {
62 ctx = zerowrap.CtxWithFields(ctx, map[string]any{99 ctx = zerowrap.CtxWithFields(ctx, map[string]any{

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