detached 4102fdf

chore(plans): remove executed plan

commit
4102fdf4ed18a804a442070eabc06d8e71aaed87
author
brice · · 2026-02-06 12:58 UTC
parent
820201d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +0 -893
D docs/plans/2026-02-06-attachment-secrets.md +0 -893
@@ -1,893 +0,0 @@
1-# Attachment Secrets CLI Support — Implementation Plan
2-
3-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4-
5-**Goal:** Allow `gordon secrets set` and `gordon secrets remove` to target attachment containers via an `--attachment` flag, closing issue #56.
6-
7-**Architecture:** The storage layer (`DomainSecretStore.SetAttachment`) and file format (`gordon-{container}.env`) already exist. The gap is a missing `DeleteAttachment` in the store, missing attachment methods on the `SecretService` use case, a missing admin API endpoint for attachment secrets, and missing `--attachment` flag on CLI commands. We thread through all layers: storage → use case → admin API → remote client → CLI.
8-
9-**Tech Stack:** Go, cobra (CLI), mockery (mock generation), httptest
10-
11----
12-
13-### Task 1: Add `DeleteAttachment` to storage interface and implementation
14-
15-The `DomainSecretStore` has `SetAttachment` but no `DeleteAttachment`. We need it to remove individual keys from attachment env files.
16-
17-**Files:**
18-- Modify: `internal/boundaries/out/domain_secrets.go:13-36`
19-- Modify: `internal/adapters/out/domainsecrets/store.go` (add method after `SetAttachment`)
20-- Test: `internal/adapters/out/domainsecrets/store_test.go`
21-
22-**Step 1: Write the failing test**
23-
24-Add to `store_test.go`, near the existing `SetAttachment` tests (~line 229):
25-
26-```go
27-func TestFileStore_DeleteAttachment(t *testing.T) {
28- dir := t.TempDir()
29- store := NewFileStore(dir, testLogger())
30- containerName := "app__example__com-postgres"
31-
32- // Seed with two secrets
33- err := store.SetAttachment(containerName, map[string]string{
34- "POSTGRES_USER": "admin",
35- "POSTGRES_PASSWORD": "secret",
36- })
37- require.NoError(t, err)
38-
39- t.Run("deletes single key", func(t *testing.T) {
40- err := store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
41- require.NoError(t, err)
42-
43- secrets, err := store.GetAllAttachment(containerName)
44- require.NoError(t, err)
45- assert.Equal(t, map[string]string{"POSTGRES_USER": "admin"}, secrets)
46- })
47-
48- t.Run("deleting nonexistent key is no-op", func(t *testing.T) {
49- err := store.DeleteAttachment(containerName, "NONEXISTENT")
50- require.NoError(t, err)
51- })
52-}
53-```
54-
55-**Step 2: Run test to verify it fails**
56-
57-Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v`
58-Expected: FAIL — `store.DeleteAttachment` undefined
59-
60-**Step 3: Add to interface and implement**
61-
62-In `internal/boundaries/out/domain_secrets.go`, add to the `DomainSecretStore` interface:
63-
64-```go
65- // DeleteAttachment removes a specific secret key from an attachment container.
66- DeleteAttachment(containerName, key string) error
67-```
68-
69-In `internal/adapters/out/domainsecrets/store.go`, add after the `SetAttachment` method (~line 177):
70-
71-```go
72-// DeleteAttachment removes a specific secret key from an attachment container.
73-func (s *FileStore) DeleteAttachment(containerName, key string) error {
74- // Validate container name first
75- if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
76- return err
77- }
78-
79- // Read existing secrets
80- existing, err := s.GetAllAttachment(containerName)
81- if err != nil {
82- return err
83- }
84-
85- // Remove the key
86- delete(existing, key)
87-
88- // Write back atomically
89- return s.writeAttachmentSecretsAtomic(containerName, existing)
90-}
91-```
92-
93-**Step 4: Run test to verify it passes**
94-
95-Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v`
96-Expected: PASS
97-
98-**Step 5: Regenerate mocks**
99-
100-Run: `mockery`
101-Expected: Regenerates `internal/boundaries/out/mocks/mock_domain_secret_store.go` with new `DeleteAttachment` method.
102-
103-**Step 6: Run go fmt and full test suite**
104-
105-Run: `go fmt ./... && go test ./... -count=1`
106-Expected: ALL PASS
107-
108-**Step 7: Commit**
109-
110-```bash
111-git add internal/boundaries/out/domain_secrets.go internal/adapters/out/domainsecrets/store.go internal/adapters/out/domainsecrets/store_test.go internal/boundaries/out/mocks/mock_domain_secret_store.go
112-git commit -m "feat(secrets): add DeleteAttachment to storage layer
113-
114-Add ability to remove individual keys from attachment env files,
115-matching the existing SetAttachment capability."
116-```
117-
118----
119-
120-### Task 2: Add attachment methods to SecretService interface and use case
121-
122-The use case layer needs to resolve `(domain, service)` → container name and call through to the store.
123-
124-**Files:**
125-- Modify: `internal/boundaries/in/secrets.go:11-31`
126-- Modify: `internal/usecase/secrets/service.go`
127-- Test: `internal/usecase/secrets/service_test.go` (create if needed)
128-
129-**Step 1: Write the failing test**
130-
131-Create or add to `internal/usecase/secrets/service_test.go`:
132-
133-```go
134-package secrets
135-
136-import (
137- "context"
138- "testing"
139-
140- "github.com/bnema/zerowrap"
141- "github.com/stretchr/testify/assert"
142- "github.com/stretchr/testify/mock"
143-
144- outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
145-)
146-
147-func testLogger() zerowrap.Logger {
148- return zerowrap.Default()
149-}
150-
151-func TestService_SetAttachment(t *testing.T) {
152- store := outmocks.NewMockDomainSecretStore(t)
153- svc := NewService(store, testLogger())
154-
155- secrets := map[string]string{"POSTGRES_PASSWORD": "secret"}
156-
157- // domain "app.example.com" + service "postgres" → container name "app__example__com-postgres"
158- store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)
159-
160- err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
161- assert.NoError(t, err)
162-}
163-
164-func TestService_DeleteAttachment(t *testing.T) {
165- store := outmocks.NewMockDomainSecretStore(t)
166- svc := NewService(store, testLogger())
167-
168- store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)
169-
170- err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
171- assert.NoError(t, err)
172-}
173-
174-func TestService_SetAttachment_InvalidDomain(t *testing.T) {
175- store := outmocks.NewMockDomainSecretStore(t)
176- svc := NewService(store, testLogger())
177-
178- err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"K": "V"})
179- assert.Error(t, err)
180-}
181-
182-func TestService_SetAttachment_EmptyService(t *testing.T) {
183- store := outmocks.NewMockDomainSecretStore(t)
184- svc := NewService(store, testLogger())
185-
186- err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"K": "V"})
187- assert.ErrorIs(t, err, ErrServiceEmpty)
188-}
189-```
190-
191-**Step 2: Run test to verify it fails**
192-
193-Run: `go test ./internal/usecase/secrets/... -count=1 -run "TestService_SetAttachment|TestService_DeleteAttachment" -v`
194-Expected: FAIL — `svc.SetAttachment` undefined
195-
196-**Step 3: Add to interface and implement**
197-
198-In `internal/boundaries/in/secrets.go`, add to the `SecretService` interface:
199-
200-```go
201- // SetAttachment sets or updates secrets for an attachment container.
202- // Resolves the container name from domain + service name.
203- SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error
204-
205- // DeleteAttachment removes a secret key from an attachment container.
206- // Resolves the container name from domain + service name.
207- DeleteAttachment(ctx context.Context, domain, service, key string) error
208-```
209-
210-In `internal/usecase/secrets/service.go`, add the error variable and methods:
211-
212-```go
213-var ErrServiceEmpty = errors.New("service name cannot be empty")
214-```
215-
216-Add the import for the `domain` package:
217-
218-```go
219-import (
220- // ... existing imports ...
221- "github.com/bnema/gordon/internal/domain"
222-)
223-```
224-
225-Add the methods:
226-
227-```go
228-// SetAttachment sets or updates secrets for an attachment container.
229-func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
230- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
231- zerowrap.FieldLayer: "usecase",
232- zerowrap.FieldUseCase: "SetAttachment",
233- "domain": domainName,
234- "service": service,
235- })
236- log := zerowrap.FromCtx(ctx)
237-
238- if err := ValidateDomain(domainName); err != nil {
239- log.Warn().Err(err).Msg("domain validation failed")
240- return err
241- }
242- if service == "" {
243- return ErrServiceEmpty
244- }
245-
246- containerName := resolveContainerName(domainName, service)
247- if err := s.store.SetAttachment(containerName, secrets); err != nil {
248- log.Error().Err(err).Str("container", containerName).Msg("failed to set attachment secrets")
249- return err
250- }
251-
252- log.Info().Str("container", containerName).Int("count", len(secrets)).Msg("attachment secrets set")
253- return nil
254-}
255-
256-// DeleteAttachment removes a secret key from an attachment container.
257-func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
258- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
259- zerowrap.FieldLayer: "usecase",
260- zerowrap.FieldUseCase: "DeleteAttachment",
261- "domain": domainName,
262- "service": service,
263- "key": key,
264- })
265- log := zerowrap.FromCtx(ctx)
266-
267- if err := ValidateDomain(domainName); err != nil {
268- log.Warn().Err(err).Msg("domain validation failed")
269- return err
270- }
271- if service == "" {
272- return ErrServiceEmpty
273- }
274-
275- containerName := resolveContainerName(domainName, service)
276- if err := s.store.DeleteAttachment(containerName, key); err != nil {
277- log.Error().Err(err).Str("container", containerName).Msg("failed to delete attachment secret")
278- return err
279- }
280-
281- log.Info().Str("container", containerName).Msg("attachment secret deleted")
282- return nil
283-}
284-
285-// resolveContainerName builds the attachment container name from domain and service.
286-// Must match the naming convention in container service's deployAttachedService().
287-func resolveContainerName(domainName, service string) string {
288- return domain.SanitizeDomainForContainer(domainName) + "-" + service
289-}
290-```
291-
292-**Step 4: Run test to verify it passes**
293-
294-Run: `go test ./internal/usecase/secrets/... -count=1 -v`
295-Expected: ALL PASS
296-
297-**Step 5: Regenerate mocks**
298-
299-Run: `mockery`
300-Expected: Regenerates `internal/boundaries/in/mocks/mock_secret_service.go` with new methods.
301-
302-**Step 6: Run go fmt and full test suite**
303-
304-Run: `go fmt ./... && go test ./... -count=1`
305-Expected: ALL PASS
306-
307-**Step 7: Commit**
308-
309-```bash
310-git add internal/boundaries/in/secrets.go internal/usecase/secrets/service.go internal/usecase/secrets/service_test.go internal/boundaries/in/mocks/mock_secret_service.go
311-git commit -m "feat(secrets): add SetAttachment/DeleteAttachment to use case layer
312-
313-Resolves (domain, service) to container name and delegates to
314-the storage layer. Matches container naming from deployAttachedService()."
315-```
316-
317----
318-
319-### Task 3: Add admin API endpoint for attachment secrets
320-
321-Add `/admin/secrets/{domain}/attachments/{service}` routes for POST (set) and DELETE (with key).
322-
323-**Files:**
324-- Modify: `internal/adapters/in/http/admin/handler.go:446-518`
325-- Test: `internal/adapters/in/http/admin/handler_test.go`
326-
327-**Step 1: Write the failing tests**
328-
329-Add to `handler_test.go`:
330-
331-```go
332-func TestHandler_AttachmentSecretsPost(t *testing.T) {
333- configSvc := inmocks.NewMockConfigService(t)
334- authSvc := inmocks.NewMockAuthService(t)
335- containerSvc := inmocks.NewMockContainerService(t)
336- secretSvc := inmocks.NewMockSecretService(t)
337-
338- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
339-
340- secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil)
341-
342- body := `{"POSTGRES_PASSWORD": "secret"}`
343- req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/postgres", bytes.NewBufferString(body))
344- req = req.WithContext(ctxWithScopes("admin:secrets:write"))
345- rec := httptest.NewRecorder()
346-
347- handler.ServeHTTP(rec, req)
348-
349- assert.Equal(t, http.StatusOK, rec.Code)
350-}
351-
352-func TestHandler_AttachmentSecretsDelete(t *testing.T) {
353- configSvc := inmocks.NewMockConfigService(t)
354- authSvc := inmocks.NewMockAuthService(t)
355- containerSvc := inmocks.NewMockContainerService(t)
356- secretSvc := inmocks.NewMockSecretService(t)
357-
358- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
359-
360- secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "postgres", "OLD_KEY").Return(nil)
361-
362- req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/postgres/OLD_KEY", nil)
363- req = req.WithContext(ctxWithScopes("admin:secrets:write"))
364- rec := httptest.NewRecorder()
365-
366- handler.ServeHTTP(rec, req)
367-
368- assert.Equal(t, http.StatusOK, rec.Code)
369-}
370-```
371-
372-**Step 2: Run tests to verify they fail**
373-
374-Run: `go test ./internal/adapters/in/http/admin/... -count=1 -run "TestHandler_AttachmentSecrets" -v`
375-Expected: FAIL — routes not recognized, 400 or 404
376-
377-**Step 3: Implement the endpoint**
378-
379-In `handler.go`, modify the `handleSecrets` function. Replace the path parsing section (lines 451-462) to detect the `/attachments/` sub-path:
380-
381-```go
382-func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
383- ctx := r.Context()
384- log := zerowrap.FromCtx(ctx)
385-
386- // Parse path: /secrets/{domain}, /secrets/{domain}/{key},
387- // or /secrets/{domain}/attachments/{service}[/{key}]
388- trimmed := strings.TrimPrefix(path, "/secrets/")
389- parts := strings.Split(trimmed, "/")
390- if len(parts) == 0 || parts[0] == "" {
391- h.sendError(w, http.StatusBadRequest, "domain required")
392- return
393- }
394-
395- secretDomain := parts[0]
396-
397- // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
398- if len(parts) >= 3 && parts[1] == "attachments" {
399- service := parts[2]
400- attachmentKey := ""
401- if len(parts) > 3 {
402- attachmentKey = parts[3]
403- }
404- h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
405- return
406- }
407-
408- secretKey := ""
409- if len(parts) > 1 {
410- secretKey = parts[1]
411- }
412-
413- switch r.Method {
414- case http.MethodGet:
415- h.handleSecretsGet(w, r, secretDomain)
416-
417- case http.MethodPost:
418- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
419- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
420- return
421- }
422-
423- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
424-
425- var data map[string]string
426- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
427- log.Warn().Err(err).Msg("invalid secrets JSON")
428- h.sendError(w, http.StatusBadRequest, "invalid JSON")
429- return
430- }
431-
432- if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
433- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
434- h.sendError(w, http.StatusBadRequest, "invalid domain")
435- return
436- }
437-
438- log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
439- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
440-
441- case http.MethodDelete:
442- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
443- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
444- return
445- }
446- if secretKey == "" {
447- h.sendError(w, http.StatusBadRequest, "key required in path")
448- return
449- }
450-
451- if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
452- log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
453- h.sendError(w, http.StatusBadRequest, "invalid domain")
454- return
455- }
456-
457- log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
458- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
459-
460- default:
461- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
462- }
463-}
464-```
465-
466-Add the new handler method:
467-
468-```go
469-// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}].
470-func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
471- ctx := r.Context()
472- log := zerowrap.FromCtx(ctx)
473-
474- switch r.Method {
475- case http.MethodPost:
476- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
477- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
478- return
479- }
480-
481- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
482-
483- var data map[string]string
484- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
485- log.Warn().Err(err).Msg("invalid attachment secrets JSON")
486- h.sendError(w, http.StatusBadRequest, "invalid JSON")
487- return
488- }
489-
490- if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
491- log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
492- h.sendError(w, http.StatusBadRequest, err.Error())
493- return
494- }
495-
496- log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
497- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
498-
499- case http.MethodDelete:
500- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
501- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
502- return
503- }
504- if key == "" {
505- h.sendError(w, http.StatusBadRequest, "key required in path")
506- return
507- }
508-
509- if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
510- log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
511- h.sendError(w, http.StatusBadRequest, err.Error())
512- return
513- }
514-
515- log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
516- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
517-
518- default:
519- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
520- }
521-}
522-```
523-
524-**Step 4: Run tests to verify they pass**
525-
526-Run: `go test ./internal/adapters/in/http/admin/... -count=1 -v`
527-Expected: ALL PASS (both new and existing tests)
528-
529-**Step 5: Run go fmt**
530-
531-Run: `go fmt ./internal/adapters/in/http/admin/...`
532-
533-**Step 6: Commit**
534-
535-```bash
536-git add internal/adapters/in/http/admin/handler.go internal/adapters/in/http/admin/handler_test.go
537-git commit -m "feat(api): add attachment secrets endpoints
538-
539-POST /admin/secrets/{domain}/attachments/{service} to set secrets.
540-DELETE /admin/secrets/{domain}/attachments/{service}/{key} to remove."
541-```
542-
543----
544-
545-### Task 4: Add remote client methods
546-
547-**Files:**
548-- Modify: `internal/adapters/in/cli/remote/client.go` (after `DeleteSecret` ~line 296)
549-
550-**Step 1: Implement the client methods**
551-
552-No separate test file exists for the remote client (it's tested via integration). Add the methods:
553-
554-```go
555-// SetAttachmentSecrets sets secrets for an attachment container.
556-func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
557- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
558- resp, err := c.request(ctx, http.MethodPost, path, secrets)
559- if err != nil {
560- return err
561- }
562- return parseResponse(resp, nil)
563-}
564-
565-// DeleteAttachmentSecret removes a secret from an attachment container.
566-func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
567- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
568- resp, err := c.request(ctx, http.MethodDelete, path, nil)
569- if err != nil {
570- return err
571- }
572- return parseResponse(resp, nil)
573-}
574-```
575-
576-**Step 2: Run go fmt and verify build**
577-
578-Run: `go fmt ./internal/adapters/in/cli/remote/... && go build ./...`
579-Expected: Clean build
580-
581-**Step 3: Commit**
582-
583-```bash
584-git add internal/adapters/in/cli/remote/client.go
585-git commit -m "feat(client): add attachment secrets API methods"
586-```
587-
588----
589-
590-### Task 5: Add `--attachment` flag to `secrets set` and `secrets remove` CLI commands
591-
592-**Files:**
593-- Modify: `internal/adapters/in/cli/secrets.go:214-270` (set command)
594-- Modify: `internal/adapters/in/cli/secrets.go:272-327` (remove command)
595-
596-**Step 1: Write the failing test for flag parsing**
597-
598-Add to `internal/adapters/in/cli/secrets_test.go` (create file):
599-
600-```go
601-package cli
602-
603-import (
604- "testing"
605-
606- "github.com/stretchr/testify/assert"
607-)
608-
609-func TestResolveContainerName(t *testing.T) {
610- tests := []struct {
611- domain string
612- service string
613- want string
614- }{
615- {"app.example.com", "postgres", "app__example__com-postgres"},
616- {"git.example.com", "redis", "git__example__com-redis"},
617- }
618-
619- for _, tt := range tests {
620- t.Run(tt.domain+"/"+tt.service, func(t *testing.T) {
621- got := resolveAttachmentContainerName(tt.domain, tt.service)
622- assert.Equal(t, tt.want, got)
623- })
624- }
625-}
626-```
627-
628-**Step 2: Run test to verify it fails**
629-
630-Run: `go test ./internal/adapters/in/cli/... -count=1 -run TestResolveContainerName -v`
631-Expected: FAIL — `resolveAttachmentContainerName` undefined
632-
633-**Step 3: Implement**
634-
635-In `secrets.go`, add the helper:
636-
637-```go
638-// resolveAttachmentContainerName builds the container name from domain and service.
639-// Must match the naming in usecase/container deployAttachedService() and usecase/secrets resolveContainerName().
640-func resolveAttachmentContainerName(domainName, service string) string {
641- return domain.SanitizeDomainForContainer(domainName) + "-" + service
642-}
643-```
644-
645-Add the import for the domain package at the top of `secrets.go`:
646-
647-```go
648-import (
649- // ... existing imports ...
650- "github.com/bnema/gordon/internal/domain"
651-)
652-```
653-
654-Now modify `newSecretsSetCmd()` to add the `--attachment` flag. Replace the function:
655-
656-```go
657-func newSecretsSetCmd() *cobra.Command {
658- var attachment string
659-
660- cmd := &cobra.Command{
661- Use: "set <domain> <KEY=value>...",
662- Short: "Set secrets for a domain or attachment",
663- Long: `Set one or more secrets for a domain or an attachment container.
664-
665-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
666-
667-Use --attachment to target an attachment service (e.g., postgres, redis) instead
668-of the main domain container.
669-
670-Examples:
671- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
672- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
673- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
674- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
675- Args: cobra.MinimumNArgs(2),
676- RunE: func(cmd *cobra.Command, args []string) error {
677- ctx := context.Background()
678- secretDomain := args[0]
679- pairs := args[1:]
680-
681- // Parse KEY=value pairs
682- secrets := make(map[string]string)
683- for _, pair := range pairs {
684- parts := strings.SplitN(pair, "=", 2)
685- if len(parts) != 2 {
686- fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
687- return nil
688- }
689- secrets[parts[0]] = parts[1]
690- }
691-
692- client, isRemote := GetRemoteClient()
693- if isRemote {
694- if attachment != "" {
695- if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
696- return fmt.Errorf("failed to set attachment secrets: %w", err)
697- }
698- } else {
699- if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
700- return fmt.Errorf("failed to set secrets: %w", err)
701- }
702- }
703- } else {
704- local, err := GetLocalServices(configPath)
705- if err != nil {
706- return fmt.Errorf("failed to initialize local services: %w", err)
707- }
708- if attachment != "" {
709- if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil {
710- return fmt.Errorf("failed to set attachment secrets: %w", err)
711- }
712- } else {
713- if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
714- return fmt.Errorf("failed to set secrets: %w", err)
715- }
716- }
717- }
718-
719- target := secretDomain
720- if attachment != "" {
721- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
722- }
723-
724- if len(secrets) == 1 {
725- for key := range secrets {
726- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
727- }
728- } else {
729- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
730- }
731-
732- return nil
733- },
734- }
735-
736- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
737-
738- return cmd
739-}
740-```
741-
742-Now modify `newSecretsRemoveCmd()` similarly:
743-
744-```go
745-func newSecretsRemoveCmd() *cobra.Command {
746- var (
747- force bool
748- attachment string
749- )
750-
751- cmd := &cobra.Command{
752- Use: "remove <domain> <key>",
753- Short: "Remove a secret",
754- Long: `Remove a secret from a domain or attachment container.
755-
756-Use --attachment to target an attachment service instead of the main domain.
757-
758-Examples:
759- gordon secrets remove app.mydomain.com OLD_API_KEY
760- gordon secrets remove app.mydomain.com OLD_API_KEY --force
761- gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
762- Args: cobra.ExactArgs(2),
763- RunE: func(cmd *cobra.Command, args []string) error {
764- ctx := context.Background()
765- secretDomain := args[0]
766- key := args[1]
767-
768- target := secretDomain
769- if attachment != "" {
770- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
771- }
772-
773- // Confirm unless --force
774- if !force {
775- confirmed, err := components.RunConfirm(
776- fmt.Sprintf("Remove secret '%s' from %s?", key, target),
777- )
778- if err != nil {
779- return err
780- }
781- if !confirmed {
782- fmt.Println(styles.Theme.Muted.Render("Cancelled"))
783- return nil
784- }
785- }
786-
787- client, isRemote := GetRemoteClient()
788- if isRemote {
789- if attachment != "" {
790- if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
791- return fmt.Errorf("failed to remove attachment secret: %w", err)
792- }
793- } else {
794- if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
795- return fmt.Errorf("failed to remove secret: %w", err)
796- }
797- }
798- } else {
799- local, err := GetLocalServices(configPath)
800- if err != nil {
801- return fmt.Errorf("failed to initialize local services: %w", err)
802- }
803- if attachment != "" {
804- if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil {
805- return fmt.Errorf("failed to remove attachment secret: %w", err)
806- }
807- } else {
808- if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
809- return fmt.Errorf("failed to remove secret: %w", err)
810- }
811- }
812- }
813-
814- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s from %s", key, target)))
815- return nil
816- },
817- }
818-
819- cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
820- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
821-
822- return cmd
823-}
824-```
825-
826-**Step 4: Run tests to verify they pass**
827-
828-Run: `go test ./internal/adapters/in/cli/... -count=1 -v`
829-Expected: ALL PASS
830-
831-**Step 5: Run go fmt and full suite**
832-
833-Run: `go fmt ./... && go test ./... -count=1`
834-Expected: ALL PASS
835-
836-**Step 6: Commit**
837-
838-```bash
839-git add internal/adapters/in/cli/secrets.go internal/adapters/in/cli/secrets_test.go
840-git commit -m "feat(cli): add --attachment flag to secrets set/remove
841-
842-Closes #56. Users can now configure attachment secrets via:
843- gordon secrets set <domain> --attachment <service> KEY=value
844- gordon secrets remove <domain> --attachment <service> KEY"
845-```
846-
847----
848-
849-### Task 6: Update secrets command description and verify end-to-end
850-
851-**Files:**
852-- Modify: `internal/adapters/in/cli/secrets.go:16-33` (parent command description)
853-
854-**Step 1: Update parent command long description**
855-
856-```go
857-func newSecretsCmd() *cobra.Command {
858- cmd := &cobra.Command{
859- Use: "secrets",
860- Short: "Manage secrets",
861- Long: `Manage secrets (environment variables) for routes and attachments.
862-
863-Secrets are stored per-domain and injected into containers as environment variables.
864-Use --attachment to target attachment containers (databases, caches, etc.).
865-
866-When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
867-these commands operate on the remote server.`,
868- }
869-
870- cmd.AddCommand(newSecretsListCmd())
871- cmd.AddCommand(newSecretsSetCmd())
872- cmd.AddCommand(newSecretsRemoveCmd())
873-
874- return cmd
875-}
876-```
877-
878-**Step 2: Run full test suite**
879-
880-Run: `go test ./... -count=1`
881-Expected: ALL PASS
882-
883-**Step 3: Run go vet and build**
884-
885-Run: `go vet ./... && go build ./...`
886-Expected: Clean
887-
888-**Step 4: Commit**
889-
890-```bash
891-git add internal/adapters/in/cli/secrets.go
892-git commit -m "docs(cli): update secrets command description for attachments"
893-```
@@ -1,893 +0,0 @@
1-# Attachment Secrets CLI Support — Implementation Plan
2-
3-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4-
5-**Goal:** Allow `gordon secrets set` and `gordon secrets remove` to target attachment containers via an `--attachment` flag, closing issue #56.
6-
7-**Architecture:** The storage layer (`DomainSecretStore.SetAttachment`) and file format (`gordon-{container}.env`) already exist. The gap is a missing `DeleteAttachment` in the store, missing attachment methods on the `SecretService` use case, a missing admin API endpoint for attachment secrets, and missing `--attachment` flag on CLI commands. We thread through all layers: storage → use case → admin API → remote client → CLI.
8-
9-**Tech Stack:** Go, cobra (CLI), mockery (mock generation), httptest
10-
11----
12-
13-### Task 1: Add `DeleteAttachment` to storage interface and implementation
14-
15-The `DomainSecretStore` has `SetAttachment` but no `DeleteAttachment`. We need it to remove individual keys from attachment env files.
16-
17-**Files:**
18-- Modify: `internal/boundaries/out/domain_secrets.go:13-36`
19-- Modify: `internal/adapters/out/domainsecrets/store.go` (add method after `SetAttachment`)
20-- Test: `internal/adapters/out/domainsecrets/store_test.go`
21-
22-**Step 1: Write the failing test**
23-
24-Add to `store_test.go`, near the existing `SetAttachment` tests (~line 229):
25-
26-```go
27-func TestFileStore_DeleteAttachment(t *testing.T) {
28- dir := t.TempDir()
29- store := NewFileStore(dir, testLogger())
30- containerName := "app__example__com-postgres"
31-
32- // Seed with two secrets
33- err := store.SetAttachment(containerName, map[string]string{
34- "POSTGRES_USER": "admin",
35- "POSTGRES_PASSWORD": "secret",
36- })
37- require.NoError(t, err)
38-
39- t.Run("deletes single key", func(t *testing.T) {
40- err := store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
41- require.NoError(t, err)
42-
43- secrets, err := store.GetAllAttachment(containerName)
44- require.NoError(t, err)
45- assert.Equal(t, map[string]string{"POSTGRES_USER": "admin"}, secrets)
46- })
47-
48- t.Run("deleting nonexistent key is no-op", func(t *testing.T) {
49- err := store.DeleteAttachment(containerName, "NONEXISTENT")
50- require.NoError(t, err)
51- })
52-}
53-```
54-
55-**Step 2: Run test to verify it fails**
56-
57-Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v`
58-Expected: FAIL — `store.DeleteAttachment` undefined
59-
60-**Step 3: Add to interface and implement**
61-
62-In `internal/boundaries/out/domain_secrets.go`, add to the `DomainSecretStore` interface:
63-
64-```go
65- // DeleteAttachment removes a specific secret key from an attachment container.
66- DeleteAttachment(containerName, key string) error
67-```
68-
69-In `internal/adapters/out/domainsecrets/store.go`, add after the `SetAttachment` method (~line 177):
70-
71-```go
72-// DeleteAttachment removes a specific secret key from an attachment container.
73-func (s *FileStore) DeleteAttachment(containerName, key string) error {
74- // Validate container name first
75- if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
76- return err
77- }
78-
79- // Read existing secrets
80- existing, err := s.GetAllAttachment(containerName)
81- if err != nil {
82- return err
83- }
84-
85- // Remove the key
86- delete(existing, key)
87-
88- // Write back atomically
89- return s.writeAttachmentSecretsAtomic(containerName, existing)
90-}
91-```
92-
93-**Step 4: Run test to verify it passes**
94-
95-Run: `go test ./internal/adapters/out/domainsecrets/... -count=1 -run TestFileStore_DeleteAttachment -v`
96-Expected: PASS
97-
98-**Step 5: Regenerate mocks**
99-
100-Run: `mockery`
101-Expected: Regenerates `internal/boundaries/out/mocks/mock_domain_secret_store.go` with new `DeleteAttachment` method.
102-
103-**Step 6: Run go fmt and full test suite**
104-
105-Run: `go fmt ./... && go test ./... -count=1`
106-Expected: ALL PASS
107-
108-**Step 7: Commit**
109-
110-```bash
111-git add internal/boundaries/out/domain_secrets.go internal/adapters/out/domainsecrets/store.go internal/adapters/out/domainsecrets/store_test.go internal/boundaries/out/mocks/mock_domain_secret_store.go
112-git commit -m "feat(secrets): add DeleteAttachment to storage layer
113-
114-Add ability to remove individual keys from attachment env files,
115-matching the existing SetAttachment capability."
116-```
117-
118----
119-
120-### Task 2: Add attachment methods to SecretService interface and use case
121-
122-The use case layer needs to resolve `(domain, service)` → container name and call through to the store.
123-
124-**Files:**
125-- Modify: `internal/boundaries/in/secrets.go:11-31`
126-- Modify: `internal/usecase/secrets/service.go`
127-- Test: `internal/usecase/secrets/service_test.go` (create if needed)
128-
129-**Step 1: Write the failing test**
130-
131-Create or add to `internal/usecase/secrets/service_test.go`:
132-
133-```go
134-package secrets
135-
136-import (
137- "context"
138- "testing"
139-
140- "github.com/bnema/zerowrap"
141- "github.com/stretchr/testify/assert"
142- "github.com/stretchr/testify/mock"
143-
144- outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
145-)
146-
147-func testLogger() zerowrap.Logger {
148- return zerowrap.Default()
149-}
150-
151-func TestService_SetAttachment(t *testing.T) {
152- store := outmocks.NewMockDomainSecretStore(t)
153- svc := NewService(store, testLogger())
154-
155- secrets := map[string]string{"POSTGRES_PASSWORD": "secret"}
156-
157- // domain "app.example.com" + service "postgres" → container name "app__example__com-postgres"
158- store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)
159-
160- err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
161- assert.NoError(t, err)
162-}
163-
164-func TestService_DeleteAttachment(t *testing.T) {
165- store := outmocks.NewMockDomainSecretStore(t)
166- svc := NewService(store, testLogger())
167-
168- store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)
169-
170- err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
171- assert.NoError(t, err)
172-}
173-
174-func TestService_SetAttachment_InvalidDomain(t *testing.T) {
175- store := outmocks.NewMockDomainSecretStore(t)
176- svc := NewService(store, testLogger())
177-
178- err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"K": "V"})
179- assert.Error(t, err)
180-}
181-
182-func TestService_SetAttachment_EmptyService(t *testing.T) {
183- store := outmocks.NewMockDomainSecretStore(t)
184- svc := NewService(store, testLogger())
185-
186- err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"K": "V"})
187- assert.ErrorIs(t, err, ErrServiceEmpty)
188-}
189-```
190-
191-**Step 2: Run test to verify it fails**
192-
193-Run: `go test ./internal/usecase/secrets/... -count=1 -run "TestService_SetAttachment|TestService_DeleteAttachment" -v`
194-Expected: FAIL — `svc.SetAttachment` undefined
195-
196-**Step 3: Add to interface and implement**
197-
198-In `internal/boundaries/in/secrets.go`, add to the `SecretService` interface:
199-
200-```go
201- // SetAttachment sets or updates secrets for an attachment container.
202- // Resolves the container name from domain + service name.
203- SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error
204-
205- // DeleteAttachment removes a secret key from an attachment container.
206- // Resolves the container name from domain + service name.
207- DeleteAttachment(ctx context.Context, domain, service, key string) error
208-```
209-
210-In `internal/usecase/secrets/service.go`, add the error variable and methods:
211-
212-```go
213-var ErrServiceEmpty = errors.New("service name cannot be empty")
214-```
215-
216-Add the import for the `domain` package:
217-
218-```go
219-import (
220- // ... existing imports ...
221- "github.com/bnema/gordon/internal/domain"
222-)
223-```
224-
225-Add the methods:
226-
227-```go
228-// SetAttachment sets or updates secrets for an attachment container.
229-func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
230- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
231- zerowrap.FieldLayer: "usecase",
232- zerowrap.FieldUseCase: "SetAttachment",
233- "domain": domainName,
234- "service": service,
235- })
236- log := zerowrap.FromCtx(ctx)
237-
238- if err := ValidateDomain(domainName); err != nil {
239- log.Warn().Err(err).Msg("domain validation failed")
240- return err
241- }
242- if service == "" {
243- return ErrServiceEmpty
244- }
245-
246- containerName := resolveContainerName(domainName, service)
247- if err := s.store.SetAttachment(containerName, secrets); err != nil {
248- log.Error().Err(err).Str("container", containerName).Msg("failed to set attachment secrets")
249- return err
250- }
251-
252- log.Info().Str("container", containerName).Int("count", len(secrets)).Msg("attachment secrets set")
253- return nil
254-}
255-
256-// DeleteAttachment removes a secret key from an attachment container.
257-func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
258- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
259- zerowrap.FieldLayer: "usecase",
260- zerowrap.FieldUseCase: "DeleteAttachment",
261- "domain": domainName,
262- "service": service,
263- "key": key,
264- })
265- log := zerowrap.FromCtx(ctx)
266-
267- if err := ValidateDomain(domainName); err != nil {
268- log.Warn().Err(err).Msg("domain validation failed")
269- return err
270- }
271- if service == "" {
272- return ErrServiceEmpty
273- }
274-
275- containerName := resolveContainerName(domainName, service)
276- if err := s.store.DeleteAttachment(containerName, key); err != nil {
277- log.Error().Err(err).Str("container", containerName).Msg("failed to delete attachment secret")
278- return err
279- }
280-
281- log.Info().Str("container", containerName).Msg("attachment secret deleted")
282- return nil
283-}
284-
285-// resolveContainerName builds the attachment container name from domain and service.
286-// Must match the naming convention in container service's deployAttachedService().
287-func resolveContainerName(domainName, service string) string {
288- return domain.SanitizeDomainForContainer(domainName) + "-" + service
289-}
290-```
291-
292-**Step 4: Run test to verify it passes**
293-
294-Run: `go test ./internal/usecase/secrets/... -count=1 -v`
295-Expected: ALL PASS
296-
297-**Step 5: Regenerate mocks**
298-
299-Run: `mockery`
300-Expected: Regenerates `internal/boundaries/in/mocks/mock_secret_service.go` with new methods.
301-
302-**Step 6: Run go fmt and full test suite**
303-
304-Run: `go fmt ./... && go test ./... -count=1`
305-Expected: ALL PASS
306-
307-**Step 7: Commit**
308-
309-```bash
310-git add internal/boundaries/in/secrets.go internal/usecase/secrets/service.go internal/usecase/secrets/service_test.go internal/boundaries/in/mocks/mock_secret_service.go
311-git commit -m "feat(secrets): add SetAttachment/DeleteAttachment to use case layer
312-
313-Resolves (domain, service) to container name and delegates to
314-the storage layer. Matches container naming from deployAttachedService()."
315-```
316-
317----
318-
319-### Task 3: Add admin API endpoint for attachment secrets
320-
321-Add `/admin/secrets/{domain}/attachments/{service}` routes for POST (set) and DELETE (with key).
322-
323-**Files:**
324-- Modify: `internal/adapters/in/http/admin/handler.go:446-518`
325-- Test: `internal/adapters/in/http/admin/handler_test.go`
326-
327-**Step 1: Write the failing tests**
328-
329-Add to `handler_test.go`:
330-
331-```go
332-func TestHandler_AttachmentSecretsPost(t *testing.T) {
333- configSvc := inmocks.NewMockConfigService(t)
334- authSvc := inmocks.NewMockAuthService(t)
335- containerSvc := inmocks.NewMockContainerService(t)
336- secretSvc := inmocks.NewMockSecretService(t)
337-
338- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
339-
340- secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil)
341-
342- body := `{"POSTGRES_PASSWORD": "secret"}`
343- req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/postgres", bytes.NewBufferString(body))
344- req = req.WithContext(ctxWithScopes("admin:secrets:write"))
345- rec := httptest.NewRecorder()
346-
347- handler.ServeHTTP(rec, req)
348-
349- assert.Equal(t, http.StatusOK, rec.Code)
350-}
351-
352-func TestHandler_AttachmentSecretsDelete(t *testing.T) {
353- configSvc := inmocks.NewMockConfigService(t)
354- authSvc := inmocks.NewMockAuthService(t)
355- containerSvc := inmocks.NewMockContainerService(t)
356- secretSvc := inmocks.NewMockSecretService(t)
357-
358- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
359-
360- secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "postgres", "OLD_KEY").Return(nil)
361-
362- req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/postgres/OLD_KEY", nil)
363- req = req.WithContext(ctxWithScopes("admin:secrets:write"))
364- rec := httptest.NewRecorder()
365-
366- handler.ServeHTTP(rec, req)
367-
368- assert.Equal(t, http.StatusOK, rec.Code)
369-}
370-```
371-
372-**Step 2: Run tests to verify they fail**
373-
374-Run: `go test ./internal/adapters/in/http/admin/... -count=1 -run "TestHandler_AttachmentSecrets" -v`
375-Expected: FAIL — routes not recognized, 400 or 404
376-
377-**Step 3: Implement the endpoint**
378-
379-In `handler.go`, modify the `handleSecrets` function. Replace the path parsing section (lines 451-462) to detect the `/attachments/` sub-path:
380-
381-```go
382-func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
383- ctx := r.Context()
384- log := zerowrap.FromCtx(ctx)
385-
386- // Parse path: /secrets/{domain}, /secrets/{domain}/{key},
387- // or /secrets/{domain}/attachments/{service}[/{key}]
388- trimmed := strings.TrimPrefix(path, "/secrets/")
389- parts := strings.Split(trimmed, "/")
390- if len(parts) == 0 || parts[0] == "" {
391- h.sendError(w, http.StatusBadRequest, "domain required")
392- return
393- }
394-
395- secretDomain := parts[0]
396-
397- // Check for attachment sub-path: /secrets/{domain}/attachments/{service}[/{key}]
398- if len(parts) >= 3 && parts[1] == "attachments" {
399- service := parts[2]
400- attachmentKey := ""
401- if len(parts) > 3 {
402- attachmentKey = parts[3]
403- }
404- h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
405- return
406- }
407-
408- secretKey := ""
409- if len(parts) > 1 {
410- secretKey = parts[1]
411- }
412-
413- switch r.Method {
414- case http.MethodGet:
415- h.handleSecretsGet(w, r, secretDomain)
416-
417- case http.MethodPost:
418- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
419- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
420- return
421- }
422-
423- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
424-
425- var data map[string]string
426- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
427- log.Warn().Err(err).Msg("invalid secrets JSON")
428- h.sendError(w, http.StatusBadRequest, "invalid JSON")
429- return
430- }
431-
432- if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
433- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
434- h.sendError(w, http.StatusBadRequest, "invalid domain")
435- return
436- }
437-
438- log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
439- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
440-
441- case http.MethodDelete:
442- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
443- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
444- return
445- }
446- if secretKey == "" {
447- h.sendError(w, http.StatusBadRequest, "key required in path")
448- return
449- }
450-
451- if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
452- log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
453- h.sendError(w, http.StatusBadRequest, "invalid domain")
454- return
455- }
456-
457- log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
458- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
459-
460- default:
461- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
462- }
463-}
464-```
465-
466-Add the new handler method:
467-
468-```go
469-// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}].
470-func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
471- ctx := r.Context()
472- log := zerowrap.FromCtx(ctx)
473-
474- switch r.Method {
475- case http.MethodPost:
476- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
477- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
478- return
479- }
480-
481- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
482-
483- var data map[string]string
484- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
485- log.Warn().Err(err).Msg("invalid attachment secrets JSON")
486- h.sendError(w, http.StatusBadRequest, "invalid JSON")
487- return
488- }
489-
490- if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
491- log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
492- h.sendError(w, http.StatusBadRequest, err.Error())
493- return
494- }
495-
496- log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
497- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
498-
499- case http.MethodDelete:
500- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
501- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
502- return
503- }
504- if key == "" {
505- h.sendError(w, http.StatusBadRequest, "key required in path")
506- return
507- }
508-
509- if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
510- log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
511- h.sendError(w, http.StatusBadRequest, err.Error())
512- return
513- }
514-
515- log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
516- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
517-
518- default:
519- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
520- }
521-}
522-```
523-
524-**Step 4: Run tests to verify they pass**
525-
526-Run: `go test ./internal/adapters/in/http/admin/... -count=1 -v`
527-Expected: ALL PASS (both new and existing tests)
528-
529-**Step 5: Run go fmt**
530-
531-Run: `go fmt ./internal/adapters/in/http/admin/...`
532-
533-**Step 6: Commit**
534-
535-```bash
536-git add internal/adapters/in/http/admin/handler.go internal/adapters/in/http/admin/handler_test.go
537-git commit -m "feat(api): add attachment secrets endpoints
538-
539-POST /admin/secrets/{domain}/attachments/{service} to set secrets.
540-DELETE /admin/secrets/{domain}/attachments/{service}/{key} to remove."
541-```
542-
543----
544-
545-### Task 4: Add remote client methods
546-
547-**Files:**
548-- Modify: `internal/adapters/in/cli/remote/client.go` (after `DeleteSecret` ~line 296)
549-
550-**Step 1: Implement the client methods**
551-
552-No separate test file exists for the remote client (it's tested via integration). Add the methods:
553-
554-```go
555-// SetAttachmentSecrets sets secrets for an attachment container.
556-func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
557- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
558- resp, err := c.request(ctx, http.MethodPost, path, secrets)
559- if err != nil {
560- return err
561- }
562- return parseResponse(resp, nil)
563-}
564-
565-// DeleteAttachmentSecret removes a secret from an attachment container.
566-func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
567- path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
568- resp, err := c.request(ctx, http.MethodDelete, path, nil)
569- if err != nil {
570- return err
571- }
572- return parseResponse(resp, nil)
573-}
574-```
575-
576-**Step 2: Run go fmt and verify build**
577-
578-Run: `go fmt ./internal/adapters/in/cli/remote/... && go build ./...`
579-Expected: Clean build
580-
581-**Step 3: Commit**
582-
583-```bash
584-git add internal/adapters/in/cli/remote/client.go
585-git commit -m "feat(client): add attachment secrets API methods"
586-```
587-
588----
589-
590-### Task 5: Add `--attachment` flag to `secrets set` and `secrets remove` CLI commands
591-
592-**Files:**
593-- Modify: `internal/adapters/in/cli/secrets.go:214-270` (set command)
594-- Modify: `internal/adapters/in/cli/secrets.go:272-327` (remove command)
595-
596-**Step 1: Write the failing test for flag parsing**
597-
598-Add to `internal/adapters/in/cli/secrets_test.go` (create file):
599-
600-```go
601-package cli
602-
603-import (
604- "testing"
605-
606- "github.com/stretchr/testify/assert"
607-)
608-
609-func TestResolveContainerName(t *testing.T) {
610- tests := []struct {
611- domain string
612- service string
613- want string
614- }{
615- {"app.example.com", "postgres", "app__example__com-postgres"},
616- {"git.example.com", "redis", "git__example__com-redis"},
617- }
618-
619- for _, tt := range tests {
620- t.Run(tt.domain+"/"+tt.service, func(t *testing.T) {
621- got := resolveAttachmentContainerName(tt.domain, tt.service)
622- assert.Equal(t, tt.want, got)
623- })
624- }
625-}
626-```
627-
628-**Step 2: Run test to verify it fails**
629-
630-Run: `go test ./internal/adapters/in/cli/... -count=1 -run TestResolveContainerName -v`
631-Expected: FAIL — `resolveAttachmentContainerName` undefined
632-
633-**Step 3: Implement**
634-
635-In `secrets.go`, add the helper:
636-
637-```go
638-// resolveAttachmentContainerName builds the container name from domain and service.
639-// Must match the naming in usecase/container deployAttachedService() and usecase/secrets resolveContainerName().
640-func resolveAttachmentContainerName(domainName, service string) string {
641- return domain.SanitizeDomainForContainer(domainName) + "-" + service
642-}
643-```
644-
645-Add the import for the domain package at the top of `secrets.go`:
646-
647-```go
648-import (
649- // ... existing imports ...
650- "github.com/bnema/gordon/internal/domain"
651-)
652-```
653-
654-Now modify `newSecretsSetCmd()` to add the `--attachment` flag. Replace the function:
655-
656-```go
657-func newSecretsSetCmd() *cobra.Command {
658- var attachment string
659-
660- cmd := &cobra.Command{
661- Use: "set <domain> <KEY=value>...",
662- Short: "Set secrets for a domain or attachment",
663- Long: `Set one or more secrets for a domain or an attachment container.
664-
665-Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
666-
667-Use --attachment to target an attachment service (e.g., postgres, redis) instead
668-of the main domain container.
669-
670-Examples:
671- gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
672- gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
673- gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
674- gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
675- Args: cobra.MinimumNArgs(2),
676- RunE: func(cmd *cobra.Command, args []string) error {
677- ctx := context.Background()
678- secretDomain := args[0]
679- pairs := args[1:]
680-
681- // Parse KEY=value pairs
682- secrets := make(map[string]string)
683- for _, pair := range pairs {
684- parts := strings.SplitN(pair, "=", 2)
685- if len(parts) != 2 {
686- fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
687- return nil
688- }
689- secrets[parts[0]] = parts[1]
690- }
691-
692- client, isRemote := GetRemoteClient()
693- if isRemote {
694- if attachment != "" {
695- if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
696- return fmt.Errorf("failed to set attachment secrets: %w", err)
697- }
698- } else {
699- if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
700- return fmt.Errorf("failed to set secrets: %w", err)
701- }
702- }
703- } else {
704- local, err := GetLocalServices(configPath)
705- if err != nil {
706- return fmt.Errorf("failed to initialize local services: %w", err)
707- }
708- if attachment != "" {
709- if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil {
710- return fmt.Errorf("failed to set attachment secrets: %w", err)
711- }
712- } else {
713- if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
714- return fmt.Errorf("failed to set secrets: %w", err)
715- }
716- }
717- }
718-
719- target := secretDomain
720- if attachment != "" {
721- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
722- }
723-
724- if len(secrets) == 1 {
725- for key := range secrets {
726- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
727- }
728- } else {
729- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
730- }
731-
732- return nil
733- },
734- }
735-
736- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
737-
738- return cmd
739-}
740-```
741-
742-Now modify `newSecretsRemoveCmd()` similarly:
743-
744-```go
745-func newSecretsRemoveCmd() *cobra.Command {
746- var (
747- force bool
748- attachment string
749- )
750-
751- cmd := &cobra.Command{
752- Use: "remove <domain> <key>",
753- Short: "Remove a secret",
754- Long: `Remove a secret from a domain or attachment container.
755-
756-Use --attachment to target an attachment service instead of the main domain.
757-
758-Examples:
759- gordon secrets remove app.mydomain.com OLD_API_KEY
760- gordon secrets remove app.mydomain.com OLD_API_KEY --force
761- gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
762- Args: cobra.ExactArgs(2),
763- RunE: func(cmd *cobra.Command, args []string) error {
764- ctx := context.Background()
765- secretDomain := args[0]
766- key := args[1]
767-
768- target := secretDomain
769- if attachment != "" {
770- target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
771- }
772-
773- // Confirm unless --force
774- if !force {
775- confirmed, err := components.RunConfirm(
776- fmt.Sprintf("Remove secret '%s' from %s?", key, target),
777- )
778- if err != nil {
779- return err
780- }
781- if !confirmed {
782- fmt.Println(styles.Theme.Muted.Render("Cancelled"))
783- return nil
784- }
785- }
786-
787- client, isRemote := GetRemoteClient()
788- if isRemote {
789- if attachment != "" {
790- if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
791- return fmt.Errorf("failed to remove attachment secret: %w", err)
792- }
793- } else {
794- if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
795- return fmt.Errorf("failed to remove secret: %w", err)
796- }
797- }
798- } else {
799- local, err := GetLocalServices(configPath)
800- if err != nil {
801- return fmt.Errorf("failed to initialize local services: %w", err)
802- }
803- if attachment != "" {
804- if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil {
805- return fmt.Errorf("failed to remove attachment secret: %w", err)
806- }
807- } else {
808- if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
809- return fmt.Errorf("failed to remove secret: %w", err)
810- }
811- }
812- }
813-
814- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s from %s", key, target)))
815- return nil
816- },
817- }
818-
819- cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
820- cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
821-
822- return cmd
823-}
824-```
825-
826-**Step 4: Run tests to verify they pass**
827-
828-Run: `go test ./internal/adapters/in/cli/... -count=1 -v`
829-Expected: ALL PASS
830-
831-**Step 5: Run go fmt and full suite**
832-
833-Run: `go fmt ./... && go test ./... -count=1`
834-Expected: ALL PASS
835-
836-**Step 6: Commit**
837-
838-```bash
839-git add internal/adapters/in/cli/secrets.go internal/adapters/in/cli/secrets_test.go
840-git commit -m "feat(cli): add --attachment flag to secrets set/remove
841-
842-Closes #56. Users can now configure attachment secrets via:
843- gordon secrets set <domain> --attachment <service> KEY=value
844- gordon secrets remove <domain> --attachment <service> KEY"
845-```
846-
847----
848-
849-### Task 6: Update secrets command description and verify end-to-end
850-
851-**Files:**
852-- Modify: `internal/adapters/in/cli/secrets.go:16-33` (parent command description)
853-
854-**Step 1: Update parent command long description**
855-
856-```go
857-func newSecretsCmd() *cobra.Command {
858- cmd := &cobra.Command{
859- Use: "secrets",
860- Short: "Manage secrets",
861- Long: `Manage secrets (environment variables) for routes and attachments.
862-
863-Secrets are stored per-domain and injected into containers as environment variables.
864-Use --attachment to target attachment containers (databases, caches, etc.).
865-
866-When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
867-these commands operate on the remote server.`,
868- }
869-
870- cmd.AddCommand(newSecretsListCmd())
871- cmd.AddCommand(newSecretsSetCmd())
872- cmd.AddCommand(newSecretsRemoveCmd())
873-
874- return cmd
875-}
876-```
877-
878-**Step 2: Run full test suite**
879-
880-Run: `go test ./... -count=1`
881-Expected: ALL PASS
882-
883-**Step 3: Run go vet and build**
884-
885-Run: `go vet ./... && go build ./...`
886-Expected: Clean
887-
888-**Step 4: Commit**
889-
890-```bash
891-git add internal/adapters/in/cli/secrets.go
892-git commit -m "docs(cli): update secrets command description for attachments"
893-```

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