From adfb27386f9231cf77f9a979cc3acd333a2f19a6 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 23:24:22 -0400 Subject: [PATCH] fix: acquire state before repository resolution --- internal/application/initialize/service.go | 3 ++ .../application/initialize/service_test.go | 10 +++---- internal/bootstrap/adapters.go | 30 +++++++++++++++++++ internal/bootstrap/applications.go | 4 +-- internal/state/store.go | 10 +++++++ 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/internal/application/initialize/service.go b/internal/application/initialize/service.go index 6148e05..d85f29d 100644 --- a/internal/application/initialize/service.go +++ b/internal/application/initialize/service.go @@ -40,6 +40,9 @@ func (service *Service) Initialize(ctx context.Context, request Request) (Result if err := ctx.Err(); err != nil { return Result{}, err } + if err := service.store.EnsureAcquired(); err != nil { + return Result{}, failure.New(failure.Operational, "initialize: acquire state store", err) + } environment, err := service.prepare(request) if err != nil { return Result{}, err diff --git a/internal/application/initialize/service_test.go b/internal/application/initialize/service_test.go index 6ca61d5..064a808 100644 --- a/internal/application/initialize/service_test.go +++ b/internal/application/initialize/service_test.go @@ -26,7 +26,7 @@ func TestInitializeService(t *testing.T) { {"rejects home overlap", testServiceRejectsHomeOverlap}, {"rejects state overlap", testServiceRejectsStateOverlap}, {"rejects portable case-equivalent overlap", testServiceRejectsPortableOverlap}, - {"requires an acquired store", testServiceRequiresAcquiredStore}, + {"acquires the store lazily", testServiceAcquiresStoreLazily}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) @@ -186,12 +186,12 @@ func testServiceRejectsPortableOverlap(t *testing.T) { } } -func testServiceRequiresAcquiredStore(t *testing.T) { +func testServiceAcquiresStoreLazily(t *testing.T) { store := state.NewStore(state.Dependencies{StateHome: t.TempDir()}) + defer store.Close() service := NewService(Dependencies{Home: t.TempDir(), Store: store}) - _, err := service.Initialize(context.Background(), Request{Path: t.TempDir()}) - if kind, matched := failure.HasKind(err); !matched || kind != failure.Operational { - t.Fatalf("error = %v, want Operational", err) + if _, err := service.Initialize(context.Background(), Request{Path: t.TempDir()}); err != nil { + t.Fatalf("Initialize: %v", err) } } diff --git a/internal/bootstrap/adapters.go b/internal/bootstrap/adapters.go index bcdde87..7465b12 100644 --- a/internal/bootstrap/adapters.go +++ b/internal/bootstrap/adapters.go @@ -59,21 +59,33 @@ type stateReaderAdapter struct { // FileBaselines returns the persisted file rows of one repository pair. func (adapter stateReaderAdapter) FileBaselines(root, home string) ([]state.FileBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return nil, err + } return adapter.store.FileBaselines(root, home) } // AliasBaselines returns the persisted alias rows of one repository pair. func (adapter stateReaderAdapter) AliasBaselines(root, home string) ([]state.AliasBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return nil, err + } return adapter.store.AliasBaselines(root, home) } // RecoverHashKey loads the per-installation secret hash key. func (adapter stateReaderAdapter) RecoverHashKey() ([32]byte, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return [32]byte{}, err + } return adapter.store.RecoverHashKey() } // DefaultRepository returns the default repository of one home. func (adapter stateReaderAdapter) DefaultRepository(home string) (state.Repository, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.Repository{}, err + } return adapter.store.DefaultRepository(home) } @@ -84,11 +96,17 @@ type baselineAdapter struct { // UpsertFileBaseline establishes or replaces one file row. func (adapter baselineAdapter) UpsertFileBaseline(root, home string, baseline state.FileBaseline) (state.FileBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.FileBaseline{}, err + } return adapter.store.UpsertFileBaseline(root, home, baseline) } // UpsertAliasBaseline establishes or replaces one alias row. func (adapter baselineAdapter) UpsertAliasBaseline(root, home string, baseline state.AliasBaseline) (state.AliasBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.AliasBaseline{}, err + } return adapter.store.UpsertAliasBaseline(root, home, baseline) } @@ -99,11 +117,17 @@ type transitionAdapter struct { // TransitionToAlias switches one file row to an alias row. func (adapter transitionAdapter) TransitionToAlias(root, home string, baseline state.AliasBaseline) (state.AliasBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.AliasBaseline{}, err + } return adapter.store.TransitionToAlias(root, home, baseline) } // TransitionToFile switches one alias row to a file row. func (adapter transitionAdapter) TransitionToFile(root, home string, baseline state.FileBaseline) (state.FileBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.FileBaseline{}, err + } return adapter.store.TransitionToFile(root, home, baseline) } @@ -114,11 +138,17 @@ type retirementAdapter struct { // RetireFileBaseline retires one file row. func (adapter retirementAdapter) RetireFileBaseline(root, home, target string) (state.FileBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.FileBaseline{}, err + } return adapter.store.RetireFileBaseline(root, home, target) } // RetireAliasBaseline retires one alias row. func (adapter retirementAdapter) RetireAliasBaseline(root, home, aliasPath string) (state.AliasBaseline, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return state.AliasBaseline{}, err + } return adapter.store.RetireAliasBaseline(root, home, aliasPath) } diff --git a/internal/bootstrap/applications.go b/internal/bootstrap/applications.go index a2912a2..bb3070b 100644 --- a/internal/bootstrap/applications.go +++ b/internal/bootstrap/applications.go @@ -137,12 +137,12 @@ func buildApply(input ApplicationsInput, shared shared) *apply.Service { // buildAdd wires the add service. func buildAdd(input ApplicationsInput, shared shared) *add.Service { - return add.NewService(add.Dependencies{ + return add.NewServiceWithWrites(add.Dependencies{ RepositorySource: repositorySourceOf(shared.resolver, addIdentity), Compiler: shared.compiler, Writer: input.Adapters.Replacer, Baselines: shared.baselines, - }) + }, add.WriteDependencies{Secrets: input.Adapters.SOPS, HashKey: shared.state}) } // applyIdentity projects one state repository into the apply identity. diff --git a/internal/state/store.go b/internal/state/store.go index d9d0fd6..ac9a7da 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -42,6 +42,16 @@ func (store *Store) Database() *Database { return store.database } +// EnsureAcquired lazily opens the state store for application adapters that do +// not receive a context. Repeated calls after a successful acquisition are +// no-ops; construction and version/help paths remain side-effect-free. +func (store *Store) EnsureAcquired() error { + if store.database != nil { + return nil + } + return store.Acquire(context.Background()) +} + // Acquire resolves the canonical state directory, creates it, acquires the // advisory lock, opens the database, and applies any required migration. On any // failure it releases what was acquired so the Store is safe to drop. -- 2.51.2