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