detached 71f5489

fix(secrets): address all Copilot review comments

- Add 'gordon-' prefix to resolveContainerName to match actual container naming
- Update test expectations to include 'gordon-' prefix in container names
- Fix documentation: use 'gordon deploy' instead of 'gordon routes deploy' (3 locations)
- Validate attachment path segments strictly: reject paths with extra parts
- Update ErrInvalidServiceName message to mention all validation rules
- Improve error handling for invalid attachment DELETE paths

This completes all 7 Copilot review comments.
commit
71f548904c144fb56adf1d444ca6cbff095cce7a
author
brice · · 2026-02-06 12:51 UTC
parent
bb9656b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +34 -17
M docs/cli/secrets.md +2 -2
@@ -183,7 +183,7 @@ gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL"
183183 gordon secrets set myapp.example.com API_KEY "$API_KEY"
184184
185185 # Deploy
186-gordon routes deploy myapp.example.com
186+gordon deploy myapp.example.com
187187 ```
188188
189189 ### Rotating Secrets
@@ -196,7 +196,7 @@ NEW_JWT_SECRET=$(openssl rand -base64 32)
196196 gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
197197
198198 # Redeploy to pick up new secret
199-gordon routes deploy myapp.local
199+gordon deploy myapp.local
200200 ```
201201
202202 ### Attachment Secrets
@@ -183,7 +183,7 @@ gordon secrets set myapp.example.com DATABASE_URL "$DATABASE_URL"
183 gordon secrets set myapp.example.com API_KEY "$API_KEY"183 gordon secrets set myapp.example.com API_KEY "$API_KEY"
184 184
185 # Deploy185 # Deploy
186-gordon routes deploy myapp.example.com186+gordon deploy myapp.example.com
187 ```187 ```
188 188
189 ### Rotating Secrets189 ### Rotating Secrets
@@ -196,7 +196,7 @@ NEW_JWT_SECRET=$(openssl rand -base64 32)
196 gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"196 gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
197 197
198 # Redeploy to pick up new secret198 # Redeploy to pick up new secret
199-gordon routes deploy myapp.local199+gordon deploy myapp.local
200 ```200 ```
201 201
202 ### Attachment Secrets202 ### Attachment Secrets
M internal/adapters/in/http/admin/handler.go +24 -7
@@ -455,14 +455,31 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
455455 secretDomain := parts[0]
456456
457457 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
458- if len(parts) >= 3 && parts[1] == "attachments" {
459- service := parts[2]
460- attachmentKey := ""
461- if len(parts) > 3 {
462- attachmentKey = parts[3]
458+ if len(parts) >= 2 && parts[1] == "attachments" {
459+ switch r.Method {
460+ case http.MethodPost:
461+ // Expected path: /secrets/{domain}/attachments/{service}
462+ if len(parts) != 3 {
463+ h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
464+ return
465+ }
466+ service := parts[2]
467+ h.handleAttachmentSecrets(w, r, secretDomain, service, "")
468+ return
469+ case http.MethodDelete:
470+ // Expected path: /secrets/{domain}/attachments/{service}/{key}
471+ if len(parts) != 4 {
472+ h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
473+ return
474+ }
475+ service := parts[2]
476+ attachmentKey := parts[3]
477+ h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
478+ return
479+ default:
480+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
481+ return
463482 }
464- h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
465- return
466483 }
467484
468485 secretKey := ""
@@ -455,14 +455,31 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
455 secretDomain := parts[0]455 secretDomain := parts[0]
456 456
457 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]457 // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
458- if len(parts) >= 3 && parts[1] == "attachments" {458+ if len(parts) >= 2 && parts[1] == "attachments" {
459- service := parts[2]459+ switch r.Method {
460- attachmentKey := ""460+ case http.MethodPost:
461- if len(parts) > 3 {461+ // Expected path: /secrets/{domain}/attachments/{service}
462- attachmentKey = parts[3]462+ if len(parts) != 3 {
463+ h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}")
464+ return
465+ }
466+ service := parts[2]
467+ h.handleAttachmentSecrets(w, r, secretDomain, service, "")
468+ return
469+ case http.MethodDelete:
470+ // Expected path: /secrets/{domain}/attachments/{service}/{key}
471+ if len(parts) != 4 {
472+ h.sendError(w, http.StatusBadRequest, "invalid attachment path: expected /secrets/{domain}/attachments/{service}/{key}")
473+ return
474+ }
475+ service := parts[2]
476+ attachmentKey := parts[3]
477+ h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
478+ return
479+ default:
480+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
481+ return
463 }482 }
464- h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
465- return
466 }483 }
467 484
468 secretKey := ""485 secretKey := ""
M internal/adapters/in/http/admin/handler_test.go +1 -1
@@ -1143,7 +1143,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
11431143 handler.ServeHTTP(rec, req)
11441144
11451145 assert.Equal(t, http.StatusBadRequest, rec.Code)
1146- assert.Contains(t, rec.Body.String(), "key required")
1146+ assert.Contains(t, rec.Body.String(), "invalid attachment path")
11471147 }
11481148
11491149 func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
@@ -1143,7 +1143,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1143 handler.ServeHTTP(rec, req)1143 handler.ServeHTTP(rec, req)
1144 1144
1145 assert.Equal(t, http.StatusBadRequest, rec.Code)1145 assert.Equal(t, http.StatusBadRequest, rec.Code)
1146- assert.Contains(t, rec.Body.String(), "key required")1146+ assert.Contains(t, rec.Body.String(), "invalid attachment path")
1147 }1147 }
1148 1148
1149 func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {1149 func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
M internal/usecase/secrets/service.go +5 -5
@@ -19,7 +19,7 @@ var (
1919 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
2020 ErrDomainInvalidChars = errors.New("domain contains invalid characters")
2121 ErrServiceEmpty = errors.New("service name cannot be empty")
22- ErrInvalidServiceName = errors.New("invalid service name: must start with a letter and contain only lowercase letters, numbers, and hyphens")
22+ ErrInvalidServiceName = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen")
2323 )
2424
2525 // Service implements the SecretService interface.
@@ -263,14 +263,14 @@ func validateServiceName(service string) error {
263263 // Ensures the total name does not exceed Docker's 255-character limit.
264264 func resolveContainerName(domainName, service string) string {
265265 sanitized := domain.SanitizeDomainForContainer(domainName)
266- name := sanitized + "-" + service
266+ name := "gordon-" + sanitized + "-" + service
267267
268268 // Docker container names are limited to 255 characters
269269 if len(name) > 255 {
270- // Truncate the domain part to fit, keeping room for "-" and service name
271- maxDomainLen := 255 - 1 - len(service)
270+ // Truncate the domain part to fit, keeping room for "gordon-", "-", and service name
271+ maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-"
272272 if maxDomainLen > 0 {
273- name = sanitized[:maxDomainLen] + "-" + service
273+ name = "gordon-" + sanitized[:maxDomainLen] + "-" + service
274274 }
275275 }
276276 return name
@@ -19,7 +19,7 @@ var (
19 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")19 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
20 ErrDomainInvalidChars = errors.New("domain contains invalid characters")20 ErrDomainInvalidChars = errors.New("domain contains invalid characters")
21 ErrServiceEmpty = errors.New("service name cannot be empty")21 ErrServiceEmpty = errors.New("service name cannot be empty")
22- ErrInvalidServiceName = errors.New("invalid service name: must start with a letter and contain only lowercase letters, numbers, and hyphens")22+ ErrInvalidServiceName = errors.New("invalid service name: must start with a letter, contain only lowercase letters, numbers, and hyphens, be at most 63 characters, and not end with a hyphen")
23 )23 )
24 24
25 // Service implements the SecretService interface.25 // Service implements the SecretService interface.
@@ -263,14 +263,14 @@ func validateServiceName(service string) error {
263 // Ensures the total name does not exceed Docker's 255-character limit.263 // Ensures the total name does not exceed Docker's 255-character limit.
264 func resolveContainerName(domainName, service string) string {264 func resolveContainerName(domainName, service string) string {
265 sanitized := domain.SanitizeDomainForContainer(domainName)265 sanitized := domain.SanitizeDomainForContainer(domainName)
266- name := sanitized + "-" + service266+ name := "gordon-" + sanitized + "-" + service
267 267
268 // Docker container names are limited to 255 characters268 // Docker container names are limited to 255 characters
269 if len(name) > 255 {269 if len(name) > 255 {
270- // Truncate the domain part to fit, keeping room for "-" and service name270+ // Truncate the domain part to fit, keeping room for "gordon-", "-", and service name
271- maxDomainLen := 255 - 1 - len(service)271+ maxDomainLen := 255 - 7 - 1 - len(service) // 7 for "gordon-", 1 for "-"
272 if maxDomainLen > 0 {272 if maxDomainLen > 0 {
273- name = sanitized[:maxDomainLen] + "-" + service273+ name = "gordon-" + sanitized[:maxDomainLen] + "-" + service
274 }274 }
275 }275 }
276 return name276 return name
M internal/usecase/secrets/service_test.go +2 -2
@@ -465,7 +465,7 @@ func TestService_SetAttachment(t *testing.T) {
465465 svc := NewService(store, testLogger())
466466
467467 secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
468- store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)
468+ store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil)
469469
470470 err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
471471 assert.NoError(t, err)
@@ -475,7 +475,7 @@ func TestService_DeleteAttachment(t *testing.T) {
475475 store := outmocks.NewMockDomainSecretStore(t)
476476 svc := NewService(store, testLogger())
477477
478- store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)
478+ store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil)
479479
480480 err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
481481 assert.NoError(t, err)
@@ -465,7 +465,7 @@ func TestService_SetAttachment(t *testing.T) {
465 svc := NewService(store, testLogger())465 svc := NewService(store, testLogger())
466 466
467 secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}467 secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
468- store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)468+ store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil)
469 469
470 err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)470 err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
471 assert.NoError(t, err)471 assert.NoError(t, err)
@@ -475,7 +475,7 @@ func TestService_DeleteAttachment(t *testing.T) {
475 store := outmocks.NewMockDomainSecretStore(t)475 store := outmocks.NewMockDomainSecretStore(t)
476 svc := NewService(store, testLogger())476 svc := NewService(store, testLogger())
477 477
478- store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)478+ store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil)
479 479
480 err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")480 err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
481 assert.NoError(t, err)481 assert.NoError(t, err)

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