detached 02b1509

feat(secrets): add attachment secrets support

Add support for managing secrets for attachment containers (databases, caches, etc.) via --attachment flag. Includes CLI commands, HTTP endpoints, and storage implementations for both pass and file-based backends.
commit
02b15094aed3e91ca2bfd87dae24c39453908ce7
author
brice · · 2026-02-06 12:44 UTC
parent
c9af649
signature
unsigned
19 files · +1845 -72
M .gitignore +2 -1
@@ -1,3 +1,4 @@
11 gordon
22 .env
3-dist/⏎̸
3+dist/
4+docs/plans/⏎̸
@@ -1,3 +1,4 @@
1 gordon1 gordon
2 .env2 .env
3-dist/⏎̸3+dist/
4+docs/plans/⏎̸
M docs/cli/secrets.md +39 -6
@@ -23,7 +23,7 @@ Storage depends on the secrets backend:
2323
2424 ## gordon secrets list
2525
26-List all secrets for a specific domain.
26+List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets.
2727
2828 ```bash
2929 gordon secrets list <domain>
@@ -55,11 +55,16 @@ gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TO
5555 ### Output
5656
5757 ```
58-Secret Value (masked)
59---------------------------------------------------------------------------------
60-DATABASE_URL postgres://...
58+Secrets for app.mydomain.com
59+
60+Key Value
61+DATABASE_URL ****
6162 API_KEY ****
62-JWT_SECRET ****
63+├─ [postgres]
64+│ ├─ POSTGRES_USER ****
65+│ └─ POSTGRES_PASSWORD ****
66+└─ [redis]
67+ └─ REDIS_PASSWORD ****
6368 ```
6469
6570 ---
@@ -69,7 +74,7 @@ JWT_SECRET ****
6974 Set a secret value for a domain.
7075
7176 ```bash
72-gordon secrets set <domain> <key> <value>
77+gordon secrets set <domain> <KEY=value>...
7378 gordon secrets set myapp.local DATABASE_URL "postgres://..."
7479 ```
7580
@@ -85,6 +90,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
8590
8691 | Option | Description |
8792 |--------|-------------|
93+| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
8894 | `--remote` | Remote Gordon URL |
8995 | `--token` | Authentication token for remote |
9096
@@ -97,6 +103,13 @@ gordon secrets set myapp.local API_KEY "your-api-key"
97103
98104 # Remote (override)
99105 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
106+
107+# Set attachment secrets
108+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
109+gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret
110+
111+# Multiple secrets at once
112+gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
100113 ```
101114
102115 ---
@@ -120,6 +133,7 @@ gordon secrets remove <domain> <key>
120133
121134 | Option | Description |
122135 |--------|-------------|
136+| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
123137 | `--remote` | Remote Gordon URL |
124138 | `--token` | Authentication token for remote |
125139
@@ -131,6 +145,9 @@ gordon secrets remove myapp.local DATABASE_URL
131145
132146 # Remote (override)
133147 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
148+
149+# Remove attachment secret
150+gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
134151 ```
135152
136153 ---
@@ -182,6 +199,22 @@ gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
182199 gordon routes deploy myapp.local
183200 ```
184201
202+### Attachment Secrets
203+
204+```bash
205+# Configure database credentials
206+gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
207+
208+# Configure cache credentials
209+gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret
210+
211+# Verify
212+gordon secrets list app.mydomain.com
213+
214+# Redeploy to pick up new secrets
215+gordon push app.mydomain.com
216+```
217+
185218 ## Related
186219
187220 - [CLI Overview](./index.md)
@@ -23,7 +23,7 @@ Storage depends on the secrets backend:
23 23
24 ## gordon secrets list24 ## gordon secrets list
25 25
26-List all secrets for a specific domain.26+List all secrets for a specific domain. When attachment secrets are present, they are displayed in a tree view below the domain secrets.
27 27
28 ```bash28 ```bash
29 gordon secrets list <domain>29 gordon secrets list <domain>
@@ -55,11 +55,16 @@ gordon secrets list myapp.local --remote https://gordon.mydomain.com --token $TO
55 ### Output55 ### Output
56 56
57 ```57 ```
58-Secret Value (masked)58+Secrets for app.mydomain.com
59---------------------------------------------------------------------------------59+
60-DATABASE_URL postgres://...60+Key Value
61+DATABASE_URL ****
61 API_KEY ****62 API_KEY ****
62-JWT_SECRET ****63+├─ [postgres]
64+│ ├─ POSTGRES_USER ****
65+│ └─ POSTGRES_PASSWORD ****
66+└─ [redis]
67+ └─ REDIS_PASSWORD ****
63 ```68 ```
64 69
65 ---70 ---
@@ -69,7 +74,7 @@ JWT_SECRET ****
69 Set a secret value for a domain.74 Set a secret value for a domain.
70 75
71 ```bash76 ```bash
72-gordon secrets set <domain> <key> <value>77+gordon secrets set <domain> <KEY=value>...
73 gordon secrets set myapp.local DATABASE_URL "postgres://..."78 gordon secrets set myapp.local DATABASE_URL "postgres://..."
74 ```79 ```
75 80
@@ -85,6 +90,7 @@ gordon secrets set myapp.local DATABASE_URL "postgres://..."
85 90
86 | Option | Description |91 | Option | Description |
87 |--------|-------------|92 |--------|-------------|
93+| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
88 | `--remote` | Remote Gordon URL |94 | `--remote` | Remote Gordon URL |
89 | `--token` | Authentication token for remote |95 | `--token` | Authentication token for remote |
90 96
@@ -97,6 +103,13 @@ gordon secrets set myapp.local API_KEY "your-api-key"
97 103
98 # Remote (override)104 # Remote (override)
99 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN105 gordon secrets set myapp.local DATABASE_URL "postgres://..." --remote https://gordon.mydomain.com --token $TOKEN
106+
107+# Set attachment secrets
108+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
109+gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=mysecret
110+
111+# Multiple secrets at once
112+gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
100 ```113 ```
101 114
102 ---115 ---
@@ -120,6 +133,7 @@ gordon secrets remove <domain> <key>
120 133
121 | Option | Description |134 | Option | Description |
122 |--------|-------------|135 |--------|-------------|
136+| `--attachment` / `-a` | Target an attachment service (e.g., postgres, redis) |
123 | `--remote` | Remote Gordon URL |137 | `--remote` | Remote Gordon URL |
124 | `--token` | Authentication token for remote |138 | `--token` | Authentication token for remote |
125 139
@@ -131,6 +145,9 @@ gordon secrets remove myapp.local DATABASE_URL
131 145
132 # Remote (override)146 # Remote (override)
133 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN147 gordon secrets remove myapp.local DATABASE_URL --remote https://gordon.mydomain.com --token $TOKEN
148+
149+# Remove attachment secret
150+gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
134 ```151 ```
135 152
136 ---153 ---
@@ -182,6 +199,22 @@ gordon secrets set myapp.local JWT_SECRET "$NEW_JWT_SECRET"
182 gordon routes deploy myapp.local199 gordon routes deploy myapp.local
183 ```200 ```
184 201
202+### Attachment Secrets
203+
204+```bash
205+# Configure database credentials
206+gordon secrets set app.mydomain.com -a postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
207+
208+# Configure cache credentials
209+gordon secrets set app.mydomain.com -a redis REDIS_PASSWORD=cache-secret
210+
211+# Verify
212+gordon secrets list app.mydomain.com
213+
214+# Redeploy to pick up new secrets
215+gordon push app.mydomain.com
216+```
217+
185 ## Related218 ## Related
186 219
187 - [CLI Overview](./index.md)220 - [CLI Overview](./index.md)
M docs/config/attachments.md +39 -1
@@ -80,9 +80,21 @@ FROM postgres:18
8080 VOLUME ["/var/lib/postgresql/data"]
8181 ENV POSTGRES_DB=myapp
8282 ENV POSTGRES_USER=app
83-ENV POSTGRES_PASSWORD=secret
83+# Password is injected via attachment secrets — do NOT hardcode here
8484 ```
8585
86+Configure credentials via attachment secrets instead of hardcoding them:
87+
88+```bash
89+# Set database password securely
90+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
91+
92+# Verify
93+gordon secrets list app.mydomain.com
94+```
95+
96+This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details.
97+
8698 Build and push to Gordon:
8799 ```bash
88100 docker build -f postgres.Dockerfile -t my-postgres:latest .
@@ -95,6 +107,32 @@ Use in attachments:
95107 "app.mydomain.com" = ["my-postgres:latest"]
96108 ```
97109
110+## Attachment Secrets
111+
112+Inject environment variables into attachment containers using the `--attachment` flag on secrets commands:
113+
114+```bash
115+# Set secrets for the postgres attachment
116+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
117+
118+# Set secrets for the redis attachment
119+gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret
120+
121+# View all secrets (domain + attachments) in a tree view
122+gordon secrets list app.mydomain.com
123+
124+# Remove an attachment secret
125+gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
126+```
127+
128+The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`.
129+
130+Storage depends on your secrets backend:
131+- **pass**: `gordon/env/attachments/<container-name>/<KEY>`
132+- **sops/unsafe**: `gordon-<container-name>.env` files
133+
134+See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details.
135+
98136 ## Shared Attachments with Network Groups
99137
100138 Share services between multiple apps using network groups:
@@ -80,9 +80,21 @@ FROM postgres:18
80 VOLUME ["/var/lib/postgresql/data"]80 VOLUME ["/var/lib/postgresql/data"]
81 ENV POSTGRES_DB=myapp81 ENV POSTGRES_DB=myapp
82 ENV POSTGRES_USER=app82 ENV POSTGRES_USER=app
83-ENV POSTGRES_PASSWORD=secret83+# Password is injected via attachment secrets — do NOT hardcode here
84 ```84 ```
85 85
86+Configure credentials via attachment secrets instead of hardcoding them:
87+
88+```bash
89+# Set database password securely
90+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
91+
92+# Verify
93+gordon secrets list app.mydomain.com
94+```
95+
96+This works with all secrets backends (pass, sops, unsafe). See [Secrets Configuration](./secrets.md) for backend details.
97+
86 Build and push to Gordon:98 Build and push to Gordon:
87 ```bash99 ```bash
88 docker build -f postgres.Dockerfile -t my-postgres:latest .100 docker build -f postgres.Dockerfile -t my-postgres:latest .
@@ -95,6 +107,32 @@ Use in attachments:
95 "app.mydomain.com" = ["my-postgres:latest"]107 "app.mydomain.com" = ["my-postgres:latest"]
96 ```108 ```
97 109
110+## Attachment Secrets
111+
112+Inject environment variables into attachment containers using the `--attachment` flag on secrets commands:
113+
114+```bash
115+# Set secrets for the postgres attachment
116+gordon secrets set app.mydomain.com --attachment postgres POSTGRES_USER=admin POSTGRES_PASSWORD=secret
117+
118+# Set secrets for the redis attachment
119+gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=cache-secret
120+
121+# View all secrets (domain + attachments) in a tree view
122+gordon secrets list app.mydomain.com
123+
124+# Remove an attachment secret
125+gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD
126+```
127+
128+The `--attachment` flag takes the service name (the image name before the colon in your attachments config). For example, if your config has `"app.mydomain.com" = ["postgres:18", "redis:7-alpine"]`, the service names are `postgres` and `redis`.
129+
130+Storage depends on your secrets backend:
131+- **pass**: `gordon/env/attachments/<container-name>/<KEY>`
132+- **sops/unsafe**: `gordon-<container-name>.env` files
133+
134+See [Secrets Configuration](./secrets.md) and [Secrets Commands](../cli/secrets.md) for details.
135+
98 ## Shared Attachments with Network Groups136 ## Shared Attachments with Network Groups
99 137
100 Share services between multiple apps using network groups:138 Share services between multiple apps using network groups:
M docs/config/secrets.md +12 -0
@@ -57,6 +57,8 @@ token_secret = "gordon/auth/token_secret"
5757 **Route secrets storage:**
5858 - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
5959 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
60+- Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest
61+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
6062
6163 ### SOPS
6264
@@ -98,6 +100,11 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
98100 - Domain secrets stay in `.env` files
99101 - Use `${sops:...}` syntax to resolve encrypted values
100102
103+**Attachment secrets storage:**
104+- Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files
105+- Use `${sops:...}` syntax inside attachment env files to resolve encrypted values
106+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
107+
101108 ### Unsafe (Development Only)
102109
103110 Stores secrets as plain text files:
@@ -116,6 +123,11 @@ secrets_backend = "unsafe"
116123 │ └── token_secret
117124 ```
118125
126+**Attachment secrets:**
127+- Stored as `gordon-<container-name>.env` files in the env directory
128+- Example: `gordon-app__mydomain__com-postgres.env`
129+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
130+
119131 **Usage:**
120132 ```bash
121133 # Create secret
@@ -57,6 +57,8 @@ token_secret = "gordon/auth/token_secret"
57 **Route secrets storage:**57 **Route secrets storage:**
58 - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)58 - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores)
59 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`59 - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated`
60+- Attachment secrets are stored under `gordon/env/attachments/<container-name>/<KEY>` with a `.keys` manifest
61+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
60 62
61 ### SOPS63 ### SOPS
62 64
@@ -98,6 +100,11 @@ DB_PASSWORD=${sops:secrets.yaml:database.password}
98 - Domain secrets stay in `.env` files100 - Domain secrets stay in `.env` files
99 - Use `${sops:...}` syntax to resolve encrypted values101 - Use `${sops:...}` syntax to resolve encrypted values
100 102
103+**Attachment secrets storage:**
104+- Attachment secrets are stored in `gordon-<container-name>.env` files alongside domain env files
105+- Use `${sops:...}` syntax inside attachment env files to resolve encrypted values
106+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
107+
101 ### Unsafe (Development Only)108 ### Unsafe (Development Only)
102 109
103 Stores secrets as plain text files:110 Stores secrets as plain text files:
@@ -116,6 +123,11 @@ secrets_backend = "unsafe"
116 │ └── token_secret123 │ └── token_secret
117 ```124 ```
118 125
126+**Attachment secrets:**
127+- Stored as `gordon-<container-name>.env` files in the env directory
128+- Example: `gordon-app__mydomain__com-postgres.env`
129+- Use `gordon secrets set <domain> --attachment <service> KEY=value` to manage them
130+
119 **Usage:**131 **Usage:**
120 ```bash132 ```bash
121 # Create secret133 # Create secret
A docs/plans/2026-02-06-attachment-secrets.md +893 -0
@@ -0,0 +1,893 @@
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+```
@@ -0,0 +1,893 @@
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+```
M internal/adapters/in/cli/remote/client.go +20 -0
@@ -295,6 +295,26 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err
295295 return parseResponse(resp, nil)
296296 }
297297
298+// SetAttachmentSecrets sets secrets for an attachment container.
299+func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
300+ path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
301+ resp, err := c.request(ctx, http.MethodPost, path, secrets)
302+ if err != nil {
303+ return err
304+ }
305+ return parseResponse(resp, nil)
306+}
307+
308+// DeleteAttachmentSecret removes a secret from an attachment container.
309+func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
310+ path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
311+ resp, err := c.request(ctx, http.MethodDelete, path, nil)
312+ if err != nil {
313+ return err
314+ }
315+ return parseResponse(resp, nil)
316+}
317+
298318 // Status API
299319
300320 // Status represents the Gordon server status.
@@ -295,6 +295,26 @@ func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) err
295 return parseResponse(resp, nil)295 return parseResponse(resp, nil)
296 }296 }
297 297
298+// SetAttachmentSecrets sets secrets for an attachment container.
299+func (c *Client) SetAttachmentSecrets(ctx context.Context, domain, service string, secrets map[string]string) error {
300+ path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service)
301+ resp, err := c.request(ctx, http.MethodPost, path, secrets)
302+ if err != nil {
303+ return err
304+ }
305+ return parseResponse(resp, nil)
306+}
307+
308+// DeleteAttachmentSecret removes a secret from an attachment container.
309+func (c *Client) DeleteAttachmentSecret(ctx context.Context, domain, service, key string) error {
310+ path := "/secrets/" + url.PathEscape(domain) + "/attachments/" + url.PathEscape(service) + "/" + url.PathEscape(key)
311+ resp, err := c.request(ctx, http.MethodDelete, path, nil)
312+ if err != nil {
313+ return err
314+ }
315+ return parseResponse(resp, nil)
316+}
317+
298 // Status API318 // Status API
299 319
300 // Status represents the Gordon server status.320 // Status represents the Gordon server status.
M internal/adapters/in/cli/secrets.go +73 -20
@@ -17,9 +17,10 @@ func newSecretsCmd() *cobra.Command {
1717 cmd := &cobra.Command{
1818 Use: "secrets",
1919 Short: "Manage secrets",
20- Long: `Manage secrets (environment variables) for routes.
20+ Long: `Manage secrets (environment variables) for routes and attachments.
2121
2222 Secrets are stored per-domain and injected into containers as environment variables.
23+Use --attachment to target attachment containers (databases, caches, etc.).
2324
2425 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
2526 these commands operate on the remote server.`,
@@ -213,17 +214,23 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
213214
214215 // newSecretsSetCmd creates the secrets set command.
215216 func newSecretsSetCmd() *cobra.Command {
216- return &cobra.Command{
217+ var attachment string
218+
219+ cmd := &cobra.Command{
217220 Use: "set <domain> <KEY=value>...",
218- Short: "Set secrets for a domain",
219- Long: `Set one or more secrets for a domain.
221+ Short: "Set secrets for a domain or attachment",
222+ Long: `Set one or more secrets for a domain or an attachment container.
220223
221224 Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
222225
226+Use --attachment to target an attachment service (e.g., postgres, redis) instead
227+of the main domain container.
228+
223229 Examples:
224230 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
225231 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
226- gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
232+ gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
233+ gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
227234 Args: cobra.MinimumNArgs(2),
228235 RunE: func(cmd *cobra.Command, args []string) error {
229236 ctx := context.Background()
@@ -243,54 +250,87 @@ Examples:
243250
244251 client, isRemote := GetRemoteClient()
245252 if isRemote {
246- if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
247- return fmt.Errorf("failed to set secrets: %w", err)
253+ if attachment != "" {
254+ if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
255+ return fmt.Errorf("failed to set secrets: %w", err)
256+ }
257+ } else {
258+ if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
259+ return fmt.Errorf("failed to set secrets: %w", err)
260+ }
248261 }
249262 } else {
250263 local, err := GetLocalServices(configPath)
251264 if err != nil {
252265 return fmt.Errorf("failed to initialize local services: %w", err)
253266 }
254- if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
255- return fmt.Errorf("failed to set secrets: %w", err)
267+ if attachment != "" {
268+ if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil {
269+ return fmt.Errorf("failed to set secrets: %w", err)
270+ }
271+ } else {
272+ if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
273+ return fmt.Errorf("failed to set secrets: %w", err)
274+ }
256275 }
257276 }
258277
278+ target := secretDomain
279+ if attachment != "" {
280+ target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
281+ }
282+
259283 if len(secrets) == 1 {
260284 for key := range secrets {
261- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
285+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
262286 }
263287 } else {
264- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))
288+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
265289 }
266290
267291 return nil
268292 },
269293 }
294+
295+ cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
296+
297+ return cmd
270298 }
271299
272300 // newSecretsRemoveCmd creates the secrets remove command.
273301 func newSecretsRemoveCmd() *cobra.Command {
274- var force bool
302+ var (
303+ force bool
304+ attachment string
305+ )
275306
276307 cmd := &cobra.Command{
277308 Use: "remove <domain> <key>",
278309 Short: "Remove a secret",
279- Long: `Remove a secret from a domain.
310+ Long: `Remove a secret from a domain or an attachment container.
311+
312+Use --attachment to target an attachment service (e.g., postgres, redis) instead
313+of the main domain container.
280314
281315 Examples:
282316 gordon secrets remove app.mydomain.com OLD_API_KEY
283- gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
317+ gordon secrets remove app.mydomain.com OLD_API_KEY --force
318+ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
284319 Args: cobra.ExactArgs(2),
285320 RunE: func(cmd *cobra.Command, args []string) error {
286321 ctx := context.Background()
287322 secretDomain := args[0]
288323 key := args[1]
289324
325+ target := secretDomain
326+ if attachment != "" {
327+ target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
328+ }
329+
290330 // Confirm unless --force
291331 if !force {
292332 confirmed, err := components.RunConfirm(
293- fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),
333+ fmt.Sprintf("Remove secret '%s' from %s?", key, target),
294334 )
295335 if err != nil {
296336 return err
@@ -303,25 +343,38 @@ Examples:
303343
304344 client, isRemote := GetRemoteClient()
305345 if isRemote {
306- if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
307- return fmt.Errorf("failed to remove secret: %w", err)
346+ if attachment != "" {
347+ if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
348+ return fmt.Errorf("failed to remove secret: %w", err)
349+ }
350+ } else {
351+ if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
352+ return fmt.Errorf("failed to remove secret: %w", err)
353+ }
308354 }
309355 } else {
310356 local, err := GetLocalServices(configPath)
311357 if err != nil {
312358 return fmt.Errorf("failed to initialize local services: %w", err)
313359 }
314- if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
315- return fmt.Errorf("failed to remove secret: %w", err)
360+ if attachment != "" {
361+ if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil {
362+ return fmt.Errorf("failed to remove secret: %w", err)
363+ }
364+ } else {
365+ if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
366+ return fmt.Errorf("failed to remove secret: %w", err)
367+ }
316368 }
317369 }
318370
319- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
371+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
320372 return nil
321373 },
322374 }
323375
324376 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
377+ cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
325378
326379 return cmd
327380 }
@@ -17,9 +17,10 @@ func newSecretsCmd() *cobra.Command {
17 cmd := &cobra.Command{17 cmd := &cobra.Command{
18 Use: "secrets",18 Use: "secrets",
19 Short: "Manage secrets",19 Short: "Manage secrets",
20- Long: `Manage secrets (environment variables) for routes.20+ Long: `Manage secrets (environment variables) for routes and attachments.
21 21
22 Secrets are stored per-domain and injected into containers as environment variables.22 Secrets are stored per-domain and injected into containers as environment variables.
23+Use --attachment to target attachment containers (databases, caches, etc.).
23 24
24 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),25 When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
25 these commands operate on the remote server.`,26 these commands operate on the remote server.`,
@@ -213,17 +214,23 @@ func getKeyPrefix(isLastAttachment, isLastKey bool) string {
213 214
214 // newSecretsSetCmd creates the secrets set command.215 // newSecretsSetCmd creates the secrets set command.
215 func newSecretsSetCmd() *cobra.Command {216 func newSecretsSetCmd() *cobra.Command {
216- return &cobra.Command{217+ var attachment string
218+
219+ cmd := &cobra.Command{
217 Use: "set <domain> <KEY=value>...",220 Use: "set <domain> <KEY=value>...",
218- Short: "Set secrets for a domain",221+ Short: "Set secrets for a domain or attachment",
219- Long: `Set one or more secrets for a domain.222+ Long: `Set one or more secrets for a domain or an attachment container.
220 223
221 Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.224 Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
222 225
226+Use --attachment to target an attachment service (e.g., postgres, redis) instead
227+of the main domain container.
228+
223 Examples:229 Examples:
224 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db230 gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
225 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false231 gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
226- gordon --remote https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,232+ gordon secrets set app.mydomain.com --attachment postgres POSTGRES_PASSWORD=secret
233+ gordon secrets set app.mydomain.com --attachment redis REDIS_PASSWORD=secret`,
227 Args: cobra.MinimumNArgs(2),234 Args: cobra.MinimumNArgs(2),
228 RunE: func(cmd *cobra.Command, args []string) error {235 RunE: func(cmd *cobra.Command, args []string) error {
229 ctx := context.Background()236 ctx := context.Background()
@@ -243,54 +250,87 @@ Examples:
243 250
244 client, isRemote := GetRemoteClient()251 client, isRemote := GetRemoteClient()
245 if isRemote {252 if isRemote {
246- if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {253+ if attachment != "" {
247- return fmt.Errorf("failed to set secrets: %w", err)254+ if err := client.SetAttachmentSecrets(ctx, secretDomain, attachment, secrets); err != nil {
255+ return fmt.Errorf("failed to set secrets: %w", err)
256+ }
257+ } else {
258+ if err := client.SetSecrets(ctx, secretDomain, secrets); err != nil {
259+ return fmt.Errorf("failed to set secrets: %w", err)
260+ }
248 }261 }
249 } else {262 } else {
250 local, err := GetLocalServices(configPath)263 local, err := GetLocalServices(configPath)
251 if err != nil {264 if err != nil {
252 return fmt.Errorf("failed to initialize local services: %w", err)265 return fmt.Errorf("failed to initialize local services: %w", err)
253 }266 }
254- if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {267+ if attachment != "" {
255- return fmt.Errorf("failed to set secrets: %w", err)268+ if err := local.GetSecretService().SetAttachment(ctx, secretDomain, attachment, secrets); err != nil {
269+ return fmt.Errorf("failed to set secrets: %w", err)
270+ }
271+ } else {
272+ if err := local.GetSecretService().Set(ctx, secretDomain, secrets); err != nil {
273+ return fmt.Errorf("failed to set secrets: %w", err)
274+ }
256 }275 }
257 }276 }
258 277
278+ target := secretDomain
279+ if attachment != "" {
280+ target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
281+ }
282+
259 if len(secrets) == 1 {283 if len(secrets) == 1 {
260 for key := range secrets {284 for key := range secrets {
261- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))285+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s on %s", key, target)))
262 }286 }
263 } else {287 } else {
264- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), secretDomain)))288+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), target)))
265 }289 }
266 290
267 return nil291 return nil
268 },292 },
269 }293 }
294+
295+ cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
296+
297+ return cmd
270 }298 }
271 299
272 // newSecretsRemoveCmd creates the secrets remove command.300 // newSecretsRemoveCmd creates the secrets remove command.
273 func newSecretsRemoveCmd() *cobra.Command {301 func newSecretsRemoveCmd() *cobra.Command {
274- var force bool302+ var (
303+ force bool
304+ attachment string
305+ )
275 306
276 cmd := &cobra.Command{307 cmd := &cobra.Command{
277 Use: "remove <domain> <key>",308 Use: "remove <domain> <key>",
278 Short: "Remove a secret",309 Short: "Remove a secret",
279- Long: `Remove a secret from a domain.310+ Long: `Remove a secret from a domain or an attachment container.
311+
312+Use --attachment to target an attachment service (e.g., postgres, redis) instead
313+of the main domain container.
280 314
281 Examples:315 Examples:
282 gordon secrets remove app.mydomain.com OLD_API_KEY316 gordon secrets remove app.mydomain.com OLD_API_KEY
283- gordon secrets remove app.mydomain.com OLD_API_KEY --force`,317+ gordon secrets remove app.mydomain.com OLD_API_KEY --force
318+ gordon secrets remove app.mydomain.com --attachment postgres POSTGRES_PASSWORD`,
284 Args: cobra.ExactArgs(2),319 Args: cobra.ExactArgs(2),
285 RunE: func(cmd *cobra.Command, args []string) error {320 RunE: func(cmd *cobra.Command, args []string) error {
286 ctx := context.Background()321 ctx := context.Background()
287 secretDomain := args[0]322 secretDomain := args[0]
288 key := args[1]323 key := args[1]
289 324
325+ target := secretDomain
326+ if attachment != "" {
327+ target = fmt.Sprintf("%s [%s]", secretDomain, attachment)
328+ }
329+
290 // Confirm unless --force330 // Confirm unless --force
291 if !force {331 if !force {
292 confirmed, err := components.RunConfirm(332 confirmed, err := components.RunConfirm(
293- fmt.Sprintf("Remove secret '%s' from %s?", key, secretDomain),333+ fmt.Sprintf("Remove secret '%s' from %s?", key, target),
294 )334 )
295 if err != nil {335 if err != nil {
296 return err336 return err
@@ -303,25 +343,38 @@ Examples:
303 343
304 client, isRemote := GetRemoteClient()344 client, isRemote := GetRemoteClient()
305 if isRemote {345 if isRemote {
306- if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {346+ if attachment != "" {
307- return fmt.Errorf("failed to remove secret: %w", err)347+ if err := client.DeleteAttachmentSecret(ctx, secretDomain, attachment, key); err != nil {
348+ return fmt.Errorf("failed to remove secret: %w", err)
349+ }
350+ } else {
351+ if err := client.DeleteSecret(ctx, secretDomain, key); err != nil {
352+ return fmt.Errorf("failed to remove secret: %w", err)
353+ }
308 }354 }
309 } else {355 } else {
310 local, err := GetLocalServices(configPath)356 local, err := GetLocalServices(configPath)
311 if err != nil {357 if err != nil {
312 return fmt.Errorf("failed to initialize local services: %w", err)358 return fmt.Errorf("failed to initialize local services: %w", err)
313 }359 }
314- if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {360+ if attachment != "" {
315- return fmt.Errorf("failed to remove secret: %w", err)361+ if err := local.GetSecretService().DeleteAttachment(ctx, secretDomain, attachment, key); err != nil {
362+ return fmt.Errorf("failed to remove secret: %w", err)
363+ }
364+ } else {
365+ if err := local.GetSecretService().Delete(ctx, secretDomain, key); err != nil {
366+ return fmt.Errorf("failed to remove secret: %w", err)
367+ }
316 }368 }
317 }369 }
318 370
319- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))371+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed from %s: %s", target, key)))
320 return nil372 return nil
321 },373 },
322 }374 }
323 375
324 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")376 cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
377+ cmd.Flags().StringVarP(&attachment, "attachment", "a", "", "Target an attachment service (e.g., postgres, redis)")
325 378
326 return cmd379 return cmd
327 }380 }
M internal/adapters/in/http/admin/handler.go +117 -44
@@ -445,9 +445,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
445445
446446 // handleSecrets handles /admin/secrets endpoints.
447447 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
448- ctx := r.Context()
449- log := zerowrap.FromCtx(ctx)
450-
451448 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
452449 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
453450 if len(parts) == 0 || parts[0] == "" {
@@ -456,6 +453,18 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
456453 }
457454
458455 secretDomain := parts[0]
456+
457+ // 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]
463+ }
464+ h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
465+ return
466+ }
467+
459468 secretKey := ""
460469 if len(parts) > 1 {
461470 secretKey = parts[1]
@@ -464,57 +473,66 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
464473 switch r.Method {
465474 case http.MethodGet:
466475 h.handleSecretsGet(w, r, secretDomain)
467-
468476 case http.MethodPost:
469- // Check write permission
470- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
471- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
472- return
473- }
477+ h.handleSecretsPost(w, r, secretDomain)
478+ case http.MethodDelete:
479+ h.handleSecretsDelete(w, r, secretDomain, secretKey)
480+ default:
481+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
482+ }
483+}
474484
475- // Limit request body size
476- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
485+// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
486+func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
487+ ctx := r.Context()
488+ log := zerowrap.FromCtx(ctx)
477489
478- // Set secret(s)
479- var data map[string]string
480- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
481- log.Warn().Err(err).Msg("invalid secrets JSON")
482- h.sendError(w, http.StatusBadRequest, "invalid JSON")
483- return
484- }
490+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
491+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
492+ return
493+ }
485494
486- if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
487- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
488- h.sendError(w, http.StatusBadRequest, "invalid domain")
489- return
490- }
495+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
491496
492- log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
493- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
497+ var data map[string]string
498+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
499+ log.Warn().Err(err).Msg("invalid secrets JSON")
500+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
501+ return
502+ }
494503
495- case http.MethodDelete:
496- // Check write permission
497- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
498- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
499- return
500- }
501- if secretKey == "" {
502- h.sendError(w, http.StatusBadRequest, "key required in path")
503- return
504- }
504+ if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
505+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
506+ h.sendError(w, http.StatusBadRequest, "invalid domain")
507+ return
508+ }
505509
506- if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
507- log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
508- h.sendError(w, http.StatusBadRequest, "invalid domain")
509- return
510- }
510+ log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
511+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
512+}
511513
512- log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
513- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
514+// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
515+func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
516+ ctx := r.Context()
517+ log := zerowrap.FromCtx(ctx)
514518
515- default:
516- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
519+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
520+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
521+ return
522+ }
523+ if secretKey == "" {
524+ h.sendError(w, http.StatusBadRequest, "key required in path")
525+ return
517526 }
527+
528+ if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
529+ log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
530+ h.sendError(w, http.StatusBadRequest, "invalid domain")
531+ return
532+ }
533+
534+ log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
535+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
518536 }
519537
520538 // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
@@ -552,6 +570,61 @@ func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secre
552570 })
553571 }
554572
573+// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
574+func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
575+ ctx := r.Context()
576+ log := zerowrap.FromCtx(ctx)
577+
578+ switch r.Method {
579+ case http.MethodPost:
580+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
581+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
582+ return
583+ }
584+
585+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
586+
587+ var data map[string]string
588+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
589+ log.Warn().Err(err).Msg("invalid attachment secrets JSON")
590+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
591+ return
592+ }
593+
594+ if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
595+ log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
596+ h.sendError(w, http.StatusBadRequest, "invalid domain or service")
597+ return
598+ }
599+
600+ log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
601+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
602+
603+ case http.MethodDelete:
604+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
605+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
606+ return
607+ }
608+
609+ if key == "" {
610+ h.sendError(w, http.StatusBadRequest, "key required in path")
611+ return
612+ }
613+
614+ if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
615+ log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
616+ h.sendError(w, http.StatusBadRequest, "invalid domain or service")
617+ return
618+ }
619+
620+ log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
621+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
622+
623+ default:
624+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
625+ }
626+}
627+
555628 // handleHealth handles /admin/health endpoint.
556629 // Returns detailed health status for all routes including HTTP probe results.
557630 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -445,9 +445,6 @@ func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) {
445 445
446 // handleSecrets handles /admin/secrets endpoints.446 // handleSecrets handles /admin/secrets endpoints.
447 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {447 func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path string) {
448- ctx := r.Context()
449- log := zerowrap.FromCtx(ctx)
450-
451 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}448 // Parse path: /secrets/{domain} or /secrets/{domain}/{key}
452 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")449 parts := strings.Split(strings.TrimPrefix(path, "/secrets/"), "/")
453 if len(parts) == 0 || parts[0] == "" {450 if len(parts) == 0 || parts[0] == "" {
@@ -456,6 +453,18 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
456 }453 }
457 454
458 secretDomain := parts[0]455 secretDomain := parts[0]
456+
457+ // 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]
463+ }
464+ h.handleAttachmentSecrets(w, r, secretDomain, service, attachmentKey)
465+ return
466+ }
467+
459 secretKey := ""468 secretKey := ""
460 if len(parts) > 1 {469 if len(parts) > 1 {
461 secretKey = parts[1]470 secretKey = parts[1]
@@ -464,57 +473,66 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
464 switch r.Method {473 switch r.Method {
465 case http.MethodGet:474 case http.MethodGet:
466 h.handleSecretsGet(w, r, secretDomain)475 h.handleSecretsGet(w, r, secretDomain)
467-
468 case http.MethodPost:476 case http.MethodPost:
469- // Check write permission477+ h.handleSecretsPost(w, r, secretDomain)
470- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {478+ case http.MethodDelete:
471- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")479+ h.handleSecretsDelete(w, r, secretDomain, secretKey)
472- return480+ default:
473- }481+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
482+ }
483+}
474 484
475- // Limit request body size485+// handleSecretsPost handles POST /admin/secrets/{domain} - set secrets.
476- r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)486+func (h *Handler) handleSecretsPost(w http.ResponseWriter, r *http.Request, secretDomain string) {
487+ ctx := r.Context()
488+ log := zerowrap.FromCtx(ctx)
477 489
478- // Set secret(s)490+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
479- var data map[string]string491+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
480- if err := json.NewDecoder(r.Body).Decode(&data); err != nil {492+ return
481- log.Warn().Err(err).Msg("invalid secrets JSON")493+ }
482- h.sendError(w, http.StatusBadRequest, "invalid JSON")
483- return
484- }
485 494
486- if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {495+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
487- log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
488- h.sendError(w, http.StatusBadRequest, "invalid domain")
489- return
490- }
491 496
492- log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")497+ var data map[string]string
493- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})498+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
499+ log.Warn().Err(err).Msg("invalid secrets JSON")
500+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
501+ return
502+ }
494 503
495- case http.MethodDelete:504+ if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
496- // Check write permission505+ log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets")
497- if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {506+ h.sendError(w, http.StatusBadRequest, "invalid domain")
498- h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")507+ return
499- return508+ }
500- }
501- if secretKey == "" {
502- h.sendError(w, http.StatusBadRequest, "key required in path")
503- return
504- }
505 509
506- if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {510+ log.Info().Str("domain", secretDomain).Int("count", len(data)).Msg("secrets set")
507- log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")511+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
508- h.sendError(w, http.StatusBadRequest, "invalid domain")512+}
509- return
510- }
511 513
512- log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")514+// handleSecretsDelete handles DELETE /admin/secrets/{domain}/{key} - delete a secret.
513- h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})515+func (h *Handler) handleSecretsDelete(w http.ResponseWriter, r *http.Request, secretDomain, secretKey string) {
516+ ctx := r.Context()
517+ log := zerowrap.FromCtx(ctx)
514 518
515- default:519+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
516- h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")520+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
521+ return
522+ }
523+ if secretKey == "" {
524+ h.sendError(w, http.StatusBadRequest, "key required in path")
525+ return
517 }526 }
527+
528+ if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
529+ log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret")
530+ h.sendError(w, http.StatusBadRequest, "invalid domain")
531+ return
532+ }
533+
534+ log.Info().Str("domain", secretDomain).Str("key", secretKey).Msg("secret deleted")
535+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
518 }536 }
519 537
520 // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.538 // handleSecretsGet handles GET /admin/secrets/{domain} - list secrets.
@@ -552,6 +570,61 @@ func (h *Handler) handleSecretsGet(w http.ResponseWriter, r *http.Request, secre
552 })570 })
553 }571 }
554 572
573+// handleAttachmentSecrets handles /admin/secrets/{domain}/attachments/{service}[/{key}] endpoints.
574+func (h *Handler) handleAttachmentSecrets(w http.ResponseWriter, r *http.Request, secretDomain, service, key string) {
575+ ctx := r.Context()
576+ log := zerowrap.FromCtx(ctx)
577+
578+ switch r.Method {
579+ case http.MethodPost:
580+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
581+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
582+ return
583+ }
584+
585+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
586+
587+ var data map[string]string
588+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
589+ log.Warn().Err(err).Msg("invalid attachment secrets JSON")
590+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
591+ return
592+ }
593+
594+ if err := h.secretSvc.SetAttachment(ctx, secretDomain, service, data); err != nil {
595+ log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Msg("failed to set attachment secrets")
596+ h.sendError(w, http.StatusBadRequest, "invalid domain or service")
597+ return
598+ }
599+
600+ log.Info().Str("domain", secretDomain).Str("service", service).Int("count", len(data)).Msg("attachment secrets set")
601+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "updated"})
602+
603+ case http.MethodDelete:
604+ if !HasAccess(ctx, domain.AdminResourceSecrets, domain.AdminActionWrite) {
605+ h.sendError(w, http.StatusForbidden, "insufficient permissions for secrets:write")
606+ return
607+ }
608+
609+ if key == "" {
610+ h.sendError(w, http.StatusBadRequest, "key required in path")
611+ return
612+ }
613+
614+ if err := h.secretSvc.DeleteAttachment(ctx, secretDomain, service, key); err != nil {
615+ log.Error().Err(err).Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("failed to delete attachment secret")
616+ h.sendError(w, http.StatusBadRequest, "invalid domain or service")
617+ return
618+ }
619+
620+ log.Info().Str("domain", secretDomain).Str("service", service).Str("key", key).Msg("attachment secret deleted")
621+ h.sendJSON(w, http.StatusOK, dto.SecretsStatusResponse{Status: "deleted"})
622+
623+ default:
624+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
625+ }
626+}
627+
555 // handleHealth handles /admin/health endpoint.628 // handleHealth handles /admin/health endpoint.
556 // Returns detailed health status for all routes including HTTP probe results.629 // Returns detailed health status for all routes including HTTP probe results.
557 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {630 func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
M internal/adapters/in/http/admin/handler_test.go +125 -0
@@ -389,6 +389,59 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
389389 }
390390 }
391391
392+// Attachment secrets endpoint tests
393+
394+func TestHandler_AttachmentSecretsPost(t *testing.T) {
395+ configSvc := inmocks.NewMockConfigService(t)
396+ authSvc := inmocks.NewMockAuthService(t)
397+ containerSvc := inmocks.NewMockContainerService(t)
398+ secretSvc := inmocks.NewMockSecretService(t)
399+
400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
401+
402+ secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403+
404+ body := `{"REDIS_URL": "redis://localhost:6379"}`
405+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
406+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
407+ rec := httptest.NewRecorder()
408+
409+ handler.ServeHTTP(rec, req)
410+
411+ assert.Equal(t, http.StatusOK, rec.Code)
412+
413+ var response struct {
414+ Status string `json:"status"`
415+ }
416+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
417+ assert.Equal(t, "updated", response.Status)
418+}
419+
420+func TestHandler_AttachmentSecretsDelete(t *testing.T) {
421+ configSvc := inmocks.NewMockConfigService(t)
422+ authSvc := inmocks.NewMockAuthService(t)
423+ containerSvc := inmocks.NewMockContainerService(t)
424+ secretSvc := inmocks.NewMockSecretService(t)
425+
426+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
427+
428+ secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429+
430+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
431+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
432+ rec := httptest.NewRecorder()
433+
434+ handler.ServeHTTP(rec, req)
435+
436+ assert.Equal(t, http.StatusOK, rec.Code)
437+
438+ var response struct {
439+ Status string `json:"status"`
440+ }
441+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
442+ assert.Equal(t, "deleted", response.Status)
443+}
444+
392445 // Status endpoint tests
393446
394447 func TestHandler_Status_RequiresReadScope(t *testing.T) {
@@ -1040,6 +1093,78 @@ func TestHandler_Tags_Error(t *testing.T) {
10401093 assert.NotContains(t, rec.Body.String(), "registry error")
10411094 }
10421095
1096+func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1097+ configSvc := inmocks.NewMockConfigService(t)
1098+ authSvc := inmocks.NewMockAuthService(t)
1099+ containerSvc := inmocks.NewMockContainerService(t)
1100+ secretSvc := inmocks.NewMockSecretService(t)
1101+
1102+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1103+
1104+ body := `{"REDIS_URL": "redis://localhost:6379"}`
1105+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1106+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1107+ rec := httptest.NewRecorder()
1108+
1109+ handler.ServeHTTP(rec, req)
1110+
1111+ assert.Equal(t, http.StatusForbidden, rec.Code)
1112+}
1113+
1114+func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1115+ configSvc := inmocks.NewMockConfigService(t)
1116+ authSvc := inmocks.NewMockAuthService(t)
1117+ containerSvc := inmocks.NewMockContainerService(t)
1118+ secretSvc := inmocks.NewMockSecretService(t)
1119+
1120+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1121+
1122+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1123+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1124+ rec := httptest.NewRecorder()
1125+
1126+ handler.ServeHTTP(rec, req)
1127+
1128+ assert.Equal(t, http.StatusForbidden, rec.Code)
1129+}
1130+
1131+func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1132+ configSvc := inmocks.NewMockConfigService(t)
1133+ authSvc := inmocks.NewMockAuthService(t)
1134+ containerSvc := inmocks.NewMockContainerService(t)
1135+ secretSvc := inmocks.NewMockSecretService(t)
1136+
1137+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1138+
1139+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1140+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1141+ rec := httptest.NewRecorder()
1142+
1143+ handler.ServeHTTP(rec, req)
1144+
1145+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1146+ assert.Contains(t, rec.Body.String(), "key required")
1147+}
1148+
1149+func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1150+ configSvc := inmocks.NewMockConfigService(t)
1151+ authSvc := inmocks.NewMockAuthService(t)
1152+ containerSvc := inmocks.NewMockContainerService(t)
1153+ secretSvc := inmocks.NewMockSecretService(t)
1154+
1155+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1156+
1157+ body := `{not valid json`
1158+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1159+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1160+ rec := httptest.NewRecorder()
1161+
1162+ handler.ServeHTTP(rec, req)
1163+
1164+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1165+ assert.Contains(t, rec.Body.String(), "invalid JSON")
1166+}
1167+
10431168 func TestHandler_NotFound(t *testing.T) {
10441169 configSvc := inmocks.NewMockConfigService(t)
10451170 authSvc := inmocks.NewMockAuthService(t)
@@ -389,6 +389,59 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
389 }389 }
390 }390 }
391 391
392+// Attachment secrets endpoint tests
393+
394+func TestHandler_AttachmentSecretsPost(t *testing.T) {
395+ configSvc := inmocks.NewMockConfigService(t)
396+ authSvc := inmocks.NewMockAuthService(t)
397+ containerSvc := inmocks.NewMockContainerService(t)
398+ secretSvc := inmocks.NewMockSecretService(t)
399+
400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
401+
402+ secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403+
404+ body := `{"REDIS_URL": "redis://localhost:6379"}`
405+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
406+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
407+ rec := httptest.NewRecorder()
408+
409+ handler.ServeHTTP(rec, req)
410+
411+ assert.Equal(t, http.StatusOK, rec.Code)
412+
413+ var response struct {
414+ Status string `json:"status"`
415+ }
416+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
417+ assert.Equal(t, "updated", response.Status)
418+}
419+
420+func TestHandler_AttachmentSecretsDelete(t *testing.T) {
421+ configSvc := inmocks.NewMockConfigService(t)
422+ authSvc := inmocks.NewMockAuthService(t)
423+ containerSvc := inmocks.NewMockContainerService(t)
424+ secretSvc := inmocks.NewMockSecretService(t)
425+
426+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
427+
428+ secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429+
430+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
431+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
432+ rec := httptest.NewRecorder()
433+
434+ handler.ServeHTTP(rec, req)
435+
436+ assert.Equal(t, http.StatusOK, rec.Code)
437+
438+ var response struct {
439+ Status string `json:"status"`
440+ }
441+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
442+ assert.Equal(t, "deleted", response.Status)
443+}
444+
392 // Status endpoint tests445 // Status endpoint tests
393 446
394 func TestHandler_Status_RequiresReadScope(t *testing.T) {447 func TestHandler_Status_RequiresReadScope(t *testing.T) {
@@ -1040,6 +1093,78 @@ func TestHandler_Tags_Error(t *testing.T) {
1040 assert.NotContains(t, rec.Body.String(), "registry error")1093 assert.NotContains(t, rec.Body.String(), "registry error")
1041 }1094 }
1042 1095
1096+func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1097+ configSvc := inmocks.NewMockConfigService(t)
1098+ authSvc := inmocks.NewMockAuthService(t)
1099+ containerSvc := inmocks.NewMockContainerService(t)
1100+ secretSvc := inmocks.NewMockSecretService(t)
1101+
1102+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1103+
1104+ body := `{"REDIS_URL": "redis://localhost:6379"}`
1105+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1106+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1107+ rec := httptest.NewRecorder()
1108+
1109+ handler.ServeHTTP(rec, req)
1110+
1111+ assert.Equal(t, http.StatusForbidden, rec.Code)
1112+}
1113+
1114+func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1115+ configSvc := inmocks.NewMockConfigService(t)
1116+ authSvc := inmocks.NewMockAuthService(t)
1117+ containerSvc := inmocks.NewMockContainerService(t)
1118+ secretSvc := inmocks.NewMockSecretService(t)
1119+
1120+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1121+
1122+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1123+ req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1124+ rec := httptest.NewRecorder()
1125+
1126+ handler.ServeHTTP(rec, req)
1127+
1128+ assert.Equal(t, http.StatusForbidden, rec.Code)
1129+}
1130+
1131+func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1132+ configSvc := inmocks.NewMockConfigService(t)
1133+ authSvc := inmocks.NewMockAuthService(t)
1134+ containerSvc := inmocks.NewMockContainerService(t)
1135+ secretSvc := inmocks.NewMockSecretService(t)
1136+
1137+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1138+
1139+ req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1140+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1141+ rec := httptest.NewRecorder()
1142+
1143+ handler.ServeHTTP(rec, req)
1144+
1145+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1146+ assert.Contains(t, rec.Body.String(), "key required")
1147+}
1148+
1149+func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1150+ configSvc := inmocks.NewMockConfigService(t)
1151+ authSvc := inmocks.NewMockAuthService(t)
1152+ containerSvc := inmocks.NewMockContainerService(t)
1153+ secretSvc := inmocks.NewMockSecretService(t)
1154+
1155+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1156+
1157+ body := `{not valid json`
1158+ req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1159+ req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1160+ rec := httptest.NewRecorder()
1161+
1162+ handler.ServeHTTP(rec, req)
1163+
1164+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1165+ assert.Contains(t, rec.Body.String(), "invalid JSON")
1166+}
1167+
1043 func TestHandler_NotFound(t *testing.T) {1168 func TestHandler_NotFound(t *testing.T) {
1044 configSvc := inmocks.NewMockConfigService(t)1169 configSvc := inmocks.NewMockConfigService(t)
1045 authSvc := inmocks.NewMockAuthService(t)1170 authSvc := inmocks.NewMockAuthService(t)
M internal/adapters/out/domainsecrets/pass_store.go +45 -0
@@ -299,6 +299,51 @@ func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]st
299299 return nil
300300 }
301301
302+// DeleteAttachment removes a specific secret key from an attachment container.
303+func (s *PassStore) DeleteAttachment(containerName, key string) error {
304+ if _, err := s.attachmentPath(containerName); err != nil {
305+ return err
306+ }
307+
308+ keyPath, err := s.attachmentKeyPath(containerName, key)
309+ if err != nil {
310+ return err
311+ }
312+
313+ rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
314+ defer rmCancel()
315+ if err := s.passRemove(rmCtx, keyPath); err != nil {
316+ return err
317+ }
318+
319+ keys, err := s.listAttachmentKeys(containerName)
320+ if err != nil {
321+ return err
322+ }
323+
324+ updated := make([]string, 0, len(keys))
325+ for _, existingKey := range keys {
326+ if existingKey == key {
327+ continue
328+ }
329+ updated = append(updated, existingKey)
330+ }
331+ sort.Strings(updated)
332+
333+ manifestPath, err := s.attachmentManifestPath(containerName)
334+ if err != nil {
335+ return err
336+ }
337+
338+ insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
339+ defer insertCancel()
340+ if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
341+ return fmt.Errorf("failed to update attachment manifest: %w", err)
342+ }
343+
344+ return nil
345+}
346+
302347 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
303348 func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304349 keys, err := s.listAttachmentKeys(containerName)
@@ -299,6 +299,51 @@ func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]st
299 return nil299 return nil
300 }300 }
301 301
302+// DeleteAttachment removes a specific secret key from an attachment container.
303+func (s *PassStore) DeleteAttachment(containerName, key string) error {
304+ if _, err := s.attachmentPath(containerName); err != nil {
305+ return err
306+ }
307+
308+ keyPath, err := s.attachmentKeyPath(containerName, key)
309+ if err != nil {
310+ return err
311+ }
312+
313+ rmCtx, rmCancel := context.WithTimeout(context.Background(), s.timeout)
314+ defer rmCancel()
315+ if err := s.passRemove(rmCtx, keyPath); err != nil {
316+ return err
317+ }
318+
319+ keys, err := s.listAttachmentKeys(containerName)
320+ if err != nil {
321+ return err
322+ }
323+
324+ updated := make([]string, 0, len(keys))
325+ for _, existingKey := range keys {
326+ if existingKey == key {
327+ continue
328+ }
329+ updated = append(updated, existingKey)
330+ }
331+ sort.Strings(updated)
332+
333+ manifestPath, err := s.attachmentManifestPath(containerName)
334+ if err != nil {
335+ return err
336+ }
337+
338+ insertCtx, insertCancel := context.WithTimeout(context.Background(), s.timeout)
339+ defer insertCancel()
340+ if err := s.passInsert(insertCtx, manifestPath, strings.Join(updated, "\n")); err != nil {
341+ return fmt.Errorf("failed to update attachment manifest: %w", err)
342+ }
343+
344+ return nil
345+}
346+
302 // GetAllAttachment returns all secrets for an attachment container as a key-value map.347 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
303 func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {348 func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304 keys, err := s.listAttachmentKeys(containerName)349 keys, err := s.listAttachmentKeys(containerName)
M internal/adapters/out/domainsecrets/pass_store_test.go +31 -0
@@ -126,6 +126,37 @@ func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) {
126126 assert.Equal(t, "redis123", values["REDIS_PASSWORD"])
127127 }
128128
129+func TestPassStore_DeleteAttachment(t *testing.T) {
130+ requirePass(t)
131+
132+ store, err := NewPassStore(testLogger())
133+ require.NoError(t, err)
134+
135+ containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
136+ keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
137+ defer CleanupPassAttachment(t, containerName, keys)
138+
139+ // Set 2 secrets
140+ secretsMap := map[string]string{
141+ "POSTGRES_USER": "gitea",
142+ "POSTGRES_PASSWORD": "secret123",
143+ }
144+ err = store.SetAttachment(containerName, secretsMap)
145+ require.NoError(t, err)
146+
147+ // Delete 1
148+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
149+ require.NoError(t, err)
150+
151+ // Verify remaining via GetAllAttachment
152+ values, err := store.GetAllAttachment(containerName)
153+ require.NoError(t, err)
154+ assert.Len(t, values, 1)
155+ assert.Equal(t, "gitea", values["POSTGRES_USER"])
156+ _, exists := values["POSTGRES_PASSWORD"]
157+ assert.False(t, exists)
158+}
159+
129160 func TestParsePassListOutput(t *testing.T) {
130161 tests := []struct {
131162 name string
@@ -126,6 +126,37 @@ func TestPassStore_ListAttachmentKeys_AfterSet(t *testing.T) {
126 assert.Equal(t, "redis123", values["REDIS_PASSWORD"])126 assert.Equal(t, "redis123", values["REDIS_PASSWORD"])
127 }127 }
128 128
129+func TestPassStore_DeleteAttachment(t *testing.T) {
130+ requirePass(t)
131+
132+ store, err := NewPassStore(testLogger())
133+ require.NoError(t, err)
134+
135+ containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
136+ keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
137+ defer CleanupPassAttachment(t, containerName, keys)
138+
139+ // Set 2 secrets
140+ secretsMap := map[string]string{
141+ "POSTGRES_USER": "gitea",
142+ "POSTGRES_PASSWORD": "secret123",
143+ }
144+ err = store.SetAttachment(containerName, secretsMap)
145+ require.NoError(t, err)
146+
147+ // Delete 1
148+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
149+ require.NoError(t, err)
150+
151+ // Verify remaining via GetAllAttachment
152+ values, err := store.GetAllAttachment(containerName)
153+ require.NoError(t, err)
154+ assert.Len(t, values, 1)
155+ assert.Equal(t, "gitea", values["POSTGRES_USER"])
156+ _, exists := values["POSTGRES_PASSWORD"]
157+ assert.False(t, exists)
158+}
159+
129 func TestParsePassListOutput(t *testing.T) {160 func TestParsePassListOutput(t *testing.T) {
130 tests := []struct {161 tests := []struct {
131 name string162 name string
M internal/adapters/out/domainsecrets/store.go +20 -0
@@ -176,6 +176,26 @@ func (s *FileStore) SetAttachment(containerName string, secrets map[string]strin
176176 return s.writeAttachmentSecretsAtomic(containerName, existing)
177177 }
178178
179+// DeleteAttachment removes a specific secret key from an attachment container.
180+func (s *FileStore) DeleteAttachment(containerName, key string) error {
181+ // Validate container name first
182+ if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
183+ return err
184+ }
185+
186+ // Read existing secrets
187+ existing, err := s.GetAllAttachment(containerName)
188+ if err != nil {
189+ return err
190+ }
191+
192+ // Remove the key (no-op if not present)
193+ delete(existing, key)
194+
195+ // Write back atomically
196+ return s.writeAttachmentSecretsAtomic(containerName, existing)
197+}
198+
179199 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
180200 func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
181201 envFile, err := s.validateAttachmentEnvFilePath(containerName)
@@ -176,6 +176,26 @@ func (s *FileStore) SetAttachment(containerName string, secrets map[string]strin
176 return s.writeAttachmentSecretsAtomic(containerName, existing)176 return s.writeAttachmentSecretsAtomic(containerName, existing)
177 }177 }
178 178
179+// DeleteAttachment removes a specific secret key from an attachment container.
180+func (s *FileStore) DeleteAttachment(containerName, key string) error {
181+ // Validate container name first
182+ if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
183+ return err
184+ }
185+
186+ // Read existing secrets
187+ existing, err := s.GetAllAttachment(containerName)
188+ if err != nil {
189+ return err
190+ }
191+
192+ // Remove the key (no-op if not present)
193+ delete(existing, key)
194+
195+ // Write back atomically
196+ return s.writeAttachmentSecretsAtomic(containerName, existing)
197+}
198+
179 // GetAllAttachment returns all secrets for an attachment container as a key-value map.199 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
180 func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {200 func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
181 envFile, err := s.validateAttachmentEnvFilePath(containerName)201 envFile, err := s.validateAttachmentEnvFilePath(containerName)
M internal/adapters/out/domainsecrets/store_test.go +40 -0
@@ -336,6 +336,46 @@ func TestFileStore_ValidDomainOperations(t *testing.T) {
336336 assert.False(t, exists)
337337 }
338338
339+func TestFileStore_DeleteAttachment(t *testing.T) {
340+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
341+ require.NoError(t, err)
342+ defer os.RemoveAll(tmpDir)
343+
344+ store, err := NewFileStore(tmpDir, testLogger())
345+ require.NoError(t, err)
346+
347+ containerName := "gitea-postgres"
348+
349+ // Seed with two secrets
350+ err = store.SetAttachment(containerName, map[string]string{
351+ "POSTGRES_USER": "gitea",
352+ "POSTGRES_PASSWORD": "secret123",
353+ })
354+ require.NoError(t, err)
355+
356+ // Delete one key
357+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
358+ require.NoError(t, err)
359+
360+ // Verify remaining
361+ secrets, err := store.GetAllAttachment(containerName)
362+ require.NoError(t, err)
363+ assert.Len(t, secrets, 1)
364+ assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
365+ _, exists := secrets["POSTGRES_PASSWORD"]
366+ assert.False(t, exists)
367+
368+ // Delete nonexistent key - should be no-op
369+ err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY")
370+ assert.NoError(t, err)
371+
372+ // Verify secrets unchanged after no-op delete
373+ secrets, err = store.GetAllAttachment(containerName)
374+ require.NoError(t, err)
375+ assert.Len(t, secrets, 1)
376+ assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
377+}
378+
339379 func TestGetEnvFilePath(t *testing.T) {
340380 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
341381 require.NoError(t, err)
@@ -336,6 +336,46 @@ func TestFileStore_ValidDomainOperations(t *testing.T) {
336 assert.False(t, exists)336 assert.False(t, exists)
337 }337 }
338 338
339+func TestFileStore_DeleteAttachment(t *testing.T) {
340+ tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
341+ require.NoError(t, err)
342+ defer os.RemoveAll(tmpDir)
343+
344+ store, err := NewFileStore(tmpDir, testLogger())
345+ require.NoError(t, err)
346+
347+ containerName := "gitea-postgres"
348+
349+ // Seed with two secrets
350+ err = store.SetAttachment(containerName, map[string]string{
351+ "POSTGRES_USER": "gitea",
352+ "POSTGRES_PASSWORD": "secret123",
353+ })
354+ require.NoError(t, err)
355+
356+ // Delete one key
357+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
358+ require.NoError(t, err)
359+
360+ // Verify remaining
361+ secrets, err := store.GetAllAttachment(containerName)
362+ require.NoError(t, err)
363+ assert.Len(t, secrets, 1)
364+ assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
365+ _, exists := secrets["POSTGRES_PASSWORD"]
366+ assert.False(t, exists)
367+
368+ // Delete nonexistent key - should be no-op
369+ err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY")
370+ assert.NoError(t, err)
371+
372+ // Verify secrets unchanged after no-op delete
373+ secrets, err = store.GetAllAttachment(containerName)
374+ require.NoError(t, err)
375+ assert.Len(t, secrets, 1)
376+ assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
377+}
378+
339 func TestGetEnvFilePath(t *testing.T) {379 func TestGetEnvFilePath(t *testing.T) {
340 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")380 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
341 require.NoError(t, err)381 require.NoError(t, err)
M internal/boundaries/in/mocks/mock_secret_service.go +138 -0
@@ -101,6 +101,75 @@ func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Conte
101101 return _c
102102 }
103103
104+// DeleteAttachment provides a mock function for the type MockSecretService
105+func (_mock *MockSecretService) DeleteAttachment(ctx context.Context, domain string, service string, key string) error {
106+ ret := _mock.Called(ctx, domain, service, key)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for DeleteAttachment")
110+ }
111+
112+ var r0 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
114+ r0 = returnFunc(ctx, domain, service, key)
115+ } else {
116+ r0 = ret.Error(0)
117+ }
118+ return r0
119+}
120+
121+// MockSecretService_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment'
122+type MockSecretService_DeleteAttachment_Call struct {
123+ *mock.Call
124+}
125+
126+// DeleteAttachment is a helper method to define mock.On call
127+// - ctx context.Context
128+// - domain string
129+// - service string
130+// - key string
131+func (_e *MockSecretService_Expecter) DeleteAttachment(ctx interface{}, domain interface{}, service interface{}, key interface{}) *MockSecretService_DeleteAttachment_Call {
132+ return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)}
133+}
134+
135+func (_c *MockSecretService_DeleteAttachment_Call) Run(run func(ctx context.Context, domain string, service string, key string)) *MockSecretService_DeleteAttachment_Call {
136+ _c.Call.Run(func(args mock.Arguments) {
137+ var arg0 context.Context
138+ if args[0] != nil {
139+ arg0 = args[0].(context.Context)
140+ }
141+ var arg1 string
142+ if args[1] != nil {
143+ arg1 = args[1].(string)
144+ }
145+ var arg2 string
146+ if args[2] != nil {
147+ arg2 = args[2].(string)
148+ }
149+ var arg3 string
150+ if args[3] != nil {
151+ arg3 = args[3].(string)
152+ }
153+ run(
154+ arg0,
155+ arg1,
156+ arg2,
157+ arg3,
158+ )
159+ })
160+ return _c
161+}
162+
163+func (_c *MockSecretService_DeleteAttachment_Call) Return(err error) *MockSecretService_DeleteAttachment_Call {
164+ _c.Call.Return(err)
165+ return _c
166+}
167+
168+func (_c *MockSecretService_DeleteAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, key string) error) *MockSecretService_DeleteAttachment_Call {
169+ _c.Call.Return(run)
170+ return _c
171+}
172+
104173 // GetAll provides a mock function for the type MockSecretService
105174 func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) {
106175 ret := _mock.Called(ctx, domain)
@@ -375,3 +444,72 @@ func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context,
375444 _c.Call.Return(run)
376445 return _c
377446 }
447+
448+// SetAttachment provides a mock function for the type MockSecretService
449+func (_mock *MockSecretService) SetAttachment(ctx context.Context, domain string, service string, secrets map[string]string) error {
450+ ret := _mock.Called(ctx, domain, service, secrets)
451+
452+ if len(ret) == 0 {
453+ panic("no return value specified for SetAttachment")
454+ }
455+
456+ var r0 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok {
458+ r0 = returnFunc(ctx, domain, service, secrets)
459+ } else {
460+ r0 = ret.Error(0)
461+ }
462+ return r0
463+}
464+
465+// MockSecretService_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment'
466+type MockSecretService_SetAttachment_Call struct {
467+ *mock.Call
468+}
469+
470+// SetAttachment is a helper method to define mock.On call
471+// - ctx context.Context
472+// - domain string
473+// - service string
474+// - secrets map[string]string
475+func (_e *MockSecretService_Expecter) SetAttachment(ctx interface{}, domain interface{}, service interface{}, secrets interface{}) *MockSecretService_SetAttachment_Call {
476+ return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)}
477+}
478+
479+func (_c *MockSecretService_SetAttachment_Call) Run(run func(ctx context.Context, domain string, service string, secrets map[string]string)) *MockSecretService_SetAttachment_Call {
480+ _c.Call.Run(func(args mock.Arguments) {
481+ var arg0 context.Context
482+ if args[0] != nil {
483+ arg0 = args[0].(context.Context)
484+ }
485+ var arg1 string
486+ if args[1] != nil {
487+ arg1 = args[1].(string)
488+ }
489+ var arg2 string
490+ if args[2] != nil {
491+ arg2 = args[2].(string)
492+ }
493+ var arg3 map[string]string
494+ if args[3] != nil {
495+ arg3 = args[3].(map[string]string)
496+ }
497+ run(
498+ arg0,
499+ arg1,
500+ arg2,
501+ arg3,
502+ )
503+ })
504+ return _c
505+}
506+
507+func (_c *MockSecretService_SetAttachment_Call) Return(err error) *MockSecretService_SetAttachment_Call {
508+ _c.Call.Return(err)
509+ return _c
510+}
511+
512+func (_c *MockSecretService_SetAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, secrets map[string]string) error) *MockSecretService_SetAttachment_Call {
513+ _c.Call.Return(run)
514+ return _c
515+}
@@ -101,6 +101,75 @@ func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Conte
101 return _c101 return _c
102 }102 }
103 103
104+// DeleteAttachment provides a mock function for the type MockSecretService
105+func (_mock *MockSecretService) DeleteAttachment(ctx context.Context, domain string, service string, key string) error {
106+ ret := _mock.Called(ctx, domain, service, key)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for DeleteAttachment")
110+ }
111+
112+ var r0 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok {
114+ r0 = returnFunc(ctx, domain, service, key)
115+ } else {
116+ r0 = ret.Error(0)
117+ }
118+ return r0
119+}
120+
121+// MockSecretService_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment'
122+type MockSecretService_DeleteAttachment_Call struct {
123+ *mock.Call
124+}
125+
126+// DeleteAttachment is a helper method to define mock.On call
127+// - ctx context.Context
128+// - domain string
129+// - service string
130+// - key string
131+func (_e *MockSecretService_Expecter) DeleteAttachment(ctx interface{}, domain interface{}, service interface{}, key interface{}) *MockSecretService_DeleteAttachment_Call {
132+ return &MockSecretService_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", ctx, domain, service, key)}
133+}
134+
135+func (_c *MockSecretService_DeleteAttachment_Call) Run(run func(ctx context.Context, domain string, service string, key string)) *MockSecretService_DeleteAttachment_Call {
136+ _c.Call.Run(func(args mock.Arguments) {
137+ var arg0 context.Context
138+ if args[0] != nil {
139+ arg0 = args[0].(context.Context)
140+ }
141+ var arg1 string
142+ if args[1] != nil {
143+ arg1 = args[1].(string)
144+ }
145+ var arg2 string
146+ if args[2] != nil {
147+ arg2 = args[2].(string)
148+ }
149+ var arg3 string
150+ if args[3] != nil {
151+ arg3 = args[3].(string)
152+ }
153+ run(
154+ arg0,
155+ arg1,
156+ arg2,
157+ arg3,
158+ )
159+ })
160+ return _c
161+}
162+
163+func (_c *MockSecretService_DeleteAttachment_Call) Return(err error) *MockSecretService_DeleteAttachment_Call {
164+ _c.Call.Return(err)
165+ return _c
166+}
167+
168+func (_c *MockSecretService_DeleteAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, key string) error) *MockSecretService_DeleteAttachment_Call {
169+ _c.Call.Return(run)
170+ return _c
171+}
172+
104 // GetAll provides a mock function for the type MockSecretService173 // GetAll provides a mock function for the type MockSecretService
105 func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) {174 func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) {
106 ret := _mock.Called(ctx, domain)175 ret := _mock.Called(ctx, domain)
@@ -375,3 +444,72 @@ func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context,
375 _c.Call.Return(run)444 _c.Call.Return(run)
376 return _c445 return _c
377 }446 }
447+
448+// SetAttachment provides a mock function for the type MockSecretService
449+func (_mock *MockSecretService) SetAttachment(ctx context.Context, domain string, service string, secrets map[string]string) error {
450+ ret := _mock.Called(ctx, domain, service, secrets)
451+
452+ if len(ret) == 0 {
453+ panic("no return value specified for SetAttachment")
454+ }
455+
456+ var r0 error
457+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, map[string]string) error); ok {
458+ r0 = returnFunc(ctx, domain, service, secrets)
459+ } else {
460+ r0 = ret.Error(0)
461+ }
462+ return r0
463+}
464+
465+// MockSecretService_SetAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetAttachment'
466+type MockSecretService_SetAttachment_Call struct {
467+ *mock.Call
468+}
469+
470+// SetAttachment is a helper method to define mock.On call
471+// - ctx context.Context
472+// - domain string
473+// - service string
474+// - secrets map[string]string
475+func (_e *MockSecretService_Expecter) SetAttachment(ctx interface{}, domain interface{}, service interface{}, secrets interface{}) *MockSecretService_SetAttachment_Call {
476+ return &MockSecretService_SetAttachment_Call{Call: _e.mock.On("SetAttachment", ctx, domain, service, secrets)}
477+}
478+
479+func (_c *MockSecretService_SetAttachment_Call) Run(run func(ctx context.Context, domain string, service string, secrets map[string]string)) *MockSecretService_SetAttachment_Call {
480+ _c.Call.Run(func(args mock.Arguments) {
481+ var arg0 context.Context
482+ if args[0] != nil {
483+ arg0 = args[0].(context.Context)
484+ }
485+ var arg1 string
486+ if args[1] != nil {
487+ arg1 = args[1].(string)
488+ }
489+ var arg2 string
490+ if args[2] != nil {
491+ arg2 = args[2].(string)
492+ }
493+ var arg3 map[string]string
494+ if args[3] != nil {
495+ arg3 = args[3].(map[string]string)
496+ }
497+ run(
498+ arg0,
499+ arg1,
500+ arg2,
501+ arg3,
502+ )
503+ })
504+ return _c
505+}
506+
507+func (_c *MockSecretService_SetAttachment_Call) Return(err error) *MockSecretService_SetAttachment_Call {
508+ _c.Call.Return(err)
509+ return _c
510+}
511+
512+func (_c *MockSecretService_SetAttachment_Call) RunAndReturn(run func(ctx context.Context, domain string, service string, secrets map[string]string) error) *MockSecretService_SetAttachment_Call {
513+ _c.Call.Return(run)
514+ return _c
515+}
M internal/boundaries/in/secrets.go +8 -0
@@ -28,4 +28,12 @@ type SecretService interface {
2828 // Delete removes a specific secret key from a domain.
2929 // Returns an error if the domain is invalid.
3030 Delete(ctx context.Context, domain, key string) error
31+
32+ // SetAttachment sets or updates multiple secrets for an attachment container.
33+ // The container name is derived from the domain and service name.
34+ SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error
35+
36+ // DeleteAttachment removes a specific secret key from an attachment container.
37+ // The container name is derived from the domain and service name.
38+ DeleteAttachment(ctx context.Context, domain, service, key string) error
3139 }
@@ -28,4 +28,12 @@ type SecretService interface {
28 // Delete removes a specific secret key from a domain.28 // Delete removes a specific secret key from a domain.
29 // Returns an error if the domain is invalid.29 // Returns an error if the domain is invalid.
30 Delete(ctx context.Context, domain, key string) error30 Delete(ctx context.Context, domain, key string) error
31+
32+ // SetAttachment sets or updates multiple secrets for an attachment container.
33+ // The container name is derived from the domain and service name.
34+ SetAttachment(ctx context.Context, domain, service string, secrets map[string]string) error
35+
36+ // DeleteAttachment removes a specific secret key from an attachment container.
37+ // The container name is derived from the domain and service name.
38+ DeleteAttachment(ctx context.Context, domain, service, key string) error
31 }39 }
M internal/boundaries/out/domain_secrets.go +3 -0
@@ -29,6 +29,9 @@ type DomainSecretStore interface {
2929 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
3030 GetAllAttachment(containerName string) (map[string]string, error)
3131
32+ // DeleteAttachment removes a specific secret key from an attachment container.
33+ DeleteAttachment(containerName, key string) error
34+
3235 // ListAttachmentKeys finds and returns secret keys for attachment containers
3336 // associated with the given domain. Returns a list of AttachmentSecrets, one
3437 // for each attachment that has secrets configured.
@@ -29,6 +29,9 @@ type DomainSecretStore interface {
29 // GetAllAttachment returns all secrets for an attachment container as a key-value map.29 // GetAllAttachment returns all secrets for an attachment container as a key-value map.
30 GetAllAttachment(containerName string) (map[string]string, error)30 GetAllAttachment(containerName string) (map[string]string, error)
31 31
32+ // DeleteAttachment removes a specific secret key from an attachment container.
33+ DeleteAttachment(containerName, key string) error
34+
32 // ListAttachmentKeys finds and returns secret keys for attachment containers35 // ListAttachmentKeys finds and returns secret keys for attachment containers
33 // associated with the given domain. Returns a list of AttachmentSecrets, one36 // associated with the given domain. Returns a list of AttachmentSecrets, one
34 // for each attachment that has secrets configured.37 // for each attachment that has secrets configured.
M internal/boundaries/out/mocks/mock_domain_secret_store.go +57 -0
@@ -93,6 +93,63 @@ func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string
9393 return _c
9494 }
9595
96+// DeleteAttachment provides a mock function for the type MockDomainSecretStore
97+func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error {
98+ ret := _mock.Called(containerName, key)
99+
100+ if len(ret) == 0 {
101+ panic("no return value specified for DeleteAttachment")
102+ }
103+
104+ var r0 error
105+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
106+ r0 = returnFunc(containerName, key)
107+ } else {
108+ r0 = ret.Error(0)
109+ }
110+ return r0
111+}
112+
113+// MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment'
114+type MockDomainSecretStore_DeleteAttachment_Call struct {
115+ *mock.Call
116+}
117+
118+// DeleteAttachment is a helper method to define mock.On call
119+// - containerName string
120+// - key string
121+func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName interface{}, key interface{}) *MockDomainSecretStore_DeleteAttachment_Call {
122+ return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}
123+}
124+
125+func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 string
128+ if args[0] != nil {
129+ arg0 = args[0].(string)
130+ }
131+ var arg1 string
132+ if args[1] != nil {
133+ arg1 = args[1].(string)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ )
139+ })
140+ return _c
141+}
142+
143+func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call {
144+ _c.Call.Return(err)
145+ return _c
146+}
147+
148+func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call {
149+ _c.Call.Return(run)
150+ return _c
151+}
152+
96153 // GetAll provides a mock function for the type MockDomainSecretStore
97154 func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) {
98155 ret := _mock.Called(domain)
@@ -93,6 +93,63 @@ func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string
93 return _c93 return _c
94 }94 }
95 95
96+// DeleteAttachment provides a mock function for the type MockDomainSecretStore
97+func (_mock *MockDomainSecretStore) DeleteAttachment(containerName string, key string) error {
98+ ret := _mock.Called(containerName, key)
99+
100+ if len(ret) == 0 {
101+ panic("no return value specified for DeleteAttachment")
102+ }
103+
104+ var r0 error
105+ if returnFunc, ok := ret.Get(0).(func(string, string) error); ok {
106+ r0 = returnFunc(containerName, key)
107+ } else {
108+ r0 = ret.Error(0)
109+ }
110+ return r0
111+}
112+
113+// MockDomainSecretStore_DeleteAttachment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteAttachment'
114+type MockDomainSecretStore_DeleteAttachment_Call struct {
115+ *mock.Call
116+}
117+
118+// DeleteAttachment is a helper method to define mock.On call
119+// - containerName string
120+// - key string
121+func (_e *MockDomainSecretStore_Expecter) DeleteAttachment(containerName interface{}, key interface{}) *MockDomainSecretStore_DeleteAttachment_Call {
122+ return &MockDomainSecretStore_DeleteAttachment_Call{Call: _e.mock.On("DeleteAttachment", containerName, key)}
123+}
124+
125+func (_c *MockDomainSecretStore_DeleteAttachment_Call) Run(run func(containerName string, key string)) *MockDomainSecretStore_DeleteAttachment_Call {
126+ _c.Call.Run(func(args mock.Arguments) {
127+ var arg0 string
128+ if args[0] != nil {
129+ arg0 = args[0].(string)
130+ }
131+ var arg1 string
132+ if args[1] != nil {
133+ arg1 = args[1].(string)
134+ }
135+ run(
136+ arg0,
137+ arg1,
138+ )
139+ })
140+ return _c
141+}
142+
143+func (_c *MockDomainSecretStore_DeleteAttachment_Call) Return(err error) *MockDomainSecretStore_DeleteAttachment_Call {
144+ _c.Call.Return(err)
145+ return _c
146+}
147+
148+func (_c *MockDomainSecretStore_DeleteAttachment_Call) RunAndReturn(run func(containerName string, key string) error) *MockDomainSecretStore_DeleteAttachment_Call {
149+ _c.Call.Return(run)
150+ return _c
151+}
152+
96 // GetAll provides a mock function for the type MockDomainSecretStore153 // GetAll provides a mock function for the type MockDomainSecretStore
97 func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) {154 func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) {
98 ret := _mock.Called(domain)155 ret := _mock.Called(domain)
M internal/usecase/secrets/service.go +99 -0
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/zerowrap"
1010
1111 "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
1213 )
1314
1415 // Domain validation errors.
@@ -17,6 +18,8 @@ var (
1718 ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")
1819 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
1920 ErrDomainInvalidChars = errors.New("domain contains invalid characters")
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")
2023 )
2124
2225 // Service implements the SecretService interface.
@@ -165,6 +168,102 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
165168 return nil
166169 }
167170
171+// SetAttachment sets or updates multiple secrets for an attachment container.
172+func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
173+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
174+ zerowrap.FieldLayer: "usecase",
175+ zerowrap.FieldUseCase: "SetAttachment",
176+ "domain": domainName,
177+ "service": service,
178+ })
179+ log := zerowrap.FromCtx(ctx)
180+
181+ if err := ValidateDomain(domainName); err != nil {
182+ log.Warn().Err(err).Msg("domain validation failed")
183+ return err
184+ }
185+
186+ if err := validateServiceName(service); err != nil {
187+ log.Warn().Err(err).Msg("service name validation failed")
188+ return err
189+ }
190+
191+ containerName := resolveContainerName(domainName, service)
192+
193+ if err := s.store.SetAttachment(containerName, secrets); err != nil {
194+ log.Error().Err(err).Msg("failed to set attachment secrets")
195+ return err
196+ }
197+
198+ log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199+ return nil
200+}
201+
202+// DeleteAttachment removes a specific secret key from an attachment container.
203+func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
204+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
205+ zerowrap.FieldLayer: "usecase",
206+ zerowrap.FieldUseCase: "DeleteAttachment",
207+ "domain": domainName,
208+ "service": service,
209+ "key": key,
210+ })
211+ log := zerowrap.FromCtx(ctx)
212+
213+ if err := ValidateDomain(domainName); err != nil {
214+ log.Warn().Err(err).Msg("domain validation failed")
215+ return err
216+ }
217+
218+ if err := validateServiceName(service); err != nil {
219+ log.Warn().Err(err).Msg("service name validation failed")
220+ return err
221+ }
222+
223+ containerName := resolveContainerName(domainName, service)
224+
225+ if err := s.store.DeleteAttachment(containerName, key); err != nil {
226+ log.Error().Err(err).Msg("failed to delete attachment secret")
227+ return err
228+ }
229+
230+ log.Info().Str("container", containerName).Msg("attachment secret deleted")
231+ return nil
232+}
233+
234+// validateServiceName checks that a service name is safe and follows Docker-style conventions.
235+func validateServiceName(service string) error {
236+ if service == "" {
237+ return ErrServiceEmpty
238+ }
239+ // DNS labels are limited to 63 characters (RFC 1035)
240+ if len(service) > 63 {
241+ return ErrInvalidServiceName
242+ }
243+ // Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens
244+ for i, c := range service {
245+ if i == 0 {
246+ if c < 'a' || c > 'z' {
247+ return ErrInvalidServiceName
248+ }
249+ continue
250+ }
251+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
252+ return ErrInvalidServiceName
253+ }
254+ }
255+ // Trailing hyphen is not allowed in DNS labels
256+ if service[len(service)-1] == '-' {
257+ return ErrInvalidServiceName
258+ }
259+ return nil
260+}
261+
262+// resolveContainerName builds the container name from a domain and service.
263+func resolveContainerName(domainName, service string) string {
264+ return domain.SanitizeDomainForContainer(domainName) + "-" + service
265+}
266+
168267 // ValidateDomain validates that a domain is safe to use for secret storage.
169268 // This prevents path traversal attacks and ensures the domain is valid.
170269 func ValidateDomain(domain string) error {
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
10 10
11 "github.com/bnema/gordon/internal/boundaries/out"11 "github.com/bnema/gordon/internal/boundaries/out"
12+ "github.com/bnema/gordon/internal/domain"
12 )13 )
13 14
14 // Domain validation errors.15 // Domain validation errors.
@@ -17,6 +18,8 @@ var (
17 ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")18 ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters")
18 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")19 ErrDomainPathTraversal = errors.New("domain contains path traversal sequence")
19 ErrDomainInvalidChars = errors.New("domain contains invalid characters")20 ErrDomainInvalidChars = errors.New("domain contains invalid characters")
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")
20 )23 )
21 24
22 // Service implements the SecretService interface.25 // Service implements the SecretService interface.
@@ -165,6 +168,102 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
165 return nil168 return nil
166 }169 }
167 170
171+// SetAttachment sets or updates multiple secrets for an attachment container.
172+func (s *Service) SetAttachment(ctx context.Context, domainName, service string, secrets map[string]string) error {
173+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
174+ zerowrap.FieldLayer: "usecase",
175+ zerowrap.FieldUseCase: "SetAttachment",
176+ "domain": domainName,
177+ "service": service,
178+ })
179+ log := zerowrap.FromCtx(ctx)
180+
181+ if err := ValidateDomain(domainName); err != nil {
182+ log.Warn().Err(err).Msg("domain validation failed")
183+ return err
184+ }
185+
186+ if err := validateServiceName(service); err != nil {
187+ log.Warn().Err(err).Msg("service name validation failed")
188+ return err
189+ }
190+
191+ containerName := resolveContainerName(domainName, service)
192+
193+ if err := s.store.SetAttachment(containerName, secrets); err != nil {
194+ log.Error().Err(err).Msg("failed to set attachment secrets")
195+ return err
196+ }
197+
198+ log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199+ return nil
200+}
201+
202+// DeleteAttachment removes a specific secret key from an attachment container.
203+func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key string) error {
204+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
205+ zerowrap.FieldLayer: "usecase",
206+ zerowrap.FieldUseCase: "DeleteAttachment",
207+ "domain": domainName,
208+ "service": service,
209+ "key": key,
210+ })
211+ log := zerowrap.FromCtx(ctx)
212+
213+ if err := ValidateDomain(domainName); err != nil {
214+ log.Warn().Err(err).Msg("domain validation failed")
215+ return err
216+ }
217+
218+ if err := validateServiceName(service); err != nil {
219+ log.Warn().Err(err).Msg("service name validation failed")
220+ return err
221+ }
222+
223+ containerName := resolveContainerName(domainName, service)
224+
225+ if err := s.store.DeleteAttachment(containerName, key); err != nil {
226+ log.Error().Err(err).Msg("failed to delete attachment secret")
227+ return err
228+ }
229+
230+ log.Info().Str("container", containerName).Msg("attachment secret deleted")
231+ return nil
232+}
233+
234+// validateServiceName checks that a service name is safe and follows Docker-style conventions.
235+func validateServiceName(service string) error {
236+ if service == "" {
237+ return ErrServiceEmpty
238+ }
239+ // DNS labels are limited to 63 characters (RFC 1035)
240+ if len(service) > 63 {
241+ return ErrInvalidServiceName
242+ }
243+ // Docker-style: lowercase letter start, then lowercase alphanumeric + hyphens
244+ for i, c := range service {
245+ if i == 0 {
246+ if c < 'a' || c > 'z' {
247+ return ErrInvalidServiceName
248+ }
249+ continue
250+ }
251+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
252+ return ErrInvalidServiceName
253+ }
254+ }
255+ // Trailing hyphen is not allowed in DNS labels
256+ if service[len(service)-1] == '-' {
257+ return ErrInvalidServiceName
258+ }
259+ return nil
260+}
261+
262+// resolveContainerName builds the container name from a domain and service.
263+func resolveContainerName(domainName, service string) string {
264+ return domain.SanitizeDomainForContainer(domainName) + "-" + service
265+}
266+
168 // ValidateDomain validates that a domain is safe to use for secret storage.267 // ValidateDomain validates that a domain is safe to use for secret storage.
169 // This prevents path traversal attacks and ensures the domain is valid.268 // This prevents path traversal attacks and ensures the domain is valid.
170 func ValidateDomain(domain string) error {269 func ValidateDomain(domain string) error {
M internal/usecase/secrets/service_test.go +84 -0
@@ -459,3 +459,87 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) {
459459 // Verify no store methods were called (mockery would fail if unexpected calls happened)
460460 mock.AssertExpectationsForObjects(t, store)
461461 }
462+
463+func TestService_SetAttachment(t *testing.T) {
464+ store := outmocks.NewMockDomainSecretStore(t)
465+ svc := NewService(store, testLogger())
466+
467+ secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
468+ store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)
469+
470+ err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
471+ assert.NoError(t, err)
472+}
473+
474+func TestService_DeleteAttachment(t *testing.T) {
475+ store := outmocks.NewMockDomainSecretStore(t)
476+ svc := NewService(store, testLogger())
477+
478+ store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)
479+
480+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
481+ assert.NoError(t, err)
482+}
483+
484+func TestService_SetAttachment_InvalidDomain(t *testing.T) {
485+ store := outmocks.NewMockDomainSecretStore(t)
486+ svc := NewService(store, testLogger())
487+
488+ err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"})
489+ assert.ErrorIs(t, err, ErrDomainEmpty)
490+}
491+
492+func TestService_SetAttachment_EmptyService(t *testing.T) {
493+ store := outmocks.NewMockDomainSecretStore(t)
494+ svc := NewService(store, testLogger())
495+
496+ err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"})
497+ assert.ErrorIs(t, err, ErrServiceEmpty)
498+}
499+
500+func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
501+ store := outmocks.NewMockDomainSecretStore(t)
502+ svc := NewService(store, testLogger())
503+
504+ err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY")
505+ assert.ErrorIs(t, err, ErrDomainEmpty)
506+}
507+
508+func TestService_DeleteAttachment_EmptyService(t *testing.T) {
509+ store := outmocks.NewMockDomainSecretStore(t)
510+ svc := NewService(store, testLogger())
511+
512+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY")
513+ assert.ErrorIs(t, err, ErrServiceEmpty)
514+}
515+
516+func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
517+ store := outmocks.NewMockDomainSecretStore(t)
518+ svc := NewService(store, testLogger())
519+
520+ err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"})
521+ assert.ErrorIs(t, err, ErrInvalidServiceName)
522+}
523+
524+func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
525+ store := outmocks.NewMockDomainSecretStore(t)
526+ svc := NewService(store, testLogger())
527+
528+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY")
529+ assert.ErrorIs(t, err, ErrInvalidServiceName)
530+}
531+
532+func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) {
533+ store := outmocks.NewMockDomainSecretStore(t)
534+ svc := NewService(store, testLogger())
535+
536+ // With invalid service name, store methods should never be called
537+ err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"})
538+ assert.ErrorIs(t, err, ErrInvalidServiceName)
539+
540+ err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key")
541+ assert.ErrorIs(t, err, ErrInvalidServiceName)
542+
543+ // Verify no store methods were called
544+ mock.AssertExpectationsForObjects(t, store)
545+}
@@ -459,3 +459,87 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) {
459 // Verify no store methods were called (mockery would fail if unexpected calls happened)459 // Verify no store methods were called (mockery would fail if unexpected calls happened)
460 mock.AssertExpectationsForObjects(t, store)460 mock.AssertExpectationsForObjects(t, store)
461 }461 }
462+
463+func TestService_SetAttachment(t *testing.T) {
464+ store := outmocks.NewMockDomainSecretStore(t)
465+ svc := NewService(store, testLogger())
466+
467+ secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
468+ store.EXPECT().SetAttachment("app__example__com-postgres", secrets).Return(nil)
469+
470+ err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
471+ assert.NoError(t, err)
472+}
473+
474+func TestService_DeleteAttachment(t *testing.T) {
475+ store := outmocks.NewMockDomainSecretStore(t)
476+ svc := NewService(store, testLogger())
477+
478+ store.EXPECT().DeleteAttachment("app__example__com-postgres", "OLD_KEY").Return(nil)
479+
480+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
481+ assert.NoError(t, err)
482+}
483+
484+func TestService_SetAttachment_InvalidDomain(t *testing.T) {
485+ store := outmocks.NewMockDomainSecretStore(t)
486+ svc := NewService(store, testLogger())
487+
488+ err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"})
489+ assert.ErrorIs(t, err, ErrDomainEmpty)
490+}
491+
492+func TestService_SetAttachment_EmptyService(t *testing.T) {
493+ store := outmocks.NewMockDomainSecretStore(t)
494+ svc := NewService(store, testLogger())
495+
496+ err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"})
497+ assert.ErrorIs(t, err, ErrServiceEmpty)
498+}
499+
500+func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
501+ store := outmocks.NewMockDomainSecretStore(t)
502+ svc := NewService(store, testLogger())
503+
504+ err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY")
505+ assert.ErrorIs(t, err, ErrDomainEmpty)
506+}
507+
508+func TestService_DeleteAttachment_EmptyService(t *testing.T) {
509+ store := outmocks.NewMockDomainSecretStore(t)
510+ svc := NewService(store, testLogger())
511+
512+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY")
513+ assert.ErrorIs(t, err, ErrServiceEmpty)
514+}
515+
516+func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
517+ store := outmocks.NewMockDomainSecretStore(t)
518+ svc := NewService(store, testLogger())
519+
520+ err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"})
521+ assert.ErrorIs(t, err, ErrInvalidServiceName)
522+}
523+
524+func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
525+ store := outmocks.NewMockDomainSecretStore(t)
526+ svc := NewService(store, testLogger())
527+
528+ err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY")
529+ assert.ErrorIs(t, err, ErrInvalidServiceName)
530+}
531+
532+func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) {
533+ store := outmocks.NewMockDomainSecretStore(t)
534+ svc := NewService(store, testLogger())
535+
536+ // With invalid service name, store methods should never be called
537+ err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"})
538+ assert.ErrorIs(t, err, ErrInvalidServiceName)
539+
540+ err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key")
541+ assert.ErrorIs(t, err, ErrInvalidServiceName)
542+
543+ // Verify no store methods were called
544+ mock.AssertExpectationsForObjects(t, store)
545+}

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