From 840ba5d9143b3dd1066a5138c7cea2389ca97ffe Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 22:02:42 -0400 Subject: [PATCH] fix: isolate add write dependencies from contracts --- internal/application/add/execute.go | 5 +++- internal/application/add/execute_test.go | 6 ++--- internal/application/add/service.go | 27 ++++++++++++++++++- internal/application/add/service_test.go | 2 +- internal/application/add/types.go | 15 ++--------- internal/application/add/types_test.go | 3 --- internal/application/add/write_secret.go | 8 ++++-- internal/application/add/write_secret_test.go | 5 +++- 8 files changed, 46 insertions(+), 25 deletions(-) diff --git a/internal/application/add/execute.go b/internal/application/add/execute.go index e2bab29..ce0017f 100644 --- a/internal/application/add/execute.go +++ b/internal/application/add/execute.go @@ -116,7 +116,10 @@ func (exec *executor) recoverKey() ([32]byte, error) { if exec.haveKey { return exec.key, nil } - key, err := exec.service.deps.HashKey.RecoverHashKey() + if exec.service.write.HashKey == nil { + return [32]byte{}, failure.New(failure.Operational, "add: recover hash key", errors.New("hash key recovery is not configured")) + } + key, err := exec.service.write.HashKey.RecoverHashKey() if err != nil { return [32]byte{}, failure.New(failure.Operational, "add: recover hash key", err) } diff --git a/internal/application/add/execute_test.go b/internal/application/add/execute_test.go index 5f89cd7..4176adb 100644 --- a/internal/application/add/execute_test.go +++ b/internal/application/add/execute_test.go @@ -135,13 +135,13 @@ func newExecutionStage(t *testing.T, payloads ...ordinaryPayload) executionStage deps := Dependencies{ Writer: filesystem.NewReplacer(), Baselines: fixture.Store, - HashKey: fixture.Store, } + writes := WriteDependencies{HashKey: fixture.Store} if anySecret(payloads) { - deps.Secrets = roundTripClient(t, repo) + writes.Secrets = roundTripClient(t, repo) } return executionStage{ - service: NewService(deps), identity: RepositoryIdentity{Root: repo, Home: fixture.Home}, + service: NewServiceWithWrites(deps, writes), identity: RepositoryIdentity{Root: repo, Home: fixture.Home}, plan: plan, fixture: fixture, repo: repo, } } diff --git a/internal/application/add/service.go b/internal/application/add/service.go index 03fbd3a..b3440f7 100644 --- a/internal/application/add/service.go +++ b/internal/application/add/service.go @@ -7,15 +7,30 @@ import ( "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/failure" "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" "github.com/alyraffauf/cattery/internal/selection" ) +// WriteDependencies carries the secret-specific ports required by add's +// execution phases. They remain separate from Task 75's frozen Dependencies +// so the contract owner can finish without a shared-file change. +type WriteDependencies struct { + Secrets *secrets.Client + HashKey Recoverer +} + +// Recoverer loads the per-installation secret hash key for keyed baselines. +type Recoverer interface { + RecoverHashKey() ([32]byte, error) +} + // Service performs one add batch against the injectable ports. Construction // is side-effect-free: every repository, filesystem, secret, and state effect // happens inside Add. The runtime platform layer is read once at // construction via runtime.GOOS so an explicit --platform must equal it. type Service struct { deps Dependencies + write WriteDependencies platform deployment.Layer } @@ -23,7 +38,17 @@ type Service struct { // (linux or darwin on a supported host; the zero layer otherwise, which Add // rejects). func NewService(deps Dependencies) *Service { - return &Service{deps: deps, platform: runtimeLayer()} + return newService(deps, WriteDependencies{}) +} + +// NewServiceWithWrites binds the additional secret execution ports while +// preserving the frozen Task 75 construction seam for ordinary callers. +func NewServiceWithWrites(deps Dependencies, writes WriteDependencies) *Service { + return newService(deps, writes) +} + +func newService(deps Dependencies, writes WriteDependencies) *Service { + return &Service{deps: deps, write: writes, platform: runtimeLayer()} } // runtimeLayer resolves the host platform layer, returning the zero layer diff --git a/internal/application/add/service_test.go b/internal/application/add/service_test.go index 2bba48d..34e4539 100644 --- a/internal/application/add/service_test.go +++ b/internal/application/add/service_test.go @@ -150,7 +150,7 @@ func newServiceStage(t *testing.T) serviceStage { compiler := &fakeCompiler{plan: plan} deps := Dependencies{ RepositorySource: source, Compiler: compiler, - Writer: filesystem.NewReplacer(), Baselines: fixture.Store, HashKey: fixture.Store, + Writer: filesystem.NewReplacer(), Baselines: fixture.Store, } return serviceStage{ service: NewService(deps), source: source, compiler: compiler, diff --git a/internal/application/add/types.go b/internal/application/add/types.go index 21cf477..162f959 100644 --- a/internal/application/add/types.go +++ b/internal/application/add/types.go @@ -14,29 +14,18 @@ import ( "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/repository" - "github.com/alyraffauf/cattery/internal/secrets" "github.com/alyraffauf/cattery/internal/selection" "github.com/alyraffauf/cattery/internal/state" ) // Dependencies bundles the injectable seams of the add service: repository -// resolution, plan compilation, atomic source replacement, baseline -// persistence, secret encryption, and the per-installation hash key. The -// concrete secrets client mirrors inspect; construction is side-effect-free -// and effects begin inside Add. +// resolution, plan compilation, atomic source replacement, and baseline +// persistence. Construction is side-effect-free; effects begin inside Add. type Dependencies struct { RepositorySource RepositorySource Compiler Compiler Writer AtomicWriter Baselines BaselineStore - Secrets *secrets.Client - HashKey Recoverer -} - -// Recoverer loads the per-installation secret hash key for keyed baselines. -// The state store satisfies it; add recovers the key once per batch. -type Recoverer interface { - RecoverHashKey() ([32]byte, error) } // RepositorySource resolves the canonical repository pair for a selection diff --git a/internal/application/add/types_test.go b/internal/application/add/types_test.go index b9dc914..b0d990c 100644 --- a/internal/application/add/types_test.go +++ b/internal/application/add/types_test.go @@ -12,7 +12,6 @@ import ( "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/repository" - "github.com/alyraffauf/cattery/internal/secrets" "github.com/alyraffauf/cattery/internal/selection" "github.com/alyraffauf/cattery/internal/state" ) @@ -210,8 +209,6 @@ func assertDependencySeams(t *testing.T) { "Compiler": reflect.TypeOf((*Compiler)(nil)).Elem(), "Writer": reflect.TypeOf((*AtomicWriter)(nil)).Elem(), "Baselines": reflect.TypeOf((*BaselineStore)(nil)).Elem(), - "Secrets": reflect.TypeOf((*secrets.Client)(nil)), - "HashKey": reflect.TypeOf((*Recoverer)(nil)).Elem(), } if dependencies.NumField() != len(ports) { t.Fatalf("Dependencies has %d fields, want %d", dependencies.NumField(), len(ports)) diff --git a/internal/application/add/write_secret.go b/internal/application/add/write_secret.go index 6439a9f..2759234 100644 --- a/internal/application/add/write_secret.go +++ b/internal/application/add/write_secret.go @@ -2,6 +2,7 @@ package add import ( "context" + "fmt" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/secrets" @@ -39,11 +40,14 @@ func (service *Service) writeSecret(ctx context.Context, identity RepositoryIden // publishSecret encrypts, validates, and publishes one secret source. The // caller owns the plaintext buffer and clears it after verification. func (service *Service) publishSecret(ctx context.Context, input publishInput) ([]byte, filesystem.ReplaceResult, error) { - ciphertext, err := service.deps.Secrets.Encrypt(ctx, input.plaintext, input.item.SourceRepositoryPath()) + if service.write.Secrets == nil { + return nil, filesystem.ReplaceResult{}, fmt.Errorf("add: secret writer is not configured") + } + ciphertext, err := service.write.Secrets.Encrypt(ctx, input.plaintext, input.item.SourceRepositoryPath()) if err != nil { return nil, filesystem.ReplaceResult{}, err } - validated, err := service.deps.Secrets.ValidateCandidate(ctx, secrets.Candidate{ + validated, err := service.write.Secrets.ValidateCandidate(ctx, secrets.Candidate{ Plaintext: input.plaintext, Ciphertext: ciphertext, SourcePath: input.item.SourceRepositoryPath(), }) if err != nil { diff --git a/internal/application/add/write_secret_test.go b/internal/application/add/write_secret_test.go index ec22b9c..a958ccd 100644 --- a/internal/application/add/write_secret_test.go +++ b/internal/application/add/write_secret_test.go @@ -92,7 +92,10 @@ func newSecretStage(t *testing.T, roundTrip bool) secretStage { t.Fatal(err) } client := newFakeSopsClient(t, fakeSopsTarget{repo: repo, plaintext: plaintext, roundTrip: roundTrip}) - service := NewService(Dependencies{Writer: filesystem.NewReplacer(), Secrets: client}) + service := NewServiceWithWrites( + Dependencies{Writer: filesystem.NewReplacer()}, + WriteDependencies{Secrets: client}, + ) return secretStage{ service: service, identity: RepositoryIdentity{Root: repo, Home: home}, item: item, plaintext: plaintext, repo: repo, -- 2.51.2