detached fac7705

Merge pull request #35 from bnema/fix/minor-qa-issues

fix: minor QA issues from v2.6.0 testing
commit
fac7705d045279b0db970c03d8417ce276388283
author
bnema · · 2026-01-18 08:13 UTC
committer
GitHub · · 2026-01-18 08:13 UTC
merge of
edb08f9 + bb7bc50 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.7.0
17 files · +829 -69
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 +155 -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,175 @@ 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+
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.
199+func 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
100210 }
211+ return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
101212 }
102213
103214 // 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,175 @@ 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+
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.
199+func 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
100 }210 }
211+ return styles.IconTreeVert + " " + styles.IconTreeBranch + styles.IconTreeLine
101 }212 }
102 213
103 // newSecretsSetCmd creates the secrets set command.214 // newSecretsSetCmd creates the secrets set command.
M internal/adapters/in/http/admin/handler.go +43 -15
@@ -350,7 +350,11 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
350350
351351 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
352352 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
353- h.sendError(w, http.StatusInternalServerError, "failed to remove route")
353+ if errors.Is(err, domain.ErrRouteNotFound) {
354+ h.sendError(w, http.StatusNotFound, "route not found")
355+ } else {
356+ h.sendError(w, http.StatusInternalServerError, "failed to remove route")
357+ }
354358 return
355359 }
356360
@@ -421,19 +425,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
421425
422426 switch r.Method {
423427 case http.MethodGet:
424- // Check read permission
425- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
426- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
427- return
428- }
429- // List secrets for domain (names only, not values)
430- keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
431- if err != nil {
432- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
433- h.sendError(w, http.StatusBadRequest, "invalid domain")
434- return
435- }
436- h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{Domain: secretDomain, Keys: keys})
428+ h.handleSecretsGet(w, r, secretDomain)
437429
438430 case http.MethodPost:
439431 // Check write permission
@@ -487,6 +479,41 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
487479 }
488480 }
489481
482+// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
483+func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
484+ ctx := r.Context()
485+ log := zerowrap.FromCtx(ctx)
486+
487+ // Check read permission
488+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
489+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
490+ return
491+ }
492+
493+ // List secrets for domain (names only, not values) including attachments
494+ keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
495+ if err != nil {
496+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
497+ h.sendError(w, http.StatusBadRequest, "invalid domain")
498+ return
499+ }
500+
501+ // Convert attachments to DTO format
502+ var attachmentDTOs []dto.AttachmentSecretsResponse
503+ for _, att := range attachments {
504+ attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
505+ Service: att.Service,
506+ Keys: att.Keys,
507+ })
508+ }
509+
510+ h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
511+ Domain: secretDomain,
512+ Keys: keys,
513+ Attachments: attachmentDTOs,
514+ })
515+}
516+
490517 // handleHealth handles /admin/health endpoint.
491518 // Returns detailed health status for all routes including HTTP probe results.
492519 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -581,7 +608,8 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
581608 }
582609
583610 // Reload configuration from file into memory
584- if err := h.configSvc.Load(ctx); err != nil {
611+ // Using Reload() instead of Load() to ensure we re-read the file from disk
612+ if err := h.configSvc.Reload(ctx); err != nil {
585613 log.Error().Err(err).Msg("failed to reload config")
586614 h.sendError(w, http.StatusInternalServerError, "failed to reload config")
587615 return
@@ -350,7 +350,11 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou
350 350
351 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {351 if err := h.configSvc.RemoveRoute(ctx, routeDomain); err != nil {
352 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")352 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to remove route")
353- h.sendError(w, http.StatusInternalServerError, "failed to remove route")353+ if errors.Is(err, domain.ErrRouteNotFound) {
354+ h.sendError(w, http.StatusNotFound, "route not found")
355+ } else {
356+ h.sendError(w, http.StatusInternalServerError, "failed to remove route")
357+ }
354 return358 return
355 }359 }
356 360
@@ -421,19 +425,7 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
421 425
422 switch r.Method {426 switch r.Method {
423 case http.MethodGet:427 case http.MethodGet:
424- // Check read permission428+ h.handleSecretsGet(w, r, secretDomain)
425- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
426- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
427- return
428- }
429- // List secrets for domain (names only, not values)
430- keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
431- if err != nil {
432- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
433- h.sendError(w, http.StatusBadRequest, "invalid domain")
434- return
435- }
436- h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{Domain: secretDomain, Keys: keys})
437 429
438 case http.MethodPost:430 case http.MethodPost:
439 // Check write permission431 // Check write permission
@@ -487,6 +479,41 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
487 }479 }
488 }480 }
489 481
482+// handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
483+func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secretDomain string) {
484+ ctx := r.Context()
485+ log := zerowrap.FromCtx(ctx)
486+
487+ // Check read permission
488+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionRead) {
489+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:read")
490+ return
491+ }
492+
493+ // List secrets for domain (names only, not values) including attachments
494+ keys, attachments, err := h.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
495+ if err != nil {
496+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets")
497+ h.sendError(w, http.StatusBadRequest, "invalid domain")
498+ return
499+ }
500+
501+ // Convert attachments to DTO format
502+ var attachmentDTOs []dto.AttachmentSecretsResponse
503+ for _, att := range attachments {
504+ attachmentDTOs = append(attachmentDTOs, dto.AttachmentSecretsResponse{
505+ Service: att.Service,
506+ Keys: att.Keys,
507+ })
508+ }
509+
510+ h.sendJSON(w, http.StatusOK, dto.SecretsListResponse{
511+ Domain: secretDomain,
512+ Keys: keys,
513+ Attachments: attachmentDTOs,
514+ })
515+}
516+
490 // handleHealth handles /admin/health endpoint.517 // handleHealth handles /admin/health endpoint.
491 // Returns detailed health status for all routes including HTTP probe results.518 // Returns detailed health status for all routes including HTTP probe results.
492 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {519 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -581,7 +608,8 @@ func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
581 }608 }
582 609
583 // Reload configuration from file into memory610 // Reload configuration from file into memory
584- if err := h.configSvc.Load(ctx); err != nil {611+ // Using Reload() instead of Load() to ensure we re-read the file from disk
612+ if err := h.configSvc.Reload(ctx); err != nil {
585 log.Error().Err(err).Msg("failed to reload config")613 log.Error().Err(err).Msg("failed to reload config")
586 h.sendError(w, http.StatusInternalServerError, "failed to reload config")614 h.sendError(w, http.StatusInternalServerError, "failed to reload config")
587 return615 return
M internal/adapters/in/http/admin/handler_test.go +2 -2
@@ -279,7 +279,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
279279 for _, tt := range tests {
280280 t.Run(tt.name, func(t *testing.T) {
281281 if tt.wantStatus == http.StatusOK {
282- secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe()
282+ secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
283283 }
284284
285285 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
@@ -535,7 +535,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
535535 for _, tt := range tests {
536536 t.Run(tt.name, func(t *testing.T) {
537537 if tt.wantStatus == http.StatusOK {
538- configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe()
538+ configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
539539 }
540540
541541 req := httptest.NewRequest("POST", "/admin/reload", nil)
@@ -279,7 +279,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
279 for _, tt := range tests {279 for _, tt := range tests {
280 t.Run(tt.name, func(t *testing.T) {280 t.Run(tt.name, func(t *testing.T) {
281 if tt.wantStatus == http.StatusOK {281 if tt.wantStatus == http.StatusOK {
282- secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe()282+ secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
283 }283 }
284 284
285 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)285 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
@@ -535,7 +535,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
535 for _, tt := range tests {535 for _, tt := range tests {
536 t.Run(tt.name, func(t *testing.T) {536 t.Run(tt.name, func(t *testing.T) {
537 if tt.wantStatus == http.StatusOK {537 if tt.wantStatus == http.StatusOK {
538- configSvc.EXPECT().Load(mock.Anything).Return(nil).Maybe()538+ configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
539 }539 }
540 540
541 req := httptest.NewRequest("POST", "/admin/reload", nil)541 req := httptest.NewRequest("POST", "/admin/reload", nil)
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/config.go +4 -0
@@ -11,6 +11,10 @@ type ConfigService interface {
1111 // Load loads the configuration from the configured source.
1212 Load(ctx context.Context) error
1313
14+ // Reload re-reads the configuration file from disk and loads it into memory.
15+ // This is different from Load() which only loads from the cached viper values.
16+ Reload(ctx context.Context) error
17+
1418 // GetRoutes returns all configured routes.
1519 GetRoutes(ctx context.Context) []domain.Route
1620
@@ -11,6 +11,10 @@ type ConfigService interface {
11 // Load loads the configuration from the configured source.11 // Load loads the configuration from the configured source.
12 Load(ctx context.Context) error12 Load(ctx context.Context) error
13 13
14+ // Reload re-reads the configuration file from disk and loads it into memory.
15+ // This is different from Load() which only loads from the cached viper values.
16+ Reload(ctx context.Context) error
17+
14 // GetRoutes returns all configured routes.18 // GetRoutes returns all configured routes.
15 GetRoutes(ctx context.Context) []domain.Route19 GetRoutes(ctx context.Context) []domain.Route
16 20
M internal/boundaries/in/mocks/mock_config_service.go +51 -0
@@ -805,6 +805,57 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context
805805 return _c
806806 }
807807
808+// Reload provides a mock function for the type MockConfigService
809+func (_mock *MockConfigService) Reload(ctx context.Context) error {
810+ ret := _mock.Called(ctx)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for Reload")
814+ }
815+
816+ var r0 error
817+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
818+ r0 = returnFunc(ctx)
819+ } else {
820+ r0 = ret.Error(0)
821+ }
822+ return r0
823+}
824+
825+// MockConfigService_Reload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reload'
826+type MockConfigService_Reload_Call struct {
827+ *mock.Call
828+}
829+
830+// Reload is a helper method to define mock.On call
831+// - ctx context.Context
832+func (_e *MockConfigService_Expecter) Reload(ctx interface{}) *MockConfigService_Reload_Call {
833+ return &MockConfigService_Reload_Call{Call: _e.mock.On("Reload", ctx)}
834+}
835+
836+func (_c *MockConfigService_Reload_Call) Run(run func(ctx context.Context)) *MockConfigService_Reload_Call {
837+ _c.Call.Run(func(args mock.Arguments) {
838+ var arg0 context.Context
839+ if args[0] != nil {
840+ arg0 = args[0].(context.Context)
841+ }
842+ run(
843+ arg0,
844+ )
845+ })
846+ return _c
847+}
848+
849+func (_c *MockConfigService_Reload_Call) Return(err error) *MockConfigService_Reload_Call {
850+ _c.Call.Return(err)
851+ return _c
852+}
853+
854+func (_c *MockConfigService_Reload_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Reload_Call {
855+ _c.Call.Return(run)
856+ return _c
857+}
858+
808859 // RemoveAttachment provides a mock function for the type MockConfigService
809860 func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
810861 ret := _mock.Called(ctx, domainOrGroup, image)
@@ -805,6 +805,57 @@ func (_c *MockConfigService_Load_Call) RunAndReturn(run func(ctx context.Context
805 return _c805 return _c
806 }806 }
807 807
808+// Reload provides a mock function for the type MockConfigService
809+func (_mock *MockConfigService) Reload(ctx context.Context) error {
810+ ret := _mock.Called(ctx)
811+
812+ if len(ret) == 0 {
813+ panic("no return value specified for Reload")
814+ }
815+
816+ var r0 error
817+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
818+ r0 = returnFunc(ctx)
819+ } else {
820+ r0 = ret.Error(0)
821+ }
822+ return r0
823+}
824+
825+// MockConfigService_Reload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reload'
826+type MockConfigService_Reload_Call struct {
827+ *mock.Call
828+}
829+
830+// Reload is a helper method to define mock.On call
831+// - ctx context.Context
832+func (_e *MockConfigService_Expecter) Reload(ctx interface{}) *MockConfigService_Reload_Call {
833+ return &MockConfigService_Reload_Call{Call: _e.mock.On("Reload", ctx)}
834+}
835+
836+func (_c *MockConfigService_Reload_Call) Run(run func(ctx context.Context)) *MockConfigService_Reload_Call {
837+ _c.Call.Run(func(args mock.Arguments) {
838+ var arg0 context.Context
839+ if args[0] != nil {
840+ arg0 = args[0].(context.Context)
841+ }
842+ run(
843+ arg0,
844+ )
845+ })
846+ return _c
847+}
848+
849+func (_c *MockConfigService_Reload_Call) Return(err error) *MockConfigService_Reload_Call {
850+ _c.Call.Return(err)
851+ return _c
852+}
853+
854+func (_c *MockConfigService_Reload_Call) RunAndReturn(run func(ctx context.Context) error) *MockConfigService_Reload_Call {
855+ _c.Call.Return(run)
856+ return _c
857+}
858+
808 // RemoveAttachment provides a mock function for the type MockConfigService859 // RemoveAttachment provides a mock function for the type MockConfigService
809 func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {860 func (_mock *MockConfigService) RemoveAttachment(ctx context.Context, domainOrGroup string, image string) error {
810 ret := _mock.Called(ctx, domainOrGroup, image)861 ret := _mock.Called(ctx, domainOrGroup, image)
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/config/service.go +19 -0
@@ -85,6 +85,25 @@ func (s *Service) Load(ctx context.Context) error {
8585 return nil
8686 }
8787
88+// Reload re-reads the configuration file from disk and loads it into memory.
89+// This should be used when you want to pick up external changes to the config file.
90+// It differs from Load() which only loads from cached viper values.
91+func (s *Service) Reload(ctx context.Context) error {
92+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
93+ zerowrap.FieldLayer: "usecase",
94+ zerowrap.FieldUseCase: "Reload",
95+ })
96+ log := zerowrap.FromCtx(ctx)
97+
98+ // Re-read the config file from disk
99+ if err := s.viper.ReadInConfig(); err != nil {
100+ return log.WrapErr(err, "failed to read config file")
101+ }
102+
103+ // Load the values into memory
104+ return s.Load(ctx)
105+}
106+
88107 // loadConfigValues loads simple config values from viper.
89108 func (s *Service) loadConfigValues() Config {
90109 // Prefer gordon_domain over registry_domain
@@ -85,6 +85,25 @@ func (s *Service) Load(ctx context.Context) error {
85 return nil85 return nil
86 }86 }
87 87
88+// Reload re-reads the configuration file from disk and loads it into memory.
89+// This should be used when you want to pick up external changes to the config file.
90+// It differs from Load() which only loads from cached viper values.
91+func (s *Service) Reload(ctx context.Context) error {
92+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
93+ zerowrap.FieldLayer: "usecase",
94+ zerowrap.FieldUseCase: "Reload",
95+ })
96+ log := zerowrap.FromCtx(ctx)
97+
98+ // Re-read the config file from disk
99+ if err := s.viper.ReadInConfig(); err != nil {
100+ return log.WrapErr(err, "failed to read config file")
101+ }
102+
103+ // Load the values into memory
104+ return s.Load(ctx)
105+}
106+
88 // loadConfigValues loads simple config values from viper.107 // loadConfigValues loads simple config values from viper.
89 func (s *Service) loadConfigValues() Config {108 func (s *Service) loadConfigValues() Config {
90 // Prefer gordon_domain over registry_domain109 // Prefer gordon_domain over registry_domain
M internal/usecase/config/service_test.go +109 -0
@@ -64,6 +64,115 @@ func TestService_Load(t *testing.T) {
6464 assert.False(t, preserve)
6565 }
6666
67+func TestService_Reload(t *testing.T) {
68+ t.Run("success - picks up config file changes", func(t *testing.T) {
69+ // Create temp config file
70+ tmpDir := t.TempDir()
71+ configFile := filepath.Join(tmpDir, "gordon.toml")
72+ initialConfig := `[server]
73+port = 8080
74+
75+[routes]
76+"app.example.com" = "myapp:v1"
77+`
78+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
79+ require.NoError(t, err)
80+
81+ v := viper.New()
82+ v.SetConfigFile(configFile)
83+ err = v.ReadInConfig()
84+ require.NoError(t, err)
85+
86+ eventBus := mocks.NewMockEventPublisher(t)
87+ svc := NewService(v, eventBus)
88+ ctx := testContext()
89+
90+ // Initial load
91+ err = svc.Load(ctx)
92+ require.NoError(t, err)
93+ assert.Equal(t, 8080, svc.GetServerPort())
94+ assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
95+
96+ // Modify config file on disk
97+ updatedConfig := `[server]
98+port = 9090
99+
100+[routes]
101+"app.example.com" = "myapp:v2"
102+"new.example.com" = "newapp:latest"
103+`
104+ err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
105+ require.NoError(t, err)
106+
107+ // Reload should pick up new values
108+ err = svc.Reload(ctx)
109+ require.NoError(t, err)
110+
111+ assert.Equal(t, 9090, svc.GetServerPort())
112+ assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
113+ assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
114+ })
115+
116+ t.Run("error - config file not found", func(t *testing.T) {
117+ tmpDir := t.TempDir()
118+ configFile := filepath.Join(tmpDir, "gordon.toml")
119+
120+ // Create config, load it, then delete it
121+ err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
122+ require.NoError(t, err)
123+
124+ v := viper.New()
125+ v.SetConfigFile(configFile)
126+ err = v.ReadInConfig()
127+ require.NoError(t, err)
128+
129+ eventBus := mocks.NewMockEventPublisher(t)
130+ svc := NewService(v, eventBus)
131+ ctx := testContext()
132+
133+ err = svc.Load(ctx)
134+ require.NoError(t, err)
135+
136+ // Delete the config file
137+ err = os.Remove(configFile)
138+ require.NoError(t, err)
139+
140+ // Reload should fail
141+ err = svc.Reload(ctx)
142+ assert.Error(t, err)
143+ assert.Contains(t, err.Error(), "failed to read config file")
144+ })
145+
146+ t.Run("error - invalid config syntax", func(t *testing.T) {
147+ tmpDir := t.TempDir()
148+ configFile := filepath.Join(tmpDir, "gordon.toml")
149+
150+ // Create valid config initially
151+ err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
152+ require.NoError(t, err)
153+
154+ v := viper.New()
155+ v.SetConfigFile(configFile)
156+ err = v.ReadInConfig()
157+ require.NoError(t, err)
158+
159+ eventBus := mocks.NewMockEventPublisher(t)
160+ svc := NewService(v, eventBus)
161+ ctx := testContext()
162+
163+ err = svc.Load(ctx)
164+ require.NoError(t, err)
165+
166+ // Write invalid TOML
167+ err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
168+ require.NoError(t, err)
169+
170+ // Reload should fail
171+ err = svc.Reload(ctx)
172+ assert.Error(t, err)
173+ })
174+}
175+
67176 func TestService_GetRoutes(t *testing.T) {
68177 v := viper.New()
69178 v.Set("routes", map[string]interface{}{
@@ -64,6 +64,115 @@ func TestService_Load(t *testing.T) {
64 assert.False(t, preserve)64 assert.False(t, preserve)
65 }65 }
66 66
67+func TestService_Reload(t *testing.T) {
68+ t.Run("success - picks up config file changes", func(t *testing.T) {
69+ // Create temp config file
70+ tmpDir := t.TempDir()
71+ configFile := filepath.Join(tmpDir, "gordon.toml")
72+ initialConfig := `[server]
73+port = 8080
74+
75+[routes]
76+"app.example.com" = "myapp:v1"
77+`
78+ err := os.WriteFile(configFile, []byte(initialConfig), 0600)
79+ require.NoError(t, err)
80+
81+ v := viper.New()
82+ v.SetConfigFile(configFile)
83+ err = v.ReadInConfig()
84+ require.NoError(t, err)
85+
86+ eventBus := mocks.NewMockEventPublisher(t)
87+ svc := NewService(v, eventBus)
88+ ctx := testContext()
89+
90+ // Initial load
91+ err = svc.Load(ctx)
92+ require.NoError(t, err)
93+ assert.Equal(t, 8080, svc.GetServerPort())
94+ assert.Equal(t, "myapp:v1", svc.GetConfig().Routes["app.example.com"])
95+
96+ // Modify config file on disk
97+ updatedConfig := `[server]
98+port = 9090
99+
100+[routes]
101+"app.example.com" = "myapp:v2"
102+"new.example.com" = "newapp:latest"
103+`
104+ err = os.WriteFile(configFile, []byte(updatedConfig), 0600)
105+ require.NoError(t, err)
106+
107+ // Reload should pick up new values
108+ err = svc.Reload(ctx)
109+ require.NoError(t, err)
110+
111+ assert.Equal(t, 9090, svc.GetServerPort())
112+ assert.Equal(t, "myapp:v2", svc.GetConfig().Routes["app.example.com"])
113+ assert.Equal(t, "newapp:latest", svc.GetConfig().Routes["new.example.com"])
114+ })
115+
116+ t.Run("error - config file not found", func(t *testing.T) {
117+ tmpDir := t.TempDir()
118+ configFile := filepath.Join(tmpDir, "gordon.toml")
119+
120+ // Create config, load it, then delete it
121+ err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
122+ require.NoError(t, err)
123+
124+ v := viper.New()
125+ v.SetConfigFile(configFile)
126+ err = v.ReadInConfig()
127+ require.NoError(t, err)
128+
129+ eventBus := mocks.NewMockEventPublisher(t)
130+ svc := NewService(v, eventBus)
131+ ctx := testContext()
132+
133+ err = svc.Load(ctx)
134+ require.NoError(t, err)
135+
136+ // Delete the config file
137+ err = os.Remove(configFile)
138+ require.NoError(t, err)
139+
140+ // Reload should fail
141+ err = svc.Reload(ctx)
142+ assert.Error(t, err)
143+ assert.Contains(t, err.Error(), "failed to read config file")
144+ })
145+
146+ t.Run("error - invalid config syntax", func(t *testing.T) {
147+ tmpDir := t.TempDir()
148+ configFile := filepath.Join(tmpDir, "gordon.toml")
149+
150+ // Create valid config initially
151+ err := os.WriteFile(configFile, []byte("[server]\nport = 8080\n"), 0600)
152+ require.NoError(t, err)
153+
154+ v := viper.New()
155+ v.SetConfigFile(configFile)
156+ err = v.ReadInConfig()
157+ require.NoError(t, err)
158+
159+ eventBus := mocks.NewMockEventPublisher(t)
160+ svc := NewService(v, eventBus)
161+ ctx := testContext()
162+
163+ err = svc.Load(ctx)
164+ require.NoError(t, err)
165+
166+ // Write invalid TOML
167+ err = os.WriteFile(configFile, []byte("[server\nport = invalid syntax"), 0600)
168+ require.NoError(t, err)
169+
170+ // Reload should fail
171+ err = svc.Reload(ctx)
172+ assert.Error(t, err)
173+ })
174+}
175+
67 func TestService_GetRoutes(t *testing.T) {176 func TestService_GetRoutes(t *testing.T) {
68 v := viper.New()177 v := viper.New()
69 v.Set("routes", map[string]interface{}{178 v.Set("routes", map[string]interface{}{
M internal/usecase/proxy/service.go +10 -0
@@ -352,6 +352,16 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
352352 func(resp *http.Response) error {
353353 resp.Header.Set("X-Proxied-By", "Gordon")
354354 resp.Header.Set("X-Registry-Backend", "gordon-registry")
355+
356+ // Remove security headers from registry response to prevent duplicates.
357+ // The proxy middleware already adds these headers, so the registry's
358+ // copies would create duplicates in the final response.
359+ resp.Header.Del("X-Content-Type-Options")
360+ resp.Header.Del("X-Frame-Options")
361+ resp.Header.Del("X-XSS-Protection")
362+ resp.Header.Del("Referrer-Policy")
363+ resp.Header.Del("Permissions-Policy")
364+
355365 return nil
356366 },
357367 )
@@ -352,6 +352,16 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
352 func(resp *http.Response) error {352 func(resp *http.Response) error {
353 resp.Header.Set("X-Proxied-By", "Gordon")353 resp.Header.Set("X-Proxied-By", "Gordon")
354 resp.Header.Set("X-Registry-Backend", "gordon-registry")354 resp.Header.Set("X-Registry-Backend", "gordon-registry")
355+
356+ // Remove security headers from registry response to prevent duplicates.
357+ // The proxy middleware already adds these headers, so the registry's
358+ // copies would create duplicates in the final response.
359+ resp.Header.Del("X-Content-Type-Options")
360+ resp.Header.Del("X-Frame-Options")
361+ resp.Header.Del("X-XSS-Protection")
362+ resp.Header.Del("Referrer-Policy")
363+ resp.Header.Del("Permissions-Policy")
364+
355 return nil365 return nil
356 },366 },
357 )367 )
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{
M internal/usecase/secrets/service_test.go +103 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/stretchr/testify/assert"
1010 "github.com/stretchr/testify/mock"
1111
12+ "gordon/internal/boundaries/out"
1213 outmocks "gordon/internal/boundaries/out/mocks"
1314 )
1415
@@ -168,6 +169,108 @@ func TestService_ListKeys_Success(t *testing.T) {
168169 assert.Equal(t, expectedKeys, keys)
169170 }
170171
172+func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
173+ store := outmocks.NewMockDomainSecretStore(t)
174+ svc := NewService(store, testLogger())
175+
176+ tests := []struct {
177+ name string
178+ domain string
179+ wantErr error
180+ }{
181+ {
182+ name: "empty domain",
183+ domain: "",
184+ wantErr: ErrDomainEmpty,
185+ },
186+ {
187+ name: "path traversal",
188+ domain: "../etc/passwd",
189+ wantErr: ErrDomainPathTraversal,
190+ },
191+ {
192+ name: "domain too long",
193+ domain: strings.Repeat("a", 300),
194+ wantErr: ErrDomainTooLong,
195+ },
196+ }
197+
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), tt.domain)
201+ assert.Nil(t, keys)
202+ assert.Nil(t, attachments)
203+ assert.ErrorIs(t, err, tt.wantErr)
204+ })
205+ }
206+}
207+
208+func TestService_ListKeysWithAttachments_Success(t *testing.T) {
209+ t.Run("returns both domain and attachment secrets", func(t *testing.T) {
210+ store := outmocks.NewMockDomainSecretStore(t)
211+ svc := NewService(store, testLogger())
212+
213+ expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
214+ expectedAttachments := []out.AttachmentSecrets{
215+ {Service: "postgres", Keys: []string{"POSTGRES_PASSWORD"}},
216+ {Service: "redis", Keys: []string{"REDIS_PASSWORD", "REDIS_USER"}},
217+ }
218+
219+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
220+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(expectedAttachments, nil)
221+
222+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
223+ assert.NoError(t, err)
224+ assert.Equal(t, expectedKeys, keys)
225+ assert.Equal(t, expectedAttachments, attachments)
226+ })
227+
228+ t.Run("returns empty attachments when none exist", func(t *testing.T) {
229+ store := outmocks.NewMockDomainSecretStore(t)
230+ svc := NewService(store, testLogger())
231+
232+ expectedKeys := []string{"API_KEY"}
233+
234+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
235+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, nil)
236+
237+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
238+ assert.NoError(t, err)
239+ assert.Equal(t, expectedKeys, keys)
240+ assert.Nil(t, attachments)
241+ })
242+
243+ t.Run("continues without attachments on attachment error", func(t *testing.T) {
244+ store := outmocks.NewMockDomainSecretStore(t)
245+ svc := NewService(store, testLogger())
246+
247+ expectedKeys := []string{"API_KEY"}
248+ attachmentErr := assert.AnError
249+
250+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
251+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, attachmentErr)
252+
253+ // Should still succeed, just without attachments
254+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
255+ assert.NoError(t, err)
256+ assert.Equal(t, expectedKeys, keys)
257+ assert.Nil(t, attachments)
258+ })
259+}
260+
261+func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
262+ store := outmocks.NewMockDomainSecretStore(t)
263+ svc := NewService(store, testLogger())
264+
265+ storeErr := assert.AnError
266+ store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
267+
268+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
269+ assert.Nil(t, keys)
270+ assert.Nil(t, attachments)
271+ assert.ErrorIs(t, err, storeErr)
272+}
273+
171274 func TestService_GetAll_ValidationErrors(t *testing.T) {
172275 store := outmocks.NewMockDomainSecretStore(t)
173276 svc := NewService(store, testLogger())
@@ -9,6 +9,7 @@ import (
9 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 11
12+ "gordon/internal/boundaries/out"
12 outmocks "gordon/internal/boundaries/out/mocks"13 outmocks "gordon/internal/boundaries/out/mocks"
13 )14 )
14 15
@@ -168,6 +169,108 @@ func TestService_ListKeys_Success(t *testing.T) {
168 assert.Equal(t, expectedKeys, keys)169 assert.Equal(t, expectedKeys, keys)
169 }170 }
170 171
172+func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
173+ store := outmocks.NewMockDomainSecretStore(t)
174+ svc := NewService(store, testLogger())
175+
176+ tests := []struct {
177+ name string
178+ domain string
179+ wantErr error
180+ }{
181+ {
182+ name: "empty domain",
183+ domain: "",
184+ wantErr: ErrDomainEmpty,
185+ },
186+ {
187+ name: "path traversal",
188+ domain: "../etc/passwd",
189+ wantErr: ErrDomainPathTraversal,
190+ },
191+ {
192+ name: "domain too long",
193+ domain: strings.Repeat("a", 300),
194+ wantErr: ErrDomainTooLong,
195+ },
196+ }
197+
198+ for _, tt := range tests {
199+ t.Run(tt.name, func(t *testing.T) {
200+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), tt.domain)
201+ assert.Nil(t, keys)
202+ assert.Nil(t, attachments)
203+ assert.ErrorIs(t, err, tt.wantErr)
204+ })
205+ }
206+}
207+
208+func TestService_ListKeysWithAttachments_Success(t *testing.T) {
209+ t.Run("returns both domain and attachment secrets", func(t *testing.T) {
210+ store := outmocks.NewMockDomainSecretStore(t)
211+ svc := NewService(store, testLogger())
212+
213+ expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
214+ expectedAttachments := []out.AttachmentSecrets{
215+ {Service: "postgres", Keys: []string{"POSTGRES_PASSWORD"}},
216+ {Service: "redis", Keys: []string{"REDIS_PASSWORD", "REDIS_USER"}},
217+ }
218+
219+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
220+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(expectedAttachments, nil)
221+
222+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
223+ assert.NoError(t, err)
224+ assert.Equal(t, expectedKeys, keys)
225+ assert.Equal(t, expectedAttachments, attachments)
226+ })
227+
228+ t.Run("returns empty attachments when none exist", func(t *testing.T) {
229+ store := outmocks.NewMockDomainSecretStore(t)
230+ svc := NewService(store, testLogger())
231+
232+ expectedKeys := []string{"API_KEY"}
233+
234+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
235+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, nil)
236+
237+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
238+ assert.NoError(t, err)
239+ assert.Equal(t, expectedKeys, keys)
240+ assert.Nil(t, attachments)
241+ })
242+
243+ t.Run("continues without attachments on attachment error", func(t *testing.T) {
244+ store := outmocks.NewMockDomainSecretStore(t)
245+ svc := NewService(store, testLogger())
246+
247+ expectedKeys := []string{"API_KEY"}
248+ attachmentErr := assert.AnError
249+
250+ store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
251+ store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, attachmentErr)
252+
253+ // Should still succeed, just without attachments
254+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
255+ assert.NoError(t, err)
256+ assert.Equal(t, expectedKeys, keys)
257+ assert.Nil(t, attachments)
258+ })
259+}
260+
261+func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
262+ store := outmocks.NewMockDomainSecretStore(t)
263+ svc := NewService(store, testLogger())
264+
265+ storeErr := assert.AnError
266+ store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
267+
268+ keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
269+ assert.Nil(t, keys)
270+ assert.Nil(t, attachments)
271+ assert.ErrorIs(t, err, storeErr)
272+}
273+
171 func TestService_GetAll_ValidationErrors(t *testing.T) {274 func TestService_GetAll_ValidationErrors(t *testing.T) {
172 store := outmocks.NewMockDomainSecretStore(t)275 store := outmocks.NewMockDomainSecretStore(t)
173 svc := NewService(store, testLogger())276 svc := NewService(store, testLogger())

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