From b38602755a0b8f8689ad62b04af106ae69e6d4f2 Mon Sep 17 00:00:00 2001 From: Wolfy-J Date: Sun, 30 Aug 2026 15:13:46 -0400 Subject: [PATCH] fix(registry): migrate persisted ownership state Move owner/root into registry-owned entry metadata, migrate persisted operations before replay, and make established deployments restorable from registry history without a lock file. --- api/event/event.go | 4 - api/modules/sources.go | 32 +- api/modules/sources_test.go | 39 +- api/registry/context_test.go | 3 +- api/registry/expansion.go | 18 +- api/registry/provenance.go | 173 ----- api/registry/provenance_test.go | 60 -- api/registry/registry.go | 47 +- api/registry/resolution.go | 67 +- api/registry/resolution_test.go | 44 ++ api/registry/rollback.go | 61 -- api/service/fs/directory/resolve.go | 11 +- api/service/fs/directory/resolve_test.go | 29 +- api/service/fs/embed/registry.go | 11 - boot/build/stages/embed_fs.go | 43 +- boot/build/stages/embed_fs_test.go | 172 +---- boot/build/stages/link_fixture_test.go | 100 --- boot/build/stages/linking.go | 68 +- .../stages/linking_declared_namespace_test.go | 30 +- boot/build/stages/linking_test.go | 412 +++++------ boot/components/core/registry.go | 3 + boot/deps/artifact/directory.go | 13 +- boot/deps/artifact/directory_test.go | 101 ++- boot/deps/hub/artifact_effect.go | 4 +- boot/deps/hub/artifact_effect_test.go | 16 +- boot/deps/hub/dependency_boot_test.go | 260 +++++-- boot/deps/hub/dependency_control.go | 33 +- boot/deps/hub/dependency_handler.go | 539 +++++++------- boot/deps/hub/dependency_handler_edge_test.go | 342 +++------ boot/deps/hub/dependency_handler_test.go | 602 +++++---------- ...dependency_handler_uninstall_repro_test.go | 91 +-- boot/deps/hub/deployment_baseline.go | 63 +- boot/deps/hub/deployment_baseline_test.go | 78 +- boot/deps/hub/embed_effect.go | 78 +- boot/deps/hub/embed_effect_test.go | 235 ++---- boot/deps/hub/entry_metadata.go | 94 +++ boot/deps/hub/entry_provenance.go | 226 ------ boot/deps/hub/legacy_root_onboarding_test.go | 108 --- boot/deps/hub/locked_manifest_provider.go | 53 +- boot/deps/hub/locked_resolution_test.go | 3 + boot/deps/hub/operation_planner.go | 104 +-- boot/deps/hub/operation_planner_test.go | 150 ++-- boot/deps/hub/provenance_fixture_test.go | 97 --- boot/deps/hub/reference_boot_cycle_test.go | 15 +- .../deps/hub/reference_fold_hardening_test.go | 72 +- boot/deps/hub/reference_param_drift_test.go | 5 +- .../hub/replacement_offline_startup_test.go | 109 ++- boot/deps/hub/replacement_restart_e2e_test.go | 26 +- boot/deps/hub/resolution_hardening_test.go | 141 ++-- boot/deps/hub/resolve.go | 4 +- boot/deps/hub/resolve_test.go | 17 + boot/deps/hub/restore_sources.go | 56 ++ boot/deps/hub/root_application_update_test.go | 80 +- boot/deps/hub/source_effect.go | 2 +- boot/handler.go | 4 - cmd/internal/entries/loader.go | 286 ++++---- cmd/internal/entries/loader_test.go | 155 +--- cmd/wippy/cmd/install.go | 5 +- cmd/wippy/cmd/load_entries.go | 4 +- cmd/wippy/cmd/load_entries_test.go | 17 +- cmd/wippy/cmd/lock_helpers.go | 2 +- cmd/wippy/cmd/pack.go | 16 +- cmd/wippy/cmd/pack_provenance.go | 144 ---- cmd/wippy/cmd/pack_provenance_test.go | 32 - cmd/wippy/cmd/publish.go | 4 +- cmd/wippy/cmd/registry.go | 52 +- cmd/wippy/cmd/registry_metadata_test.go | 29 + cmd/wippy/cmd/run.go | 4 + cmd/wippy/cmd/run_dependencies.go | 196 +++++ cmd/wippy/cmd/run_dependencies_test.go | 127 ++++ cmd/wippy/cmd/run_pack.go | 250 ++++--- cmd/wippy/cmd/run_pack_shutdown_test.go | 2 +- cmd/wippy/cmd/run_pack_test.go | 206 ++++-- runtime/lua/modules/registry/changes.go | 28 - runtime/lua/modules/registry/changes_test.go | 79 -- runtime/lua/modules/registry/history.go | 18 +- runtime/lua/modules/registry/module.go | 58 +- runtime/lua/modules/registry/module_test.go | 108 +-- runtime/lua/modules/registry/provenance.go | 134 ---- runtime/lua/modules/registry/snapshot.go | 116 ++- runtime/lua/modules/registry/snapshot_test.go | 106 +-- runtime/lua/modules/registry/types.go | 57 +- runtime/lua/modules/registry/utils.go | 79 +- runtime/lua/modules/registry/utils_test.go | 65 +- runtime/lua/modules/system/module_test.go | 8 +- service/fs/directory/manager.go | 18 +- service/fs/directory/manager_test.go | 115 ++- service/fs/embed/integration_test.go | 152 ++-- service/fs/embed/manager.go | 37 +- service/fs/embed/manager_test.go | 153 +--- service/fs/embed/registry.go | 686 +++++++----------- service/fs/embed/registry_test.go | 530 +++----------- system/eventbus/dispatcher.go | 7 +- system/fs/errors.go | 16 +- system/registry/bench_test.go | 4 +- system/registry/errors.go | 17 +- system/registry/events/handler.go | 6 - .../events/provenance_delivery_test.go | 103 --- system/registry/expansion/apply_test.go | 42 +- .../expansion/dependency_directive.go | 16 +- system/registry/expansion/directive_test.go | 24 +- system/registry/expansion/expansion.go | 26 +- system/registry/expansion/expansion_test.go | 68 +- system/registry/history/memory/memory.go | 37 +- system/registry/history/postgres/postgres.go | 112 ++- .../history/postgres/postgres_test.go | 114 +++ .../history/postgres/wire_provenance_test.go | 76 -- .../history/sqlite/metadata_migration_test.go | 220 ++++++ system/registry/history/sqlite/sqlite.go | 84 ++- system/registry/history/sqlite/sqlite_test.go | 25 +- .../history/sqlite/wire_provenance_test.go | 129 ---- .../registry/legacy_provenance_replay_test.go | 183 ----- system/registry/load_state_duplicate_test.go | 6 +- system/registry/load_state_test.go | 34 +- system/registry/migration/metadata.go | 68 ++ system/registry/migration/metadata_test.go | 57 ++ system/registry/migration/storage/metadata.go | 338 +++++++++ system/registry/options_test.go | 2 +- system/registry/overlay.go | 34 +- system/registry/overlay_bench_test.go | 2 +- system/registry/overlay_test.go | 94 +-- system/registry/provenance.go | 289 -------- system/registry/provenance_test.go | 403 ---------- system/registry/registry.go | 384 ++++------ system/registry/registry_hardening_test.go | 54 +- system/registry/registry_test.go | 159 ++-- system/registry/resolution_version_test.go | 252 ------- system/registry/rollback_v0_test.go | 2 +- system/registry/runner/bus_runner.go | 131 +--- system/registry/runner/errors.go | 8 - system/registry/topology/state_builder.go | 15 +- system/registry/version_transition_test.go | 39 +- tests/app/src/_index.yaml | 5 - tests/app/src/runner.lua | 4 +- tests/app/src/test/registry/_index.yaml | 19 +- .../src/test/registry/dependency_install.lua | 36 +- .../test/registry/dependency_requirements.lua | 39 +- .../test/registry/dependency_root_field.lua | 78 -- .../src/test/registry/dependency_update.lua | 44 +- .../test/registry/dependency_version_jump.lua | 52 +- tests/app/src/test/registry/overlay_state.lua | 26 - .../test/registry/registry_metadata_state.lua | 93 +++ tests/app/src/test/registry/snapshot.lua | 25 - tests/app/src/test/registry/snapshot_at.lua | 9 - tests/app/src/test/security/can.lua | 5 +- tests/app/src/test/security/module.lua | 12 +- tests/app/test | 2 +- tests/app/test.sh | 5 +- 148 files changed, 5256 insertions(+), 8204 deletions(-) delete mode 100644 api/registry/provenance.go delete mode 100644 api/registry/provenance_test.go delete mode 100644 api/registry/rollback.go delete mode 100644 boot/build/stages/link_fixture_test.go create mode 100644 boot/deps/hub/entry_metadata.go delete mode 100644 boot/deps/hub/entry_provenance.go delete mode 100644 boot/deps/hub/legacy_root_onboarding_test.go delete mode 100644 boot/deps/hub/provenance_fixture_test.go create mode 100644 boot/deps/hub/restore_sources.go delete mode 100644 cmd/wippy/cmd/pack_provenance.go delete mode 100644 cmd/wippy/cmd/pack_provenance_test.go create mode 100644 cmd/wippy/cmd/registry_metadata_test.go create mode 100644 cmd/wippy/cmd/run_dependencies.go create mode 100644 cmd/wippy/cmd/run_dependencies_test.go delete mode 100644 runtime/lua/modules/registry/provenance.go delete mode 100644 system/registry/events/provenance_delivery_test.go delete mode 100644 system/registry/history/postgres/wire_provenance_test.go create mode 100644 system/registry/history/sqlite/metadata_migration_test.go delete mode 100644 system/registry/history/sqlite/wire_provenance_test.go delete mode 100644 system/registry/legacy_provenance_replay_test.go create mode 100644 system/registry/migration/metadata.go create mode 100644 system/registry/migration/metadata_test.go create mode 100644 system/registry/migration/storage/metadata.go delete mode 100644 system/registry/provenance.go delete mode 100644 system/registry/provenance_test.go delete mode 100644 system/registry/resolution_version_test.go delete mode 100644 tests/app/src/test/registry/dependency_root_field.lua delete mode 100644 tests/app/src/test/registry/overlay_state.lua create mode 100644 tests/app/src/test/registry/registry_metadata_state.lua diff --git a/api/event/event.go b/api/event/event.go index c8f94ba9c..06ce9f8e4 100644 --- a/api/event/event.go +++ b/api/event/event.go @@ -21,12 +21,8 @@ type ( Path = string // Event is the fundamental structure representing an event. - // Aux carries in-process auxiliary context alongside Data — dispatcher - // facts about the event that are not the event's payload. It never - // crosses a process boundary and consumers that do not know it ignore it. Event struct { Data any - Aux any System System Kind Kind Path Path diff --git a/api/modules/sources.go b/api/modules/sources.go index a16bc810b..fad239c68 100644 --- a/api/modules/sources.go +++ b/api/modules/sources.go @@ -38,17 +38,16 @@ type Source struct { // Sources maps stable source identifiers to their current load identities. type Sources map[string]Source -// LoadedSources is one atomic view of the normalized deployment baseline, its -// provenance, and the sources authoritative over its entries. +// LoadedSources is one atomic view of the normalized deployment baseline and +// the sources authoritative over its entries. type LoadedSources struct { - Provenance regapi.ProvenanceMap - Owners []string - Entries []regapi.Entry + Owners []string + Entries []regapi.Entry } -// SourceLoader rebuilds the normalized deployment baseline and its provenance -// from an atomic snapshot of its current sources. -type SourceLoader func(context.Context, Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) +// SourceLoader rebuilds the normalized deployment baseline from an atomic +// snapshot of its current sources. +type SourceLoader func(context.Context, Sources) ([]regapi.Entry, error) // SourceRegistry owns deployment source identities and coordinates source // reloads with backing-store transitions. @@ -234,6 +233,16 @@ func (r *SourceRegistry) SetLoader(loader SourceLoader) { r.mu.Unlock() } +// Snapshot returns a detached view of the current deployment sources. +func (r *SourceRegistry) Snapshot() Sources { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + return cloneSources(r.sources) +} + // Load rebuilds the normalized deployment baseline from one stable source // generation. func (r *SourceRegistry) Load(ctx context.Context) (LoadedSources, error) { @@ -249,14 +258,13 @@ func (r *SourceRegistry) Load(ctx context.Context) (LoadedSources, error) { if loader == nil { return LoadedSources{}, ErrSourceLoaderUnavailable } - entries, prov, err := loader(ctx, sources) + entries, err := loader(ctx, sources) if err != nil { return LoadedSources{}, err } return LoadedSources{ - Owners: authoritativeOwners(sources), - Entries: entries, - Provenance: prov, + Owners: authoritativeOwners(sources), + Entries: entries, }, nil } diff --git a/api/modules/sources_test.go b/api/modules/sources_test.go index 3c3a1ed34..0365e34c1 100644 --- a/api/modules/sources_test.go +++ b/api/modules/sources_test.go @@ -29,7 +29,7 @@ func TestSourceRegistryLoadAndResourceRoots(t *testing.T) { }, }) - registry.SetLoader(func(context.Context, Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { return nil, nil, nil }) + registry.SetLoader(func(context.Context, Sources) ([]regapi.Entry, error) { return nil, nil }) loaded, err := registry.Load(context.Background()) if err != nil { t.Fatal(err) @@ -45,14 +45,27 @@ func TestSourceRegistryLoadAndResourceRoots(t *testing.T) { } } +func TestSourceRegistrySnapshotIsDetached(t *testing.T) { + registry := NewSourceRegistry() + registry.Set(Sources{"acme/app": {LoadPath: "/pack", Version: "1.0.0"}}) + + snapshot := registry.Snapshot() + snapshot["acme/app"] = Source{LoadPath: "/changed"} + delete(snapshot, "acme/app") + + if got := registry.Snapshot()["acme/app"].LoadPath; got != "/pack" { + t.Fatalf("stored source changed through snapshot: %q", got) + } +} + func TestSourceLoaderReceivesIsolatedSnapshot(t *testing.T) { registry := NewSourceRegistry() source := Source{LoadPath: "/repo/ui", Owner: "acme/ui", Version: "1.2.3", Digest: "sha256:ui", Sequence: 1} registry.Set(Sources{"acme/ui": source}) var loaded Sources - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { loaded = sources - return []regapi.Entry{{ID: regapi.NewID("example", "entry"), Kind: "registry.entry"}}, nil, nil + return []regapi.Entry{{ID: regapi.NewID("example", "entry"), Kind: "registry.entry"}}, nil }) result, err := registry.Load(context.Background()) @@ -63,11 +76,11 @@ func TestSourceLoaderReceivesIsolatedSnapshot(t *testing.T) { t.Fatalf("load = %v, %#v", result.Entries, loaded) } loaded["acme/ui"] = Source{LoadPath: "/mutated"} - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { if sources["acme/ui"] != source { t.Fatalf("loader mutated registry: %#v", sources) } - return nil, nil, nil + return nil, nil }) _, err = registry.Load(context.Background()) if err != nil { @@ -84,22 +97,22 @@ func TestSourceRegistrySetReplacesCompleteSnapshot(t *testing.T) { registry.Set(Sources{ ApplicationSourceID: {LoadPath: "/repo/next", Owner: ApplicationSourceID}, }) - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { if len(sources) != 1 || sources[ApplicationSourceID].LoadPath != "/repo/next" { t.Fatalf("sources = %#v", sources) } - return nil, nil, nil + return nil, nil }) if _, err := registry.Load(context.Background()); err != nil { t.Fatal(err) } registry.Set(nil) - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { if len(sources) != 0 { t.Fatalf("sources after empty set = %#v", sources) } - return nil, nil, nil + return nil, nil }) if _, err := registry.Load(context.Background()); err != nil { t.Fatal(err) @@ -142,10 +155,10 @@ func TestSourceTransitionWaitsForActiveReload(t *testing.T) { loadStarted := make(chan Sources, 1) releaseLoad := make(chan struct{}) - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { loadStarted <- sources <-releaseLoad - return nil, nil, nil + return nil, nil }) loadDone := make(chan error, 1) go func() { @@ -194,11 +207,11 @@ func TestSourceTransitionFailureKeepsPreviousIdentity(t *testing.T) { if err == nil || len(previous) != 0 { t.Fatalf("failed transition = %#v, %v", previous, err) } - registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + registry.SetLoader(func(_ context.Context, sources Sources) ([]regapi.Entry, error) { if sources["acme/ui"] != oldSource { t.Fatalf("source changed after failure: %#v", sources) } - return nil, nil, nil + return nil, nil }) _, err = registry.Load(context.Background()) if err != nil { diff --git a/api/registry/context_test.go b/api/registry/context_test.go index b01775358..b5308e340 100644 --- a/api/registry/context_test.go +++ b/api/registry/context_test.go @@ -104,11 +104,12 @@ func (m *mockRegistry) Apply(context.Context, ChangeSet) (Version, error) { return nil, nil //nolint:nilnil // test mock } func (m *mockRegistry) ApplyVersion(context.Context, Version) error { return nil } -func (m *mockRegistry) LoadState(context.Context, ProvenancedState, Version) error { +func (m *mockRegistry) LoadState(context.Context, State, Version) error { return nil } func (m *mockRegistry) Current() (Version, error) { return nil, nil } //nolint:nilnil // test mock func (m *mockRegistry) History() History { return nil } +func (m *mockRegistry) Snapshot() Snapshot { return Snapshot{} } func (m *mockRegistry) RegisterDependencyPattern(_ DependencyPattern) error { return nil } diff --git a/api/registry/expansion.go b/api/registry/expansion.go index ba12c3ec6..41e130e92 100644 --- a/api/registry/expansion.go +++ b/api/registry/expansion.go @@ -24,20 +24,16 @@ type ScopedOperation struct { type DirectiveResult struct { OriginalScope *Scope Resolution *DependencyResolution - // Provenance updates resident records for entries the directive emits no - // operation for: a module version bump whose entries are byte-identical - // advances their resident identity here, with no entry event. - Provenance ProvenanceMap - Additional []ScopedOperation - Effects []Effect - Applied bool + Additional []ScopedOperation + Effects []Effect + Applied bool } // ResolutionDirective restores derived state from the exact dependency graph // stored for a registry version. It is used once after declarative history has // been reconstructed, never once per historical changeset. type ResolutionDirective interface { - ReconcileResolution(context.Context, ProvenancedState, *DependencyResolution) (DirectiveResult, error) + ReconcileResolution(context.Context, State, *DependencyResolution) (DirectiveResult, error) } // ResolutionTransitionDirective is the transition-aware form of @@ -46,13 +42,13 @@ type ResolutionDirective interface { // prefers this interface when implemented and falls back to // ResolutionDirective for compatibility. type ResolutionTransitionDirective interface { - ReconcileResolutionTransition(context.Context, ProvenancedState, ProvenancedState, *DependencyResolution) (DirectiveResult, error) + ReconcileResolutionTransition(context.Context, State, State, *DependencyResolution) (DirectiveResult, error) } // ChangesDirective expands all same-kind operations in one resolution pass. // It prevents a multi-root transaction from staging intermediate graphs. type ChangesDirective interface { - ExpandChanges(context.Context, ChangeSet, ProvenancedState) (DirectiveResult, error) + ExpandChanges(context.Context, ChangeSet, State) (DirectiveResult, error) } // Directive can augment a registry operation with additional operations or effects. @@ -60,7 +56,7 @@ type ChangesDirective interface { // Directives must not call Apply/ApplyVersion/LoadState (Apply is not re-entrant). // Use Effects for work that must be staged, committed, or rolled back alongside Apply. type Directive interface { - Expand(ctx context.Context, op Operation, snapshot ProvenancedState) (DirectiveResult, error) + Expand(ctx context.Context, op Operation, snapshot State) (DirectiveResult, error) } // Effect represents external work tied to an expanded operation. diff --git a/api/registry/provenance.go b/api/registry/provenance.go deleted file mode 100644 index f1b2325f8..000000000 --- a/api/registry/provenance.go +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "context" - "errors" - "fmt" - "sort" -) - -// ErrMissingProvenance reports a state entry without a provenance record — a -// violation of the ProvenanceMap total-map invariant. -var ErrMissingProvenance = errors.New("entry has no provenance record") - -// ErrOrphanedProvenance reports a provenance record without a corresponding -// state entry — also a violation of the one-record-per-entry invariant. -var ErrOrphanedProvenance = errors.New("provenance record has no entry") - -// ErrConflictingModuleProvenance reports entries that attribute one module to -// different resident artifact identities. -var ErrConflictingModuleProvenance = errors.New("module has conflicting provenance records") - -// NewMissingProvenanceError names the entry that violates the total-map -// invariant. -func NewMissingProvenanceError(id ID) error { - return fmt.Errorf("%s: %w", id.String(), ErrMissingProvenance) -} - -// NewOrphanedProvenanceError names the record that has no state entry. -func NewOrphanedProvenanceError(id ID) error { - return fmt.Errorf("%s: %w", id.String(), ErrOrphanedProvenance) -} - -// NewConflictingModuleProvenanceError names the module whose entries disagree. -func NewConflictingModuleProvenanceError(module string) error { - return fmt.Errorf("%s: %w", module, ErrConflictingModuleProvenance) -} - -// EntryProvenance is what the runtime knows ABOUT an entry, held by the -// registry outside the author-owned entry payload: which installed module -// produced it, at which artifact identity, and whether its ns.dependency -// declaration is a deployment root. For a replacement (local dev source) the -// digest is the source tree identity and Version may be empty; Module plus -// Digest then identify the artifact. -type EntryProvenance struct { - // Module names the owning module as "org/name". Empty means the entry is - // host-authored: authored by the application or a user, owned by no module. - Module string `json:"module,omitempty"` - // Version is the owning module's version at the time this entry was - // materialized. - Version string `json:"version,omitempty"` - // Digest is the identity of the artifact that produced this entry's bytes. - Digest string `json:"digest,omitempty"` - // Root marks an ns.dependency entry selected as a deployment root. Roots - // are deployment-context state: the same declaration is a root when its - // module is the deployed application and a transitive link otherwise. - Root bool `json:"root,omitempty"` -} - -// HostAuthored reports whether the entry is owned by no module. -func (p EntryProvenance) HostAuthored() bool { - return p.Module == "" -} - -// ProvenanceMap is the provenance of one State: one record per entry. The map is -// total — every entry of the accompanying State has a key. A missing key is an -// invariant violation and must fail loudly, never be read as host-authored. -type ProvenanceMap map[ID]EntryProvenance - -// Clone returns an independent copy. -func (m ProvenanceMap) Clone() ProvenanceMap { - if m == nil { - return nil - } - out := make(ProvenanceMap, len(m)) - for id, p := range m { - out[id] = p - } - return out -} - -// ProvenancedState is a State together with its provenance. States and their -// provenance travel together through every boundary: baseline load, replay, -// directive inputs, planner comparison, and historical snapshots. -type ProvenancedState struct { - Provenance ProvenanceMap - Entries State -} - -// Validate reports the first entry without a provenance record, enforcing the -// total-map invariant at a state boundary. -func (s ProvenancedState) Validate() error { - entries := make(map[ID]struct{}, len(s.Entries)) - modules := make(map[string]EntryProvenance) - for _, entry := range s.Entries { - p, ok := s.Provenance[entry.ID] - if !ok { - return NewMissingProvenanceError(entry.ID) - } - entries[entry.ID] = struct{}{} - if p.Module == "" { - continue - } - if resident, exists := modules[p.Module]; exists && - (resident.Version != p.Version || resident.Digest != p.Digest) { - return NewConflictingModuleProvenanceError(p.Module) - } - modules[p.Module] = p - } - orphaned := make([]ID, 0) - for id := range s.Provenance { - if _, ok := entries[id]; !ok { - orphaned = append(orphaned, id) - } - } - if len(orphaned) != 0 { - sort.Slice(orphaned, func(i, j int) bool { - return orphaned[i].String() < orphaned[j].String() - }) - return NewOrphanedProvenanceError(orphaned[0]) - } - return nil -} - -// ProvenanceReader answers provenance questions about the live registry state. -// Implementations serve reads from an immutable snapshot, so a reader inside a -// listener callback never blocks a transition in flight. -type ProvenanceReader interface { - // EntryProvenance returns the record for one entry of the current state. - EntryProvenance(ID) (EntryProvenance, bool) - // ResidentModules folds the current provenance into module -> identity of - // the artifact whose entries are resident. - ResidentModules() (map[string]EntryProvenance, error) - // DependencyRoots returns the IDs of the current deployment roots. - DependencyRoots() []ID -} - -// ProvenancedSnapshotReader serves complete current and historical registry -// states with their ownership records. Each result must describe one registry -// version atomically; consumers must not reconstruct it from separate entry -// and provenance reads. -type ProvenancedSnapshotReader interface { - SnapshotState() (Version, ProvenancedState, error) - ProvenancedStateAtVersion(context.Context, Version) (ProvenancedState, error) -} - -// opProvenanceContextKey carries one operation's effective provenance to the -// listeners the transition dispatches, alongside the entry event. -type opProvenanceContextKey struct{} - -// OpProvenance is the provenance pair of one in-flight operation: Effective for -// the entry the operation carries, Original for the entry it replaces. -type OpProvenance struct { - Effective *EntryProvenance - Original *EntryProvenance -} - -// WithOpProvenance returns an operation-scoped context carrying the provenance -// pair of the operation being dispatched. -func WithOpProvenance(ctx context.Context, p OpProvenance) context.Context { - return context.WithValue(ctx, opProvenanceContextKey{}, p) -} - -// OpProvenanceFromContext returns the provenance pair of the operation being -// dispatched, if the dispatcher supplied one. -func OpProvenanceFromContext(ctx context.Context) (OpProvenance, bool) { - if ctx == nil { - return OpProvenance{}, false - } - p, ok := ctx.Value(opProvenanceContextKey{}).(OpProvenance) - return p, ok -} diff --git a/api/registry/provenance_test.go b/api/registry/provenance_test.go deleted file mode 100644 index a85035e6d..000000000 --- a/api/registry/provenance_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestProvenancedStateValidate(t *testing.T) { - a := NewID("ns", "a") - b := NewID("ns", "b") - - tests := []struct { - want error - name string - state ProvenancedState - }{ - { - name: "complete host state", - state: ProvenancedState{ - Entries: State{{ID: a}}, - Provenance: ProvenanceMap{a: {}}, - }, - }, - { - name: "missing record", - state: ProvenancedState{Entries: State{{ID: a}}, Provenance: ProvenanceMap{}}, - want: ErrMissingProvenance, - }, - { - name: "orphaned record", - state: ProvenancedState{Provenance: ProvenanceMap{a: {}}}, - want: ErrOrphanedProvenance, - }, - { - name: "conflicting module identity", - state: ProvenancedState{ - Entries: State{{ID: a}, {ID: b}}, - Provenance: ProvenanceMap{ - a: {Module: "org/mod", Version: "1.0.0", Digest: "sha256:a"}, - b: {Module: "org/mod", Version: "2.0.0", Digest: "sha256:b"}, - }, - }, - want: ErrConflictingModuleProvenance, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.state.Validate() - if tt.want == nil { - require.NoError(t, err) - return - } - require.ErrorIs(t, err, tt.want) - }) - } -} diff --git a/api/registry/registry.go b/api/registry/registry.go index a98ec6afb..9ba8d706b 100644 --- a/api/registry/registry.go +++ b/api/registry/registry.go @@ -108,16 +108,35 @@ type ( // State represents the complete state of the registry at a specific version State []Entry + // Snapshot is one atomic registry view. Registry contains system-owned + // state that must describe the same version as Entries. + Snapshot struct { + Registry StateMetadata + Version Version + Entries State + } + + // StateMetadata contains registry-owned state that is not authored entry + // payload. The selected module graph is stored once, not repeated per entry. + StateMetadata struct { + Resolution *DependencyResolution + } + // Entry represents a single entry in the registry Entry struct { - Data payload.Payload `json:"data"` - Meta attrs.Bag `json:"meta"` - ID ID `json:"id"` - Kind Kind `json:"kind"` - // DependencyRoot identifies an ns.dependency selected as a deployment - // root. It is independent of package ownership, so a published app entry - // can retain meta.module while remaining a root. - DependencyRoot bool `json:"dependency_root,omitempty"` + Data payload.Payload `json:"data"` + Meta attrs.Bag `json:"meta"` + ID ID `json:"id"` + Kind Kind `json:"kind"` + Registry EntryMetadata `json:"registry,omitzero"` + } + + // EntryMetadata is state owned by the registry rather than the entry + // author. Owner is assigned from the deployment source that supplied the + // entry. Root marks a dependency declaration selected by the deployment. + EntryMetadata struct { + Owner string `json:"owner,omitempty"` + Root bool `json:"root,omitempty"` } // ChangeSet represents a set of operations to transition the registry from one state to another @@ -125,11 +144,9 @@ type ( // Operation represents a single operation within a ChangeSet Operation struct { - OriginalEntry *Entry `json:"original_entry,omitempty"` - Provenance *EntryProvenance `json:"provenance,omitempty"` - OriginalProvenance *EntryProvenance `json:"original_provenance,omitempty"` - Kind event.Kind `json:"kind"` - Entry Entry `json:"entry"` + OriginalEntry *Entry `json:"original_entry,omitempty"` + Kind event.Kind `json:"kind"` + Entry Entry `json:"entry"` } // DependencyPattern defines a pattern for extracting dependencies from entries @@ -157,6 +174,8 @@ type ( // Current returns the current version of the registry's state Current() (Version, error) History() History + // Snapshot returns entries and registry metadata from one version. + Snapshot() Snapshot // RegisterDependencyPattern adds a pattern for dependency extraction RegisterDependencyPattern(pattern DependencyPattern) error } @@ -168,7 +187,7 @@ type ( // ApplyVersion applies a specific version to the registry ApplyVersion(context.Context, Version) error // LoadState initializes registry state from baseline and history without creating new version records - LoadState(context.Context, ProvenancedState, Version) error + LoadState(context.Context, State, Version) error } // OverlayWriter manages process-local registry overlays. Overlay entries diff --git a/api/registry/resolution.go b/api/registry/resolution.go index 7a6b0c97f..f6aecb677 100644 --- a/api/registry/resolution.go +++ b/api/registry/resolution.go @@ -35,6 +35,24 @@ type ResolvedModule struct { Protected bool `json:"protected,omitempty"` } +// Deployment records the source graph over which registry history is applied. +// It is persisted with the effective dependency resolution so an established +// deployment can restore its baseline without a lock file. +type Deployment struct { + Root string `json:"root"` + Modules []ResolvedModule `json:"modules"` +} + +// Canonical returns a detached, deterministically ordered deployment. +func (d *Deployment) Canonical() *Deployment { + if d == nil { + return nil + } + out := &Deployment{Root: d.Root, Modules: append([]ResolvedModule(nil), d.Modules...)} + sortResolvedModules(out.Modules) + return out +} + // DependencyRoot records one authored dependency declaration used as a solver // input. Parameters remain in the ordinary registry changeset because they // configure linking rather than version selection. @@ -51,6 +69,7 @@ type DependencyResolution struct { Digest string `json:"digest"` InputDigest string `json:"input_digest"` BaselineDigest string `json:"baseline_digest,omitempty"` + Deployment *Deployment `json:"deployment,omitempty"` Roots []DependencyRoot `json:"roots"` // References are root-shaped declarations folded into an existing root for // the same component: a workspace package declaring a dependency the @@ -75,6 +94,12 @@ func CanRebaseDependencyResolution(existing, next *DependencyResolution) bool { if next.BaselineDigest == "" || existing.Digest == next.Digest { return false } + if existing.Deployment == nil && next.Deployment != nil && existing.BaselineDigest == next.BaselineDigest { + withoutDeployment := next.Canonical() + withoutDeployment.Deployment = nil + withoutDeployment = withoutDeployment.Canonical() + return existing.Digest == withoutDeployment.Digest + } return existing.BaselineDigest == "" || existing.BaselineDigest != next.BaselineDigest } @@ -91,13 +116,20 @@ func (r *DependencyResolution) Canonical() *DependencyResolution { References: append([]DependencyRoot(nil), r.References...), Modules: append([]ResolvedModule(nil), r.Modules...), } + out.Deployment = r.Deployment.Canonical() if len(out.References) == 0 { out.References = nil } sortDependencyRoots(out.Roots) sortDependencyRoots(out.References) - sort.Slice(out.Modules, func(i, j int) bool { - left, right := out.Modules[i], out.Modules[j] + sortResolvedModules(out.Modules) + out.Digest = out.computeDigest() + return out +} + +func sortResolvedModules(modules []ResolvedModule) { + sort.Slice(modules, func(i, j int) bool { + left, right := modules[i], modules[j] if left.Name != right.Name { return left.Name < right.Name } @@ -115,8 +147,6 @@ func (r *DependencyResolution) Canonical() *DependencyResolution { } return !left.Protected && right.Protected }) - out.Digest = out.computeDigest() - return out } func sortDependencyRoots(roots []DependencyRoot) { @@ -169,14 +199,20 @@ func (r *DependencyResolution) Valid() bool { referenceIDs[reference.ID] = struct{}{} } modules := make(map[string]string, len(r.Modules)) - for _, module := range r.Modules { - if module.Name == "" || module.Version == "" || module.Digest == "" { + if !validResolvedModules(r.Modules, modules) { + return false + } + if r.Deployment != nil { + if strings.TrimSpace(r.Deployment.Root) == "" { return false } - if _, duplicate := modules[module.Name]; duplicate { + deploymentModules := make(map[string]string, len(r.Deployment.Modules)) + if !validResolvedModules(r.Deployment.Modules, deploymentModules) { + return false + } + if _, present := deploymentModules[r.Deployment.Root]; !present { return false } - modules[module.Name] = module.Version } // A graph that selects no module for one of its own declarations, or // selects a version a declaration provably excludes, is not a resolution @@ -196,6 +232,19 @@ func (r *DependencyResolution) Valid() bool { return r.Digest == r.Canonical().Digest } +func validResolvedModules(resolved []ResolvedModule, modules map[string]string) bool { + for _, module := range resolved { + if module.Name == "" || module.Version == "" || module.Digest == "" { + return false + } + if _, duplicate := modules[module.Name]; duplicate { + return false + } + modules[module.Name] = module.Version + } + return true +} + // constraintPermitsSelection rejects only provable mismatches: the check binds // when the declared spelling is a semver constraint and the selected version // parses under the same grammar. Channel pins ("@beta"), branch selections, @@ -221,6 +270,7 @@ func (r *DependencyResolution) computeDigest() string { payload := struct { InputDigest string `json:"input_digest"` BaselineDigest string `json:"baseline_digest,omitempty"` + Deployment *Deployment `json:"deployment,omitempty"` Roots []DependencyRoot `json:"roots"` // omitempty keeps a reference-free digest byte-identical to prior // releases while distinct reference sets produce distinct graphs in @@ -233,6 +283,7 @@ func (r *DependencyResolution) computeDigest() string { Roots: r.Roots, References: r.References, Modules: r.Modules, + Deployment: r.Deployment, } data, _ := json.Marshal(payload) // Struct contains only JSON-safe primitives. sum := sha256.Sum256(data) diff --git a/api/registry/resolution_test.go b/api/registry/resolution_test.go index 5f056e20e..2cbf57568 100644 --- a/api/registry/resolution_test.go +++ b/api/registry/resolution_test.go @@ -100,6 +100,50 @@ func TestCanRebaseDependencyResolutionRequiresBaselineTransition(t *testing.T) { } } +func TestCanRebaseDependencyResolutionPermitsDeploymentBindingOnce(t *testing.T) { + existing := (&DependencyResolution{ + BaselineDigest: "sha256:baseline", + InputDigest: "sha256:inputs", + Roots: []DependencyRoot{{ID: "app.deps:app", Component: "acme/app", Version: "1.0.0"}}, + Modules: []ResolvedModule{{Name: "acme/app", Version: "1.0.0", Digest: "sha256:app"}}, + }).Canonical() + next := existing.Canonical() + next.Deployment = &Deployment{ + Root: "acme/app", + Modules: []ResolvedModule{{Name: "acme/app", Version: "1.0.0", Digest: "sha256:app"}}, + } + next = next.Canonical() + + require.True(t, next.Valid()) + require.True(t, CanRebaseDependencyResolution(existing, next)) + + changed := next.Canonical() + changed.Deployment.Modules[0].Version = "2.0.0" + changed = changed.Canonical() + require.False(t, CanRebaseDependencyResolution(next, changed)) +} + +func TestDependencyResolutionDeploymentIsCanonicalAndValidated(t *testing.T) { + resolution := (&DependencyResolution{ + InputDigest: "sha256:inputs", + Deployment: &Deployment{ + Root: "acme/app", + Modules: []ResolvedModule{ + {Name: "acme/worker", Version: "1.0.0", Digest: "sha256:worker"}, + {Name: "acme/app", Version: "1.0.0", Digest: "sha256:app"}, + }, + }, + }).Canonical() + + require.True(t, resolution.Valid()) + require.Equal(t, "acme/app", resolution.Deployment.Modules[0].Name) + + missingRoot := resolution.Canonical() + missingRoot.Deployment.Root = "acme/missing" + missingRoot = missingRoot.Canonical() + require.False(t, missingRoot.Valid()) +} + func TestDependencyResolutionReferencesStayOutsideDigests(t *testing.T) { base := &DependencyResolution{ InputDigest: "sha256:input", diff --git a/api/registry/rollback.go b/api/registry/rollback.go deleted file mode 100644 index 45d896c48..000000000 --- a/api/registry/rollback.go +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "context" - "errors" - "sync" -) - -// RollbackOutcome collects what a runner's compensation actually did, so the -// registry can reconstruct the provenance of a partially compensated state -// instead of guessing from the map it published before the transition. -type RollbackOutcome struct { - surviving []Operation - errs []error - mu sync.Mutex -} - -// Record notes one accepted operation's compensation attempt. A failed -// compensation leaves the operation's effect in the state. -func (o *RollbackOutcome) Record(op Operation, compensated bool, err error) { - o.mu.Lock() - defer o.mu.Unlock() - if !compensated { - o.surviving = append(o.surviving, op) - } - if err != nil { - o.errs = append(o.errs, err) - } -} - -// Surviving returns the accepted operations whose effects remain in the state. -func (o *RollbackOutcome) Surviving() []Operation { - o.mu.Lock() - defer o.mu.Unlock() - return append([]Operation(nil), o.surviving...) -} - -// Err joins the compensation failures. -func (o *RollbackOutcome) Err() error { - o.mu.Lock() - defer o.mu.Unlock() - return errors.Join(o.errs...) -} - -type rollbackOutcomeContextKey struct{} - -// WithRollbackOutcome attaches a compensation collector for the runner to fill. -func WithRollbackOutcome(ctx context.Context, o *RollbackOutcome) context.Context { - return context.WithValue(ctx, rollbackOutcomeContextKey{}, o) -} - -// RollbackOutcomeFromContext returns the collector, if the caller supplied one. -func RollbackOutcomeFromContext(ctx context.Context) *RollbackOutcome { - if ctx == nil { - return nil - } - o, _ := ctx.Value(rollbackOutcomeContextKey{}).(*RollbackOutcome) - return o -} diff --git a/api/service/fs/directory/resolve.go b/api/service/fs/directory/resolve.go index 53b50ccc3..76660d49d 100644 --- a/api/service/fs/directory/resolve.go +++ b/api/service/fs/directory/resolve.go @@ -8,14 +8,11 @@ import ( "path/filepath" "github.com/wippyai/runtime/api/modules" + "github.com/wippyai/runtime/api/registry" ) -// ResolveDirectory resolves an fs.directory entry's configured path to a load -// path against the source root of the module that owns the entry. The owning -// module is supplied by the caller: the provenance of the operation carrying -// the entry at runtime, the module being built at build time. An empty module -// is host-authored and stays working-directory relative. -func ResolveDirectory(ctx context.Context, moduleName string, cfg *Config) string { +// ResolveDirectory resolves an fs.directory entry's configured path to a load path. +func ResolveDirectory(ctx context.Context, entry registry.Entry, cfg *Config) string { if cfg == nil { return "" } @@ -25,6 +22,8 @@ func ResolveDirectory(ctx context.Context, moduleName string, cfg *Config) strin if cfg.Base == BaseProject { return cfg.Directory } + + moduleName := entry.Registry.Owner if moduleName == "" { return cfg.Directory } diff --git a/api/service/fs/directory/resolve_test.go b/api/service/fs/directory/resolve_test.go index ae7520f06..7517a7e9a 100644 --- a/api/service/fs/directory/resolve_test.go +++ b/api/service/fs/directory/resolve_test.go @@ -9,8 +9,17 @@ import ( ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/modules" + "github.com/wippyai/runtime/api/registry" ) +func moduleEntry(module string) registry.Entry { + e := registry.Entry{ID: registry.NewID("acme.ui", "ui_fs")} + if module != "" { + e.Registry.Owner = module + } + return e +} + func withSourceRoot(ctx context.Context, module, root string) context.Context { sources := modules.NewSourceRegistry() sources.Set(modules.Sources{module: { @@ -25,19 +34,19 @@ func TestResolveDirectory(t *testing.T) { abs := filepath.Join(string(filepath.Separator), "abs", "path") t.Run("nil config", func(t *testing.T) { - if got := ResolveDirectory(ctxapi.NewRootContext(), "acme/ui", nil); got != "" { + if got := ResolveDirectory(ctxapi.NewRootContext(), moduleEntry("acme/ui"), nil); got != "" { t.Fatalf("got %q, want empty", got) } }) t.Run("empty directory", func(t *testing.T) { - if got := ResolveDirectory(ctxapi.NewRootContext(), "acme/ui", &Config{}); got != "" { + if got := ResolveDirectory(ctxapi.NewRootContext(), moduleEntry("acme/ui"), &Config{}); got != "" { t.Fatalf("got %q, want empty", got) } }) t.Run("absolute path unchanged", func(t *testing.T) { - if got := ResolveDirectory(ctxapi.NewRootContext(), "acme/ui", &Config{Directory: abs}); got != abs { + if got := ResolveDirectory(ctxapi.NewRootContext(), moduleEntry("acme/ui"), &Config{Directory: abs}); got != abs { t.Fatalf("got %q, want %q", got, abs) } }) @@ -46,30 +55,30 @@ func TestResolveDirectory(t *testing.T) { ctx := ctxapi.NewRootContext() ctx = withSourceRoot(ctx, "acme/ui", abs) cfg := &Config{Directory: "./static", Base: BaseProject} - if got := ResolveDirectory(ctx, "acme/ui", cfg); got != "./static" { + if got := ResolveDirectory(ctx, moduleEntry("acme/ui"), cfg); got != "./static" { t.Fatalf("got %q, want ./static", got) } }) - t.Run("host-authored entry stays unchanged", func(t *testing.T) { + t.Run("entry without owner stays unchanged", func(t *testing.T) { ctx := ctxapi.NewRootContext() ctx = withSourceRoot(ctx, "acme/ui", abs) - if got := ResolveDirectory(ctx, "", &Config{Directory: "./static"}); got != "./static" { + if got := ResolveDirectory(ctx, moduleEntry(""), &Config{Directory: "./static"}); got != "./static" { t.Fatalf("got %q, want ./static", got) } }) - t.Run("module without source root stays unchanged", func(t *testing.T) { - if got := ResolveDirectory(ctxapi.NewRootContext(), "acme/ui", &Config{Directory: "./static"}); got != "./static" { + t.Run("owner without source root stays unchanged", func(t *testing.T) { + if got := ResolveDirectory(ctxapi.NewRootContext(), moduleEntry("acme/ui"), &Config{Directory: "./static"}); got != "./static" { t.Fatalf("got %q, want ./static", got) } }) - t.Run("module with source root joins", func(t *testing.T) { + t.Run("owner with source root joins", func(t *testing.T) { ctx := ctxapi.NewRootContext() ctx = withSourceRoot(ctx, "acme/ui", abs) want := filepath.Join(abs, "static") - if got := ResolveDirectory(ctx, "acme/ui", &Config{Directory: "./static"}); got != want { + if got := ResolveDirectory(ctx, moduleEntry("acme/ui"), &Config{Directory: "./static"}); got != want { t.Fatalf("got %q, want %q", got, want) } }) diff --git a/api/service/fs/embed/registry.go b/api/service/fs/embed/registry.go index 3bf49afe2..212ebb838 100644 --- a/api/service/fs/embed/registry.go +++ b/api/service/fs/embed/registry.go @@ -23,17 +23,6 @@ type Registry interface { Close() error } -// EntryResolver is an optional Registry capability that resolves a filesystem -// for a specific registry entry, honoring the provenance of the operation that -// carries it. It lets consumers pick the correct pack when more than one module -// version exposes the same resource ID — for example, while a module update has -// both the old and new packs staged. A nil or host-authored provenance resolves -// by entry ID, as does a module-owned entry whose provenance names no version -// and whose module exposes no matching pack. -type EntryResolver interface { - GetFSForEntry(entry registry.Entry, prov *registry.EntryProvenance) (fs.ReadDirFS, error) -} - // WithRegistry stores the Registry in the context. func WithRegistry(ctx context.Context, reg Registry) context.Context { ac := ctxapi.AppFromContext(ctx) diff --git a/boot/build/stages/embed_fs.go b/boot/build/stages/embed_fs.go index 8b0803851..3f03f5bff 100644 --- a/boot/build/stages/embed_fs.go +++ b/boot/build/stages/embed_fs.go @@ -26,7 +26,6 @@ var ( type embedFSStage struct { moduleRoot string - prov registry.ProvenanceMap embedPatterns []string } @@ -35,13 +34,9 @@ type embedFSStage struct { // directories resolve against when no module source root is registered in // context (publish, which loads entries straight from a module checkout); pass // an empty string when the lock loader has already registered source roots. -// prov names the module each entry belongs to, from the loader context that -// produced the entries; entries it does not name are host-authored and resolve -// working-directory relative. -func EmbedFS(moduleRoot string, prov registry.ProvenanceMap, embedPatterns ...string) boot.Stage { +func EmbedFS(moduleRoot string, embedPatterns ...string) boot.Stage { return &embedFSStage{ moduleRoot: moduleRoot, - prov: prov, embedPatterns: embedPatterns, } } @@ -74,18 +69,7 @@ func (s *embedFSStage) Execute(ctx context.Context, entries *[]registry.Entry) e } } - // A supplied map names every entry the stage attributes; a missing record is - // an error, never a host-authored default. The nil map is the documented - // single-source build with no module world. - if s.prov != nil { - for _, entry := range filteredEntries { - if _, ok := s.prov[entry.ID]; !ok { - return registry.NewMissingProvenanceError(entry.ID) - } - } - } - - res, err := collectResources(ctx, s.moduleRoot, s.prov, filteredEntries, log) + res, err := collectResources(ctx, s.moduleRoot, filteredEntries, log) if err != nil { return err } @@ -143,13 +127,7 @@ func filterEmbeddableEntries(entries []registry.Entry, embedPatterns []string) [ return embeddable } -func collectResources( - ctx context.Context, - moduleRoot string, - prov registry.ProvenanceMap, - entries []registry.Entry, - logger *zap.Logger, -) ([]wapp.ResourceSpec, error) { +func collectResources(ctx context.Context, moduleRoot string, entries []registry.Entry, logger *zap.Logger) ([]wapp.ResourceSpec, error) { specs := make([]wapp.ResourceSpec, 0, len(entries)) for _, entry := range entries { if entry.Kind != dirapi.Kind { @@ -161,7 +139,7 @@ func collectResources( return nil, fmt.Errorf("embed %s: directory path missing", entry.ID.String()) } - dir := resolveEmbedDirectory(ctx, moduleRoot, prov[entry.ID].Module, cfg) + dir := resolveEmbedDirectory(ctx, moduleRoot, entry, cfg) info, err := os.Stat(dir) if err != nil { @@ -202,8 +180,8 @@ func directoryConfig(entry registry.Entry) *dirapi.Config { return cfg } -func resolveEmbedDirectory(ctx context.Context, moduleRoot, module string, cfg *dirapi.Config) string { - dir := dirapi.ResolveDirectory(ctx, module, cfg) +func resolveEmbedDirectory(ctx context.Context, moduleRoot string, entry registry.Entry, cfg *dirapi.Config) string { + dir := dirapi.ResolveDirectory(ctx, entry, cfg) if moduleRoot != "" && cfg.Base != dirapi.BaseProject && !dirapi.IsConfiguredPathAbsolute(cfg.Directory) { return filepath.Join(moduleRoot, cfg.Directory) } @@ -220,10 +198,11 @@ func transformEntries(entries []registry.Entry, embeddableIDs []registry.ID) []r for i, entry := range entries { if embeddableMap[entry.ID.String()] && entry.Kind == dirapi.Kind { transformed[i] = registry.Entry{ - ID: entry.ID, - Kind: embedapi.Kind, - Meta: entry.Meta, - Data: payload.New(map[string]any{}), + ID: entry.ID, + Kind: embedapi.Kind, + Meta: entry.Meta, + Registry: entry.Registry, + Data: payload.New(map[string]any{}), } } else { transformed[i] = entry diff --git a/boot/build/stages/embed_fs_test.go b/boot/build/stages/embed_fs_test.go index 6377bc91c..870009fc2 100644 --- a/boot/build/stages/embed_fs_test.go +++ b/boot/build/stages/embed_fs_test.go @@ -5,14 +5,11 @@ package stages import ( "bytes" "context" - "errors" "io/fs" "os" "path/filepath" "testing" - ctxapi "github.com/wippyai/runtime/api/context" - "github.com/wippyai/runtime/api/modules" "github.com/wippyai/runtime/api/payload" "github.com/wippyai/runtime/api/registry" dirapi "github.com/wippyai/runtime/api/service/fs/directory" @@ -33,16 +30,16 @@ func TestEmbedFSCollectsModuleRelativeDirectory(t *testing.T) { t.Chdir(moduleRoot) entry := registry.Entry{ - ID: registry.NewID("acme.ui", "static_fs"), - Kind: dirapi.Kind, + ID: registry.NewID("acme.ui", "static_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/ui"}, Data: payload.New(map[string]any{ "base": dirapi.BaseModule, "directory": "./static/app", }), } - prov := moduleProv("acme/ui", entry.ID) - resources, err := collectResources(context.Background(), "", prov, []registry.Entry{entry}, zap.NewNop()) + resources, err := collectResources(context.Background(), "", []registry.Entry{entry}, zap.NewNop()) if err != nil { t.Fatalf("collectResources failed: %v", err) } @@ -82,18 +79,13 @@ func TestEmbedFSResolvesModuleRootWithoutChdir(t *testing.T) { t.Chdir(t.TempDir()) entry := registry.Entry{ - ID: registry.NewID("acme.ui", "ui_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./static"}), + ID: registry.NewID("acme.ui", "ui_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/ui"}, + Data: payload.New(map[string]any{"directory": "./static"}), } - resources, err := collectResources( - context.Background(), - moduleRoot, - moduleProv("acme/ui", entry.ID), - []registry.Entry{entry}, - zap.NewNop(), - ) + resources, err := collectResources(context.Background(), moduleRoot, []registry.Entry{entry}, zap.NewNop()) if err != nil { t.Fatalf("collectResources failed: %v", err) } @@ -109,50 +101,6 @@ func TestEmbedFSResolvesModuleRootWithoutChdir(t *testing.T) { } } -// Pack mode: entries come from many modules at once, so the owning module of -// each entry — and with it the source root its relative directory resolves -// against — comes from the loader provenance. -func TestEmbedFSResolvesModuleSourceRootFromProvenance(t *testing.T) { - moduleRoot := t.TempDir() - staticDir := filepath.Join(moduleRoot, "static") - if err := os.MkdirAll(staticDir, 0o755); err != nil { - t.Fatalf("mkdir static dir: %v", err) - } - if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("ui"), 0o644); err != nil { - t.Fatalf("write index.html: %v", err) - } - t.Chdir(t.TempDir()) - - ctx := ctxapi.NewRootContext() - sources := modules.NewSourceRegistry() - sources.Set(modules.Sources{"acme/ui": { - LoadPath: moduleRoot, ResourceRoot: moduleRoot, Owner: "acme/ui", - }}) - ctx = modules.WithSourceRegistry(ctx, sources) - - entry := registry.Entry{ - ID: registry.NewID("acme.ui", "ui_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./static"}), - } - - resources, err := collectResources(ctx, "", moduleProv("acme/ui", entry.ID), []registry.Entry{entry}, zap.NewNop()) - if err != nil { - t.Fatalf("collectResources failed: %v", err) - } - if len(resources) != 1 { - t.Fatalf("resource count = %d, want 1", len(resources)) - } - if data, err := fs.ReadFile(resources[0].FS, "index.html"); err != nil || string(data) != "ui" { - t.Fatalf("embedded index.html = %q err=%v", string(data), err) - } - - // A host-authored entry has no module source root to resolve against. - if _, err := collectResources(ctx, "", nil, []registry.Entry{entry}, zap.NewNop()); err == nil { - t.Fatal("expected error for host-authored entry with a module-relative directory, got nil") - } -} - func TestEmbedFSCollectsMultipleResources(t *testing.T) { moduleRoot := t.TempDir() for _, sub := range []string{"static", "wasm"} { @@ -168,24 +116,20 @@ func TestEmbedFSCollectsMultipleResources(t *testing.T) { entries := []registry.Entry{ { - ID: registry.NewID("acme.app", "ui_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./static"}), + ID: registry.NewID("acme.app", "ui_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/app"}, + Data: payload.New(map[string]any{"directory": "./static"}), }, { - ID: registry.NewID("acme.app", "wasm_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./wasm"}), + ID: registry.NewID("acme.app", "wasm_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/app"}, + Data: payload.New(map[string]any{"directory": "./wasm"}), }, } - resources, err := collectResources( - context.Background(), - moduleRoot, - moduleProv("acme/app", entries[0].ID, entries[1].ID), - entries, - zap.NewNop(), - ) + resources, err := collectResources(context.Background(), moduleRoot, entries, zap.NewNop()) if err != nil { t.Fatalf("collectResources failed: %v", err) } @@ -199,18 +143,13 @@ func TestEmbedFSErrorsOnMissingDirectory(t *testing.T) { t.Chdir(t.TempDir()) entry := registry.Entry{ - ID: registry.NewID("acme.ui", "ui_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./does-not-exist"}), + ID: registry.NewID("acme.ui", "ui_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/ui"}, + Data: payload.New(map[string]any{"directory": "./does-not-exist"}), } - _, err := collectResources( - context.Background(), - moduleRoot, - moduleProv("acme/ui", entry.ID), - []registry.Entry{entry}, - zap.NewNop(), - ) + _, err := collectResources(context.Background(), moduleRoot, []registry.Entry{entry}, zap.NewNop()) if err == nil { t.Fatal("expected error for missing directory, got nil") } @@ -219,7 +158,7 @@ func TestEmbedFSErrorsOnMissingDirectory(t *testing.T) { func TestEmbedFSErrorsOnNilEntryData(t *testing.T) { entry := registry.Entry{ID: registry.NewID("acme.ui", "ui_fs"), Kind: dirapi.Kind} - _, err := collectResources(context.Background(), "", nil, []registry.Entry{entry}, zap.NewNop()) + _, err := collectResources(context.Background(), "", []registry.Entry{entry}, zap.NewNop()) if err == nil { t.Fatal("expected error for entry with nil data, got nil") } @@ -241,7 +180,7 @@ func TestEmbedFSClearsResourcesWhenNoDirectories(t *testing.T) { Kind: dirapi.Kind, Data: payload.New(map[string]any{"directory": "./static"}), }} - if err := EmbedFS(moduleRoot, nil, "ui_fs").Execute(ctx, &entries); err != nil { + if err := EmbedFS(moduleRoot, "ui_fs").Execute(ctx, &entries); err != nil { t.Fatalf("first stage execute: %v", err) } if got := len(GetResources(ctx)); got != 1 { @@ -249,7 +188,7 @@ func TestEmbedFSClearsResourcesWhenNoDirectories(t *testing.T) { } entries = []registry.Entry{{ID: registry.NewID("acme.ui", "definition"), Kind: "ns.definition"}} - if err := EmbedFS(moduleRoot, nil).Execute(ctx, &entries); err != nil { + if err := EmbedFS(moduleRoot).Execute(ctx, &entries); err != nil { t.Fatalf("second stage execute: %v", err) } if got := len(GetResources(ctx)); got != 0 { @@ -345,7 +284,7 @@ func TestEmbedFSEndToEndPacksBothResources(t *testing.T) { }, } - stage := EmbedFS(moduleRoot, nil, "ui_fs", "wasm_fs") + stage := EmbedFS(moduleRoot, "ui_fs", "wasm_fs") if err := stage.Execute(context.Background(), &entries); err != nil { t.Fatalf("stage execute: %v", err) } @@ -390,60 +329,3 @@ func TestEmbedFSEndToEndPacksBothResources(t *testing.T) { t.Fatalf("wasm content mismatch err=%v", err) } } - -// moduleProv builds the loader provenance naming the module that owns the -// entries being embedded. -func moduleProv(module string, ids ...registry.ID) registry.ProvenanceMap { - prov := make(registry.ProvenanceMap, len(ids)) - for _, id := range ids { - prov[id] = registry.EntryProvenance{Module: module} - } - return prov -} - -// A supplied provenance map is total over the entries the stage attributes. -// The nil map is the documented single-source build with no module world. -func TestEmbedFSProvenanceTotality(t *testing.T) { - moduleRoot := t.TempDir() - staticDir := filepath.Join(moduleRoot, "static") - if err := os.MkdirAll(staticDir, 0o755); err != nil { - t.Fatalf("mkdir static dir: %v", err) - } - if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("ui"), 0o644); err != nil { - t.Fatalf("write index.html: %v", err) - } - t.Chdir(moduleRoot) - - entry := registry.Entry{ - ID: registry.NewID("acme.ui", "ui_fs"), - Kind: dirapi.Kind, - Data: payload.New(map[string]any{"directory": "./static"}), - } - other := registry.NewID("acme.ui", "other_fs") - - for _, tc := range []struct { - prov registry.ProvenanceMap - name string - wantErr bool - }{ - {name: "no module world", prov: nil}, - {name: "entry named", prov: registry.ProvenanceMap{entry.ID: {}}}, - {name: "another entry named", prov: registry.ProvenanceMap{other: {Module: "acme/ui"}}, wantErr: true}, - {name: "empty map names nothing", prov: registry.ProvenanceMap{}, wantErr: true}, - } { - t.Run(tc.name, func(t *testing.T) { - ctx := ctxapi.NewRootContext() - entries := []registry.Entry{entry} - err := EmbedFS("", tc.prov, "ui_fs").Execute(ctx, &entries) - if !tc.wantErr { - if err != nil { - t.Fatalf("execute failed: %v", err) - } - return - } - if !errors.Is(err, registry.ErrMissingProvenance) { - t.Fatalf("err = %v, want ErrMissingProvenance", err) - } - }) - } -} diff --git a/boot/build/stages/link_fixture_test.go b/boot/build/stages/link_fixture_test.go deleted file mode 100644 index 70e2dc84b..000000000 --- a/boot/build/stages/link_fixture_test.go +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package stages - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/boot" - "github.com/wippyai/runtime/api/registry" -) - -// Fixtures declare who owns an entry with a "module" meta key and with -// Entry.DependencyRoot. linkFixtureProvenance moves those declarations into the -// provenance the stage reads and removes them from the entries, so a fixture -// can never make the stage read ownership from author payload. -const fixtureModuleKey = "module" - -func fixtureEntryModule(entry registry.Entry) string { - if entry.Meta == nil { - return "" - } - module, _ := entry.Meta[fixtureModuleKey].(string) - return module -} - -func linkFixtureProvenance(prov registry.ProvenanceMap, entries *[]registry.Entry) { - for i := range *entries { - entry := &(*entries)[i] - record := registry.EntryProvenance{Module: fixtureEntryModule(*entry), Root: entry.DependencyRoot} - entry.DependencyRoot = false - delete(entry.Meta, fixtureModuleKey) - prov[entry.ID] = record - } -} - -// linkFixtureStage builds a link stage over the provenance a fixture declares. -func linkFixtureStage(entries *[]registry.Entry, opts ...LinkOption) boot.Stage { - prov := registry.ProvenanceMap{} - linkFixtureProvenance(prov, entries) - return Link(prov, opts...) -} - -// A supplied provenance map is total over the entries the stage attributes. -// The nil map is the documented single-source build with no module world. -func TestLinkProvenanceTotality(t *testing.T) { - target := registry.Entry{ - ID: registry.NewID("app", "endpoint"), - Kind: "http.endpoint", - Meta: map[string]any{"router": "app:default"}, - } - definition := registry.Entry{ - ID: registry.NewID("identity.account", "definition"), - Kind: registry.NamespaceDefinition, - } - - for _, tc := range []struct { - prov registry.ProvenanceMap - name string - wantErr bool - }{ - {name: "no module world", prov: nil}, - { - name: "every entry named", - prov: registry.ProvenanceMap{target.ID: {}, definition.ID: {Module: fixtureComponent}}, - }, - { - name: "an entry unnamed", - prov: registry.ProvenanceMap{target.ID: {}}, - wantErr: true, - }, - { - name: "empty map names nothing", - prov: registry.ProvenanceMap{}, - wantErr: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - ctx, _ := setupTestContext() - entries := []registry.Entry{target, definition} - err := Link(tc.prov).Execute(ctx, &entries) - if !tc.wantErr { - require.NoError(t, err) - return - } - require.ErrorIs(t, err, registry.ErrMissingProvenance) - }) - } -} - -func TestLinkProvenanceTotalityCoversExplicitDependencies(t *testing.T) { - ctx, _ := setupTestContext() - target := registry.Entry{ID: registry.NewID("app", "endpoint"), Kind: "http.endpoint"} - dependency := dependencyEntry(fixtureComponent, "router", "app:configured") - - entries := []registry.Entry{target} - err := Link(registry.ProvenanceMap{target.ID: {}}, WithDependencies([]registry.Entry{dependency})).Execute(ctx, &entries) - require.ErrorIs(t, err, registry.ErrMissingProvenance, - "a declaration linked without being resident is attributed too") -} diff --git a/boot/build/stages/linking.go b/boot/build/stages/linking.go index b6c6da4cc..45dc2122a 100644 --- a/boot/build/stages/linking.go +++ b/boot/build/stages/linking.go @@ -54,7 +54,6 @@ type LinkOption func(*linkStage) type linkStage struct { strictModules map[string]struct{} - provenance registry.ProvenanceMap dependencyEntries []registry.Entry explicitDeps bool strict bool @@ -63,14 +62,8 @@ type linkStage struct { // Link creates a new linking stage that resolves requirements to their values // and applies them to target entries. -// -// prov is the provenance of the entries being linked and is the only source of -// module membership: which module declares a namespace, which dependency is a -// package-injected transitive, and which requirement falls under strict module -// scope. It is a required argument so every caller answers that question; a -// build of a single source tree, which has no module world, passes nil. -func Link(prov registry.ProvenanceMap, opts ...LinkOption) boot.Stage { - stage := &linkStage{provenance: prov} +func Link(opts ...LinkOption) boot.Stage { + stage := &linkStage{} for _, opt := range opts { if opt != nil { opt(stage) @@ -123,14 +116,6 @@ func (s *linkStage) Execute(ctx context.Context, entries *[]registry.Entry) erro if transcoder == nil { return ErrTranscoderNotFound } - if err := s.validateProvenance(*entries); err != nil { - return err - } - if s.explicitDeps { - if err := s.validateProvenance(s.dependencyEntries); err != nil { - return err - } - } log := logs.GetLogger(ctx) mutator := entry.NewMutator(transcoder) @@ -153,7 +138,7 @@ func (s *linkStage) Execute(ctx context.Context, entries *[]registry.Entry) erro } } - moduleNamespaces, err := s.declaredModuleNamespaces(*entries) + moduleNamespaces, err := declaredModuleNamespaces(*entries) if err != nil { return err } @@ -206,7 +191,8 @@ func (s *linkStage) shouldFailUnresolvedRequirement(req decodedRequirement) bool return false } if s.strictModuleScope { - _, ok := s.strictModules[s.entryModule(req.entry)] + module := req.entry.Registry.Owner + _, ok := s.strictModules[module] return ok } return true @@ -234,7 +220,7 @@ func (s *linkStage) collectDependencies( return nil, NewDecodeDependencyError(e.ID.String(), err) } ownedNamespace := moduleNamespaces[def.Component] - if ownedNamespace == "" && len(def.Parameters) > 0 && s.loadedModule(*entries, def.Component) { + if ownedNamespace == "" && len(def.Parameters) > 0 && loadedModule(*entries, def.Component) { return nil, fmt.Errorf( "dependency %s cannot bind parameters for component %s: loaded module has no ns.definition root namespace", e.ID.String(), @@ -245,7 +231,7 @@ func (s *linkStage) collectDependencies( dependencies[e.ID.String()] = decodedDependency{ definition: def, ownedNamespace: ownedNamespace, - transitive: s.transitiveDependency(e), + transitive: e.Registry.Owner != "" && !e.Registry.Root, } } @@ -550,39 +536,9 @@ func (s *linkStage) findTargetEntries( return results } -// validateProvenance enforces the total-map invariant over the entries the -// stage attributes. A supplied map must name every one of them; a missing -// record is an error, never a host-authored default. The nil map is the -// documented single-source build with no module world. -func (s *linkStage) validateProvenance(entries []registry.Entry) error { - if s.provenance == nil { - return nil - } - for _, e := range entries { - if _, ok := s.provenance[e.ID]; !ok { - return registry.NewMissingProvenanceError(e.ID) - } - } - return nil -} - -// entryModule returns the module owning an entry, empty when the entry is -// host-authored or the build carries no module world. Execute has already -// verified that a supplied map names every entry. -func (s *linkStage) entryModule(entry registry.Entry) string { - return s.provenance[entry.ID].Module -} - -// transitiveDependency reports a declaration a package injected, as opposed to -// one the deployment selected. Ownership and root selection are independent. -func (s *linkStage) transitiveDependency(entry registry.Entry) bool { - record := s.provenance[entry.ID] - return record.Module != "" && !record.Root -} - -func (s *linkStage) loadedModule(entries []registry.Entry, module string) bool { +func loadedModule(entries []registry.Entry, module string) bool { for _, entry := range entries { - if s.entryModule(entry) == module { + if entry.Registry.Owner == module { return true } } @@ -591,17 +547,17 @@ func (s *linkStage) loadedModule(entries []registry.Entry, module string) bool { // declaredModuleNamespaces returns the canonical registry namespace exported // by each loaded module. Publishing requires exactly one ns.definition per -// module, and provenance records which module produced that entry. This is +// module, and the loader records the containing module on that entry. This is // the authoritative bridge between a Hub component name (org/module) and its // registry namespace; neither spelling nor pluralization is inferred. -func (s *linkStage) declaredModuleNamespaces(entries []registry.Entry) (map[string]string, error) { +func declaredModuleNamespaces(entries []registry.Entry) (map[string]string, error) { namespaces := make(map[string]string) owners := make(map[string]string) for _, entry := range entries { if entry.Kind != registry.NamespaceDefinition { continue } - module := s.entryModule(entry) + module := entry.Registry.Owner if module == "" { continue } diff --git a/boot/build/stages/linking_declared_namespace_test.go b/boot/build/stages/linking_declared_namespace_test.go index 7403fee5b..378e0c191 100644 --- a/boot/build/stages/linking_declared_namespace_test.go +++ b/boot/build/stages/linking_declared_namespace_test.go @@ -3,7 +3,6 @@ package stages import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -34,7 +33,7 @@ func TestLinkUsesDeclaredModuleNamespace(t *testing.T) { ctx, _ := setupTestContext() entries := declaredNamespaceFixture(tt.parameter) - err := linkFixtureStage(&entries).Execute(ctx, &entries) + err := Link().Execute(ctx, &entries) require.NoError(t, err) target := findEntry(entries, "app", "endpoint") @@ -53,7 +52,7 @@ func TestLinkCanonicalRequirementAddressDoesNotInferOwnership(t *testing.T) { targetEntry("scheduler"), } - err := linkFixtureStage(&entries).Execute(ctx, &entries) + err := Link().Execute(ctx, &entries) require.NoError(t, err) assert.Equal(t, "app:processes", findEntry(entries, "app", "scheduler").Meta["router"]) } @@ -67,7 +66,7 @@ func TestLinkBareRequirementDoesNotEscapeDeclaredNamespace(t *testing.T) { targetEntry("scheduler"), } - err := linkFixtureStage(&entries).Execute(ctx, &entries) + err := Link().Execute(ctx, &entries) require.NoError(t, err) assert.Equal(t, "app:default", findEntry(entries, "app", "scheduler").Meta["router"]) } @@ -84,7 +83,7 @@ func TestLinkDeclaredNamespaceOwnsChildrenOnly(t *testing.T) { targetEntry("report_endpoint"), } - err := linkFixtureStage(&entries).Execute(ctx, &entries) + err := Link().Execute(ctx, &entries) require.NoError(t, err) assert.Equal(t, "app:account", findEntry(entries, "app", "account_endpoint").Meta["router"]) assert.Equal(t, "app:report_default", findEntry(entries, "app", "report_endpoint").Meta["router"]) @@ -98,7 +97,7 @@ func TestLinkRejectsLoadedModuleWithoutDefinition(t *testing.T) { targetEntry("endpoint"), } - err := linkFixtureStage(&entries).Execute(ctx, &entries) + err := Link().Execute(ctx, &entries) require.Error(t, err) assert.ErrorContains(t, err, "loaded module has no ns.definition root namespace") } @@ -109,7 +108,7 @@ func TestLinkAllowsUnloadedDependencyDuringSourceBuild(t *testing.T) { dependencyEntry(fixtureComponent, "router", "app:configured"), } - require.NoError(t, linkFixtureStage(&entries).Execute(ctx, &entries)) + require.NoError(t, Link().Execute(ctx, &entries)) } func TestLinkRejectsConflictingModuleDefinitions(t *testing.T) { @@ -140,7 +139,7 @@ func TestLinkRejectsConflictingModuleDefinitions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx, _ := setupTestContext() - err := linkFixtureStage(&tt.entries).Execute(ctx, &tt.entries) + err := Link().Execute(ctx, &tt.entries) require.Error(t, err) assert.ErrorContains(t, err, tt.want) }) @@ -156,14 +155,11 @@ func declaredNamespaceFixture(parameter string) []registry.Entry { } } -// moduleDefinition names the entry after its module: two modules declaring the -// same namespace are two distinct entries, which is the only shape a registry -// state can hold. func moduleDefinition(component, namespace string) registry.Entry { return registry.Entry{ - ID: registry.NewID(namespace, strings.ReplaceAll(component, "/", ".")+".definition"), - Kind: registry.NamespaceDefinition, - Meta: map[string]any{"module": component}, + ID: registry.NewID(namespace, "definition"), + Kind: registry.NamespaceDefinition, + Registry: registry.EntryMetadata{Owner: component}, } } @@ -182,9 +178,9 @@ func dependencyEntry(component, parameter string, value any) registry.Entry { func requirementEntry(component, namespace, name, target string, defaultValue any) registry.Entry { return registry.Entry{ - ID: registry.NewID(namespace, name), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": component}, + ID: registry.NewID(namespace, name), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: component}, Data: payload.New(map[string]any{ "default": defaultValue, "targets": []any{ diff --git a/boot/build/stages/linking_test.go b/boot/build/stages/linking_test.go index 67060356c..403cf8067 100644 --- a/boot/build/stages/linking_test.go +++ b/boot/build/stages/linking_test.go @@ -40,9 +40,8 @@ func TestLink_WithDefault(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify value was set @@ -88,9 +87,8 @@ func TestLink_FromDependency(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify dependency parameter was used @@ -153,12 +151,8 @@ func TestLink_ExplicitDependenciesOverride(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - // Explicit dependencies are linked without being resident, so the fixture - // declares their provenance too. - linkFixtureProvenance(prov, &explicitDeps) - stage := Link(prov, WithDependencies(explicitDeps)) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithDependencies(explicitDeps)) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "vendor.module", "service") @@ -217,9 +211,8 @@ func TestLink_ConflictError(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) // Linking stage now logs warnings instead of returning errors require.NoError(t, err) } @@ -274,9 +267,8 @@ func TestLink_ModuleScopedParameters(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "vendor.a", "endpoint") @@ -302,11 +294,9 @@ func TestLink_BareParameterDoesNotCrossDottedModuleMeta(t *testing.T) { }), }, { - ID: registry.NewID("userspace.dataflow", "target_db"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{ - "module": "wippy/dataflow", - }, + ID: registry.NewID("userspace.dataflow", "target_db"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/dataflow"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{ @@ -317,11 +307,9 @@ func TestLink_BareParameterDoesNotCrossDottedModuleMeta(t *testing.T) { }), }, { - ID: registry.NewID("other.bundle", "target_db"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{ - "module": "other/module", - }, + ID: registry.NewID("other.bundle", "target_db"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "other/module"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{ @@ -347,9 +335,8 @@ func TestLink_BareParameterDoesNotCrossDottedModuleMeta(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target1 := findEntry(entries, "app", "db1") @@ -388,9 +375,9 @@ func TestLink_BareParametersWithSameNameAreScopedToDependencyComponent(t *testin }), }, { - ID: registry.NewID("wippy.views", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "wippy/views"}, + ID: registry.NewID("wippy.views", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/views"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "pages_endpoint", "path": ".meta.router"}, @@ -405,9 +392,8 @@ func TestLink_BareParametersWithSameNameAreScopedToDependencyComponent(t *testin }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"wippy/views"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"wippy/views"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "wippy.views", "pages_endpoint") @@ -416,12 +402,11 @@ func TestLink_BareParametersWithSameNameAreScopedToDependencyComponent(t *testin } // TestLink_DuplicateDependencyParametersForSameRequirementConflict verifies -// that two dependencies of equal provenance resolving the same concrete +// that two equally ranked dependencies resolving the same concrete // requirement id to different values conflict rather than silently picking one. -// Both dependencies are root dependencies (no meta.module), so the deps merge -// layer applies no precedence and both parameters reach the linker; the linker -// is provenance-blind, so the disagreement surfaces as a conflict and, under -// strict enforcement for that module, leaves the requirement unresolved. +// Both dependency declarations are roots, so the merge layer applies no +// precedence and both parameters reach the linker. Under strict enforcement, +// the disagreement leaves the requirement unresolved. func TestLink_DuplicateDependencyParametersForSameRequirementConflict(t *testing.T) { ctx, _ := setupTestContext() @@ -447,9 +432,9 @@ func TestLink_DuplicateDependencyParametersForSameRequirementConflict(t *testing }), }, { - ID: registry.NewID("wippy.bootloader", "env_storage"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "wippy/bootloader"}, + ID: registry.NewID("wippy.bootloader", "env_storage"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/bootloader"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "env_loader", "path": ".meta.storage"}, @@ -464,9 +449,8 @@ func TestLink_DuplicateDependencyParametersForSameRequirementConflict(t *testing }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"wippy/bootloader"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"wippy/bootloader"})) + err := executeLinkFixture(ctx, &entries, stage) require.Error(t, err) assert.ErrorContains(t, err, "parameter conflict") assert.ErrorContains(t, err, "env_storage=app.env:store") @@ -494,9 +478,9 @@ func TestLink_FullAndBareParametersForSameRequirementConflict(t *testing.T) { }), }, { - ID: registry.NewID("wippy.views", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "wippy/views"}, + ID: registry.NewID("wippy.views", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/views"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "pages_endpoint", "path": ".meta.router"}, @@ -511,9 +495,8 @@ func TestLink_FullAndBareParametersForSameRequirementConflict(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"wippy/views"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"wippy/views"})) + err := executeLinkFixture(ctx, &entries, stage) require.Error(t, err) assert.ErrorContains(t, err, "parameter conflict") assert.ErrorContains(t, err, "api_router=app:api.views") @@ -551,9 +534,9 @@ func TestLink_FullyQualifiedParameterDoesNotCrossRequirementNamespace(t *testing }), }, { - ID: registry.NewID("wippy.facade", "router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "wippy/facade"}, + ID: registry.NewID("wippy.facade", "router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/facade"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{ @@ -564,9 +547,9 @@ func TestLink_FullyQualifiedParameterDoesNotCrossRequirementNamespace(t *testing }), }, { - ID: registry.NewID("wippy.dummy", "router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "wippy/dummy"}, + ID: registry.NewID("wippy.dummy", "router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "wippy/dummy"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{ @@ -590,9 +573,8 @@ func TestLink_FullyQualifiedParameterDoesNotCrossRequirementNamespace(t *testing }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) facadeEndpoint := findEntry(entries, "wippy.facade", "facade_endpoint") @@ -625,9 +607,9 @@ func TestLink_SameBareNameOwnedRequirementsResolveByFullID(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.projections", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.projections", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "proj_endpoint", "path": ".meta.router"}, @@ -635,9 +617,9 @@ func TestLink_SameBareNameOwnedRequirementsResolveByFullID(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.retention", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.retention", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "ret_endpoint", "path": ".meta.router"}, @@ -658,9 +640,8 @@ func TestLink_SameBareNameOwnedRequirementsResolveByFullID(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"kickside/core"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"kickside/core"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) proj := findEntry(entries, "kickside.core.projections", "proj_endpoint") @@ -691,9 +672,9 @@ func TestLink_BareNameFansOutToAllOwnedRequirements(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.projections", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.projections", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "proj_endpoint", "path": ".meta.router"}, @@ -701,9 +682,9 @@ func TestLink_BareNameFansOutToAllOwnedRequirements(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.retention", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.retention", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "ret_endpoint", "path": ".meta.router"}, @@ -724,9 +705,8 @@ func TestLink_BareNameFansOutToAllOwnedRequirements(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"kickside/core"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"kickside/core"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) proj := findEntry(entries, "kickside.core.projections", "proj_endpoint") @@ -755,9 +735,9 @@ func TestLink_ThreeWayBareNameFansOut(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.projections", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.projections", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "proj_endpoint", "path": ".meta.router"}, @@ -765,9 +745,9 @@ func TestLink_ThreeWayBareNameFansOut(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.retention", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.retention", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "ret_endpoint", "path": ".meta.router"}, @@ -775,9 +755,9 @@ func TestLink_ThreeWayBareNameFansOut(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.threads", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.threads", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "threads_endpoint", "path": ".meta.router"}, @@ -804,9 +784,8 @@ func TestLink_ThreeWayBareNameFansOut(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"kickside/core"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"kickside/core"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) for _, tc := range []struct{ ns, entry string }{ @@ -839,9 +818,9 @@ func TestLink_BareFanOutWithConflictingFullIDValueFails(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.projections", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.projections", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "proj_endpoint", "path": ".meta.router"}, @@ -849,9 +828,9 @@ func TestLink_BareFanOutWithConflictingFullIDValueFails(t *testing.T) { }), }, { - ID: registry.NewID("kickside.core.retention", "api_router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "kickside/core"}, + ID: registry.NewID("kickside.core.retention", "api_router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "kickside/core"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "ret_endpoint", "path": ".meta.router"}, @@ -877,9 +856,8 @@ func TestLink_BareFanOutWithConflictingFullIDValueFails(t *testing.T) { ctx, _ := setupTestContext() entries := build("app:other") - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"kickside/core"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"kickside/core"})) + err := executeLinkFixture(ctx, &entries, stage) require.Error(t, err) assert.ErrorContains(t, err, "parameter conflict") assert.ErrorContains(t, err, "api_router=app:router") @@ -895,9 +873,8 @@ func TestLink_BareFanOutWithConflictingFullIDValueFails(t *testing.T) { ctx, _ := setupTestContext() entries := build("app:router") - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"kickside/core"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"kickside/core"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) proj := findEntry(entries, "kickside.core.projections", "proj_endpoint") @@ -933,9 +910,8 @@ func TestLink_NoValueError(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) // Linking stage now logs warnings instead of returning errors require.NoError(t, err) } @@ -963,9 +939,8 @@ func TestLink_StrictRequirementsFailsOnMissingValue(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirements()) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirements()) + err := executeLinkFixture(ctx, &entries, stage) require.ErrorContains(t, err, "unresolved requirements") } @@ -996,9 +971,8 @@ func TestLink_StrictRequirementsFailsOnMissingTarget(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirements()) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirements()) + err := executeLinkFixture(ctx, &entries, stage) require.ErrorContains(t, err, "unresolved requirements") require.ErrorContains(t, err, "telegram.handler:webhook_endpoint") } @@ -1020,11 +994,9 @@ func TestLink_StrictRequirementModulesIgnoresUnrelatedRequirements(t *testing.T) }), }, { - ID: registry.NewID("acme.module", "router"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{ - "module": "acme/module", - }, + ID: registry.NewID("acme.module", "router"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "acme/module"}, Data: payload.New(map[string]any{ "default": "app:api", "targets": []any{ @@ -1043,9 +1015,8 @@ func TestLink_StrictRequirementModulesIgnoresUnrelatedRequirements(t *testing.T) }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"acme/module"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"acme/module"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) endpoint := findEntry(entries, "acme.module", "endpoint") @@ -1071,9 +1042,8 @@ func TestLink_StrictRequirementModulesEmptyScopeDoesNotFailAppRequirements(t *te }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules(nil)) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules(nil)) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) } @@ -1103,9 +1073,8 @@ func TestLink_AppendOperator(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify value was appended @@ -1140,9 +1109,8 @@ func TestLink_SetValue(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify value was set @@ -1176,9 +1144,8 @@ func TestLink_EmptyEntryError(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) // Linking stage now logs warnings instead of returning errors require.NoError(t, err) } @@ -1207,9 +1174,8 @@ func TestLink_CrossNamespace(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify value was set in different namespace @@ -1253,9 +1219,8 @@ func TestLink_MultipleTargets(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify both targets were updated @@ -1295,9 +1260,8 @@ func TestLink_BarePath(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify bare path was treated as data.default @@ -1332,9 +1296,8 @@ func TestLink_MetaPath(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify meta field was set @@ -1380,9 +1343,8 @@ func TestLink_MultipleRequirements(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) // Verify both requirements were applied @@ -1401,9 +1363,9 @@ func TestLink_EmptyDefaultResolvesUnderStrict(t *testing.T) { entries := []registry.Entry{ { - ID: registry.NewID("acme.module", "opt"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "acme/module"}, + ID: registry.NewID("acme.module", "opt"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "acme/module"}, Data: payload.New(map[string]any{ "default": "", // present but empty: optional, resolves to "" "targets": []any{ @@ -1419,9 +1381,8 @@ func TestLink_EmptyDefaultResolvesUnderStrict(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"acme/module"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"acme/module"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) endpoint := findEntry(entries, "acme.module", "endpoint") @@ -1447,9 +1408,9 @@ func TestLink_EmptyProvidedValueResolvesUnderStrict(t *testing.T) { }), }, { - ID: registry.NewID("vendor.module", "opt"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "vendor/module"}, + ID: registry.NewID("vendor.module", "opt"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "vendor/module"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "endpoint", "path": ".meta.opt"}, @@ -1464,9 +1425,8 @@ func TestLink_EmptyProvidedValueResolvesUnderStrict(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"vendor/module"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"vendor/module"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) } @@ -1497,9 +1457,8 @@ func TestLink_IntDefaultLandsAsInt(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "test", "service") @@ -1540,9 +1499,8 @@ func TestLink_StringDefaultStaysString(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "test", "service") @@ -1584,9 +1542,8 @@ func TestLink_BoolAndFloatDefaults(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "test", "service") @@ -1628,9 +1585,8 @@ func TestLink_TypedDependencyParameter(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "vendor.module", "service") @@ -1666,9 +1622,9 @@ func TestLink_TypedParameterSameValueNoConflict(t *testing.T) { }), }, { - ID: registry.NewID("vendor.module", "workers"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "vendor/module"}, + ID: registry.NewID("vendor.module", "workers"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "vendor/module"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "service", "path": ".workers"}, @@ -1682,9 +1638,8 @@ func TestLink_TypedParameterSameValueNoConflict(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"vendor/module"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"vendor/module"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "vendor.module", "service") @@ -1721,9 +1676,9 @@ func TestLink_TypedParameterConflictLeavesUnresolved(t *testing.T) { }), }, { - ID: registry.NewID("vendor.module", "workers"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "vendor/module"}, + ID: registry.NewID("vendor.module", "workers"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "vendor/module"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "service", "path": ".workers"}, @@ -1737,9 +1692,8 @@ func TestLink_TypedParameterConflictLeavesUnresolved(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"vendor/module"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"vendor/module"})) + err := executeLinkFixture(ctx, &entries, stage) require.Error(t, err) assert.ErrorContains(t, err, "workers=4") assert.ErrorContains(t, err, "workers=8") @@ -1775,9 +1729,8 @@ func TestLink_NullDefaultLeavesMandatory(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirements()) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirements()) + err := executeLinkFixture(ctx, &entries, stage) require.ErrorContains(t, err, "unresolved requirements") } @@ -1807,9 +1760,8 @@ func TestLink_PlaceholderDefaultPassesThroughUntouched(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "test", "service") @@ -1844,9 +1796,8 @@ func TestLink_AppendTypedValue(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) target := findEntry(entries, "test", "service") @@ -1903,9 +1854,8 @@ func TestLink_BareParameterResolvesWithinOwnModule(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link() + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) service := findEntry(entries, "vendor.alpha", "service") @@ -1960,8 +1910,7 @@ func TestLink_NormalizationDeterministicAcrossShuffledOrderings(t *testing.T) { for i, idx := range order { entries[i] = base[idx] } - prov := registry.ProvenanceMap{} - err := executeLinkFixture(ctx, &entries, Link(prov), prov) + err := executeLinkFixture(ctx, &entries, Link()) require.NoError(t, err) service := findEntry(entries, "vendor.alpha", "service") require.NotNil(t, service) @@ -2013,8 +1962,7 @@ func TestLink_TwoRequirementsSameTargetApplyDeterministically(t *testing.T) { for i, idx := range order { entries[i] = base[idx] } - prov := registry.ProvenanceMap{} - err := executeLinkFixture(ctx, &entries, Link(prov), prov) + err := executeLinkFixture(ctx, &entries, Link()) require.NoError(t, err) service := findEntry(entries, "test", "service") require.NotNil(t, service) @@ -2031,10 +1979,9 @@ func TestLink_RootParameterBeatsTransitiveBareAliasSpelling(t *testing.T) { entries := []registry.Entry{ { - ID: registry.NewID("app.deps", "telegram_root"), - Kind: registry.NamespaceDependency, - Meta: map[string]any{"module": "acme/app"}, - DependencyRoot: true, + ID: registry.NewID("app.deps", "telegram_root"), + Kind: registry.NamespaceDependency, + Registry: registry.EntryMetadata{Owner: "acme/app", Root: true}, Data: payload.New(map[string]any{ "component": "butschster/telegram", "parameters": []any{ @@ -2043,9 +1990,9 @@ func TestLink_RootParameterBeatsTransitiveBareAliasSpelling(t *testing.T) { }), }, { - ID: registry.NewID("app", "dep.telegram"), - Kind: registry.NamespaceDependency, - Meta: map[string]any{"module": "wippy/migration"}, + ID: registry.NewID("app", "dep.telegram"), + Kind: registry.NamespaceDependency, + Registry: registry.EntryMetadata{Owner: "wippy/migration"}, Data: payload.New(map[string]any{ "component": "butschster/telegram", "parameters": []any{ @@ -2054,9 +2001,9 @@ func TestLink_RootParameterBeatsTransitiveBareAliasSpelling(t *testing.T) { }), }, { - ID: registry.NewID("telegram", "env_storage"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "butschster/telegram"}, + ID: registry.NewID("telegram", "env_storage"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "butschster/telegram"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "telegram:bot_token", "path": ".storage"}, @@ -2070,9 +2017,8 @@ func TestLink_RootParameterBeatsTransitiveBareAliasSpelling(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"butschster/telegram"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"butschster/telegram"})) + err := executeLinkFixture(ctx, &entries, stage) require.NoError(t, err) botToken := findEntry(entries, "telegram", "bot_token") @@ -2082,7 +2028,7 @@ func TestLink_RootParameterBeatsTransitiveBareAliasSpelling(t *testing.T) { } // TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts verifies that two -// transitive dependencies (provenance-equal) resolving the same concrete +// transitive dependencies resolving the same concrete // requirement to different values still conflict fail-loud: precedence only // resolves root-vs-transitive, never transitive-vs-transitive. func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { @@ -2090,9 +2036,9 @@ func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { entries := []registry.Entry{ { - ID: registry.NewID("app", "dep.telegram.a"), - Kind: registry.NamespaceDependency, - Meta: map[string]any{"module": "wippy/migration"}, + ID: registry.NewID("app", "dep.telegram.a"), + Kind: registry.NamespaceDependency, + Registry: registry.EntryMetadata{Owner: "wippy/migration"}, Data: payload.New(map[string]any{ "component": "butschster/telegram", "parameters": []any{ @@ -2101,9 +2047,9 @@ func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { }), }, { - ID: registry.NewID("app", "dep.telegram.b"), - Kind: registry.NamespaceDependency, - Meta: map[string]any{"module": "wippy/other"}, + ID: registry.NewID("app", "dep.telegram.b"), + Kind: registry.NamespaceDependency, + Registry: registry.EntryMetadata{Owner: "wippy/other"}, Data: payload.New(map[string]any{ "component": "butschster/telegram", "parameters": []any{ @@ -2112,9 +2058,9 @@ func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { }), }, { - ID: registry.NewID("telegram", "env_storage"), - Kind: registry.NamespaceRequirement, - Meta: map[string]any{"module": "butschster/telegram"}, + ID: registry.NewID("telegram", "env_storage"), + Kind: registry.NamespaceRequirement, + Registry: registry.EntryMetadata{Owner: "butschster/telegram"}, Data: payload.New(map[string]any{ "targets": []any{ map[string]any{"entry": "telegram:bot_token", "path": ".storage"}, @@ -2128,9 +2074,8 @@ func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { }, } - prov := registry.ProvenanceMap{} - stage := Link(prov, WithStrictRequirementModules([]string{"butschster/telegram"})) - err := executeLinkFixture(ctx, &entries, stage, prov) + stage := Link(WithStrictRequirementModules([]string{"butschster/telegram"})) + err := executeLinkFixture(ctx, &entries, stage) require.Error(t, err) assert.ErrorContains(t, err, "parameter conflict") assert.ErrorContains(t, err, "env_storage=app:env_a") @@ -2149,13 +2094,13 @@ func TestLink_TransitiveOnlyDuplicateForSameRequirementConflicts(t *testing.T) { // attach before Link executes. Older value-resolution tests construct flattened // registry slices directly; namespace-contract tests build definitions // explicitly and call Execute without this helper. -func executeLinkFixture(ctx context.Context, entries *[]registry.Entry, stage boot.Stage, prov registry.ProvenanceMap) error { +func executeLinkFixture(ctx context.Context, entries *[]registry.Entry, stage boot.Stage) error { definitions := make(map[string]struct{}) for _, entry := range *entries { if entry.Kind != registry.NamespaceDefinition { continue } - if module := fixtureEntryModule(entry); module != "" { + if module := entry.Registry.Owner; module != "" { definitions[module] = struct{}{} } } @@ -2181,19 +2126,18 @@ func executeLinkFixture(ctx context.Context, entries *[]registry.Entry, stage bo } namespace := fixtureModuleNamespace(*entries, component) *entries = append(*entries, registry.Entry{ - ID: registry.NewID(namespace, "definition"), - Kind: registry.NamespaceDefinition, - Meta: map[string]any{"module": component}, + ID: registry.NewID(namespace, "definition"), + Kind: registry.NamespaceDefinition, + Registry: registry.EntryMetadata{Owner: component}, }) } - linkFixtureProvenance(prov, entries) return stage.Execute(ctx, entries) } func fixtureModuleNamespace(entries []registry.Entry, component string) string { var namespaces []string for _, entry := range entries { - if entry.Kind == registry.NamespaceRequirement && fixtureEntryModule(entry) == component { + if entry.Kind == registry.NamespaceRequirement && entry.Registry.Owner == component { namespaces = append(namespaces, entry.ID.NS) } } diff --git a/boot/components/core/registry.go b/boot/components/core/registry.go index bb8869638..12e8f1520 100644 --- a/boot/components/core/registry.go +++ b/boot/components/core/registry.go @@ -124,6 +124,9 @@ func Registry() boot.Component { if err != nil { logger.Warn("dependency handler disabled", zap.Error(err)) } else if depHandler != nil { + if err := depHandler.PrepareRestore(ctx, hist); err != nil { + return nil, fmt.Errorf("prepare dependency restore: %w", err) + } registryOpts = append(registryOpts, registry.WithKindDirective(regapi.NamespaceDependency, regexp.NewDependencyDirective(depHandler.Expand).WithResolutionTransition(depHandler.ReconcileResolution).WithChangesExpansion(depHandler.ExpandChanges)), ) diff --git a/boot/deps/artifact/directory.go b/boot/deps/artifact/directory.go index 49a4fbd5b..a8a8e1459 100644 --- a/boot/deps/artifact/directory.go +++ b/boot/deps/artifact/directory.go @@ -16,12 +16,11 @@ import ( ) // DirectoryResources resolves artifact declarations from selected local -// fs.directory entries. Ownership comes from the state's provenance, which is -// total over its entries. moduleRoots is the authoritative source root for each +// fs.directory entries. moduleRoots is the authoritative source root for each // local module; entries outside that set are ignored. func DirectoryResources( ctx context.Context, - state regapi.ProvenancedState, + entries regapi.State, moduleRoots map[string]string, moduleVersions map[string]string, ) ([]Resource, error) { @@ -34,12 +33,8 @@ func DirectoryResources( } resources := make([]Resource, 0) - for _, entry := range state.Entries { - record, known := state.Provenance[entry.ID] - if !known { - return nil, regapi.NewMissingProvenanceError(entry.ID) - } - module := record.Module + for _, entry := range entries { + module := entry.Registry.Owner moduleRoot, selected := moduleRoots[module] if !selected || entry.Kind != dirapi.Kind { continue diff --git a/boot/deps/artifact/directory_test.go b/boot/deps/artifact/directory_test.go index 433c904b4..7658a4c65 100644 --- a/boot/deps/artifact/directory_test.go +++ b/boot/deps/artifact/directory_test.go @@ -3,78 +3,71 @@ package artifact import ( - "context" - "os" - "path/filepath" + "errors" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wippyai/runtime/api/attrs" ctxapi "github.com/wippyai/runtime/api/context" "github.com/wippyai/runtime/api/payload" - regapi "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/api/registry" dirapi "github.com/wippyai/runtime/api/service/fs/directory" - syspayload "github.com/wippyai/runtime/system/payload" - jsonpayload "github.com/wippyai/runtime/system/payload/json" ) -func directoryTestContext() context.Context { - transcoder := syspayload.NewTranscoder() - jsonpayload.Register(transcoder) - return payload.WithTranscoder(ctxapi.NewRootContext(), transcoder) -} - -func directoryEntry(id regapi.ID) regapi.Entry { - return regapi.Entry{ - ID: id, - Kind: dirapi.Kind, - Meta: attrs.NewBagFrom(map[string]any{ - "artifact": map[string]any{"format": "node-package"}, - }), - Data: payload.New(map[string]any{"directory": "dist", "base": dirapi.BaseModule}), - } -} - -func TestDirectoryResources_SelectsByProvenance(t *testing.T) { +func TestDirectoryResourcesUsesRegistryOwner(t *testing.T) { root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, "dist"), 0o755)) - - selected := directoryEntry(regapi.NewID("example.package", "artifact")) - unrelated := directoryEntry(regapi.NewID("other.package", "artifact")) - - state := regapi.ProvenancedState{ - Entries: regapi.State{selected, unrelated}, - Provenance: regapi.ProvenanceMap{ - selected.ID: {Module: "example/package", Version: "1.0.0"}, - unrelated.ID: {Module: "other/package", Version: "2.0.0"}, + ctx := payload.WithTranscoder(ctxapi.NewRootContext(), directoryTranscoder{}) + meta := attrs.NewBagFrom(map[string]any{ + "module": "wrong/module", + "artifact": map[string]any{ + "format": "node-package/v1", + }, + }) + entries := registry.State{ + { + ID: registry.NewID("app", "selected"), + Kind: dirapi.Kind, + Meta: meta, + Registry: registry.EntryMetadata{Owner: "org/selected"}, + Data: payload.New(dirapi.Config{ + Directory: root, + }), + }, + { + ID: registry.NewID("app", "unowned"), + Kind: dirapi.Kind, + Meta: meta, + Data: payload.New(dirapi.Config{ + Directory: root, + }), }, } - resources, err := DirectoryResources( - directoryTestContext(), - state, - map[string]string{"example/package": root}, - map[string]string{"example/package": "1.0.0"}, + resources, err := DirectoryResources(ctx, entries, + map[string]string{"org/selected": root}, + map[string]string{"org/selected": "1.2.3"}, ) require.NoError(t, err) require.Len(t, resources, 1) - assert.Equal(t, filepath.Join(root, "dist"), resources[0].Source) - assert.Equal(t, "1.0.0", resources[0].ModuleVersion) + require.Equal(t, "app:selected", resources[0].ResourceID.String()) + require.Equal(t, "1.2.3", resources[0].ModuleVersion) } -func TestDirectoryResources_MissingProvenanceFailsLoud(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, "dist"), 0o755)) +type directoryTranscoder struct{} - entry := directoryEntry(regapi.NewID("example.package", "artifact")) - state := regapi.ProvenancedState{Entries: regapi.State{entry}, Provenance: regapi.ProvenanceMap{}} +func (directoryTranscoder) Unmarshal(value payload.Payload, target any) error { + config, ok := target.(*dirapi.Config) + if !ok { + return errors.New("unexpected target") + } + source, ok := value.Data().(dirapi.Config) + if !ok { + return errors.New("unexpected payload") + } + *config = source + return nil +} - _, err := DirectoryResources( - directoryTestContext(), - state, - map[string]string{"example/package": root}, - map[string]string{"example/package": "1.0.0"}, - ) - require.ErrorIs(t, err, regapi.ErrMissingProvenance) +func (directoryTranscoder) Transcode(value payload.Payload, _ payload.Format) (payload.Payload, error) { + return value, nil } diff --git a/boot/deps/hub/artifact_effect.go b/boot/deps/hub/artifact_effect.go index 7137b93d4..8d9755848 100644 --- a/boot/deps/hub/artifact_effect.go +++ b/boot/deps/hub/artifact_effect.go @@ -17,7 +17,7 @@ var _ regapi.FinalizingEffect = (*artifact.Effect)(nil) func (h *DependencyHandler) buildArtifactEffect( ctx context.Context, resolved []ResolvedModule, - state regapi.ProvenancedState, + state regapi.State, ) (regapi.Effect, error) { if h == nil || h.artifacts == nil { return nil, nil @@ -55,7 +55,7 @@ func (h *DependencyHandler) buildArtifactEffect( func (h *DependencyHandler) replacementArtifactResources( ctx context.Context, - state regapi.ProvenancedState, + state regapi.State, versions map[string]string, ) ([]artifact.Resource, error) { if len(versions) == 0 { diff --git a/boot/deps/hub/artifact_effect_test.go b/boot/deps/hub/artifact_effect_test.go index 7d00793ac..23123de27 100644 --- a/boot/deps/hub/artifact_effect_test.go +++ b/boot/deps/hub/artifact_effect_test.go @@ -44,7 +44,7 @@ func TestBuildArtifactEffectMaterializesVerifiedResolvedWAPP(t *testing.T) { Org: name.Organization, Name: name.Module, Version: "1.0.0", - }}, regapi.ProvenancedState{}) + }}, nil) if err != nil { t.Fatalf("build artifact effect: %v", err) } @@ -102,11 +102,11 @@ func TestBuildArtifactEffectMaterializesLocalReplacement(t *testing.T) { logger: zap.NewNop(), } state := regapi.State{{ - ID: regapi.NewID("example.package", "artifact"), - Kind: dirapi.Kind, + ID: regapi.NewID("example.package", "artifact"), + Kind: dirapi.Kind, + Registry: regapi.EntryMetadata{Owner: "example/package"}, Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "example/package", - "artifact": map[string]any{"format": "node-package"}, + "artifact": map[string]any{"format": "node-package"}, }), Data: payload.New(map[string]any{ "directory": ".", @@ -118,7 +118,7 @@ func TestBuildArtifactEffectMaterializesLocalReplacement(t *testing.T) { Name: "package", Version: "1.0.0", Source: moduleSourceReplacementTreeV1, - }}, fixtureState(state)) + }}, state) if err != nil { t.Fatalf("build artifact effect: %v", err) } @@ -163,7 +163,7 @@ func TestBuildArtifactEffectRemovesOutputsWhenGraphIsEmpty(t *testing.T) { replacements: map[string]lock.Replacement{}, logger: zap.NewNop(), } - effect, err := handler.buildArtifactEffect(context.Background(), nil, regapi.ProvenancedState{}) + effect, err := handler.buildArtifactEffect(context.Background(), nil, nil) if err != nil { t.Fatal(err) } @@ -203,7 +203,7 @@ func TestBuildArtifactEffectReconcilesInstallUpdateAndUninstall(t *testing.T) { } apply := func(resolved []ResolvedModule) { t.Helper() - effect, err := handler.buildArtifactEffect(context.Background(), resolved, regapi.ProvenancedState{}) + effect, err := handler.buildArtifactEffect(context.Background(), resolved, nil) if err != nil { t.Fatal(err) } diff --git a/boot/deps/hub/dependency_boot_test.go b/boot/deps/hub/dependency_boot_test.go index a62e3b8aa..2339cd4be 100644 --- a/boot/deps/hub/dependency_boot_test.go +++ b/boot/deps/hub/dependency_boot_test.go @@ -14,10 +14,11 @@ import ( "testing" "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/attrs" + moduleapi "github.com/wippyai/runtime/api/modules" "github.com/wippyai/runtime/api/payload" regapi "github.com/wippyai/runtime/api/registry" embedapi "github.com/wippyai/runtime/api/service/fs/embed" + "github.com/wippyai/runtime/boot/deps/graph" "github.com/wippyai/runtime/internal/version" embedpkg "github.com/wippyai/runtime/service/fs/embed" registryimpl "github.com/wippyai/runtime/system/registry" @@ -34,6 +35,128 @@ type bootRecordingRunner struct { transitions []regapi.ChangeSet } +func TestDependencyHandlerPrepareRestoreMaterializesRecordedArtifacts(t *testing.T) { + artifact := buildWappBytes(t, []wapp.Entry{{ + ID: wapp.NewID("acme.worker", "service"), Kind: regapi.EntryKind, + }}) + sum := sha256.Sum256(artifact) + digest := "sha256:" + hex.EncodeToString(sum[:]) + downloads := 0 + client := &fakeHub{ + getDownload: func(_ context.Context, params *DownloadParams) (*DownloadInfo, error) { + require.Equal(t, "acme", params.Org) + require.Equal(t, "worker", params.Module) + require.Equal(t, "1.2.3", params.Version) + return &DownloadInfo{URL: "memory://worker", Digest: digest, Size: uint64(len(artifact))}, nil + }, + downloadFile: func(_ context.Context, _ string, destination string) error { + downloads++ + return os.WriteFile(destination, artifact, 0o600) + }, + } + vendorDir := t.TempDir() + handler, err := NewDependencyHandler(DependencyHandlerOptions{ + Hub: client, Logger: zap.NewNop(), VendorDir: vendorDir, + }) + require.NoError(t, err) + + history := historymem.New() + root, err := history.GetVersion(regapi.RootVersion) + require.NoError(t, err) + head := version.FromParent(root, 1) + resolution := (®api.DependencyResolution{ + Roots: []regapi.DependencyRoot{{ + ID: "app.deps:worker", Component: "acme/worker", Version: "1.2.3", + }}, + Modules: []regapi.ResolvedModule{{ + Name: "acme/worker", Version: "1.2.3", Source: moduleSourceHub, + Digest: digest, SizeBytes: uint64(len(artifact)), + }}, + Deployment: ®api.Deployment{ + Root: "acme/worker", + Modules: []regapi.ResolvedModule{{ + Name: "acme/worker", Version: "1.2.3", Source: moduleSourceHub, + Digest: digest, SizeBytes: uint64(len(artifact)), + }}, + }, + }).Canonical() + require.NoError(t, history.SaveWithDependencyResolution(head, nil, resolution, true)) + + ctx := moduleapi.WithSourceRegistry(newTestContext(), moduleapi.NewSourceRegistry()) + require.NoError(t, handler.PrepareRestore(ctx, history)) + require.Equal(t, 1, downloads) + require.NoError(t, handler.PrepareRestore(ctx, history)) + require.Equal(t, 1, downloads, "verified cache must make repeated boot preparation idempotent") + sources := moduleapi.GetSourceRegistry(ctx).Snapshot() + require.Len(t, sources, 1) + require.True(t, sources["acme/worker"].DeploymentRoot) + + path, err := handler.immutableArtifactPath(graph.Name{Organization: "acme", Module: "worker"}, "1.2.3", digest) + require.NoError(t, err) + stored, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, artifact, stored) +} + +func TestDependencyHandlerPrepareRestoreIgnoresEmptyHistory(t *testing.T) { + handler, err := NewDependencyHandler(DependencyHandlerOptions{ + Hub: &fakeHub{}, Logger: zap.NewNop(), VendorDir: t.TempDir(), + }) + require.NoError(t, err) + require.NoError(t, handler.PrepareRestore(t.Context(), historymem.New())) +} + +func TestDependencyHandlerPrepareRestoreKeepsUnrootedSourceLock(t *testing.T) { + artifact := buildWappBytes(t, []wapp.Entry{{ + ID: wapp.NewID("acme.worker", "service"), Kind: regapi.EntryKind, + }}) + sum := sha256.Sum256(artifact) + digest := "sha256:" + hex.EncodeToString(sum[:]) + downloads := 0 + client := &fakeHub{ + getDownload: func(_ context.Context, params *DownloadParams) (*DownloadInfo, error) { + require.Equal(t, "1.2.3", params.Version) + return &DownloadInfo{URL: "memory://worker", Digest: digest, Size: uint64(len(artifact))}, nil + }, + downloadFile: func(_ context.Context, _ string, destination string) error { + downloads++ + return os.WriteFile(destination, artifact, 0o600) + }, + } + dir := t.TempDir() + lockPath := filepath.Join(dir, "wippy.lock") + require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf(`directories: + modules: vendor +modules: + - name: acme/worker + version: 1.2.3 + hash: %s +`, strings.TrimPrefix(digest, "sha256:"))), 0o600)) + handler, err := NewDependencyHandler(DependencyHandlerOptions{ + Hub: client, Logger: zap.NewNop(), LockPath: lockPath, VendorDir: filepath.Join(dir, "vendor"), + }) + require.NoError(t, err) + + history := historymem.New() + root, err := history.GetVersion(regapi.RootVersion) + require.NoError(t, err) + head := version.FromParent(root, 1) + resolution := (®api.DependencyResolution{ + Roots: []regapi.DependencyRoot{{ + ID: "app.deps:worker", Component: "acme/worker", Version: "1.2.3", + }}, + Modules: []regapi.ResolvedModule{{ + Name: "acme/worker", Version: "1.2.3", Source: moduleSourceHub, + Digest: digest, SizeBytes: uint64(len(artifact)), + }}, + }).Canonical() + require.NoError(t, history.SaveWithDependencyResolution(head, nil, resolution, true)) + + require.NoError(t, handler.PrepareRestore(t.Context(), history)) + require.Equal(t, 1, downloads) + require.Nil(t, handler.deployment) +} + func TestDependencyHandler_DeploymentRootSelfUpdateRepairsStoredResolution(t *testing.T) { ctx := newTestContext() tmpDir := t.TempDir() @@ -64,22 +187,18 @@ modules: root: true `, selected, digests[selected])), 0o600)) } - baseline := func(selected string) regapi.ProvenancedState { + baseline := func(selected string) regapi.State { root := regapi.Entry{ ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Data: payload.New(map[string]any{"component": "acme/app", "version": selected}), + Registry: regapi.EntryMetadata{Root: true}, + Data: payload.New(map[string]any{"component": "acme/app", "version": selected}), } service := regapi.Entry{ ID: regapi.NewID("acme.app", "service"), Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/app", - fixtureModuleVersionKey: selected, - fixtureModuleDigestKey: digests[selected], - }), - Data: payload.New(map[string]any{"version": strings.TrimPrefix(selected, "v")}), + Data: payload.New(map[string]any{"version": strings.TrimPrefix(selected, "v")}), + Registry: regapi.EntryMetadata{Owner: "acme/app"}, } - return fixtureState(regapi.State{root, service}) + return regapi.State{root, service} } newHub := func(selected string, calls *int) HubClient { return &fakeHub{ @@ -113,11 +232,6 @@ modules: regexp.NewDependencyDirective(handler.Expand).WithResolutionTransition(handler.ReconcileResolution)), ) } - residentVersion := func(reg *registryimpl.Reg, id regapi.ID) string { - record, ok := reg.EntryProvenance(id) - require.True(t, ok, "every entry of the live state has a provenance record") - return record.Version - } writeLock("v1.0.0") history, err := historysqlite.NewSQLite(dbPath, zap.NewNop()) @@ -155,9 +269,9 @@ modules: updated := newRegistry(history, newHub("v2.0.0", &v2Calls), zap.New(core)) require.NoError(t, updated.LoadState(ctx, baseline("v2.0.0"), head)) require.Equal(t, 1, v2Calls, "a changed deployment baseline must resolve the final root graph once") - service, err := updated.GetEntry(regapi.NewID("acme.app", "service")) + _, err = updated.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(updated, service.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, updated, "acme/app")) _, err = updated.GetEntry(regapi.NewID("user.settings", "theme")) require.NoError(t, err, "history-owned overlays must survive a root package update") entries := logs.FilterMessage("stored dependency resolution does not match deployment baseline; resolving final declarations").All() @@ -179,16 +293,16 @@ modules: restarted := newRegistry(history, newHub("v2.0.0", &restartCalls), zap.NewNop()) require.NoError(t, restarted.LoadState(ctx, baseline("v2.0.0"), head)) require.Zero(t, restartCalls) - service, err = restarted.GetEntry(regapi.NewID("acme.app", "service")) + _, err = restarted.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(restarted, service.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, restarted, "acme/app")) require.NoError(t, restarted.ApplyVersion(ctx, version.New(regapi.RootVersion))) require.Equal(t, 1, restartCalls, "the older version is rebound to the new deployment once") _, err = restarted.GetEntry(regapi.NewID("user.settings", "theme")) require.Error(t, err) - service, err = restarted.GetEntry(regapi.NewID("acme.app", "service")) + _, err = restarted.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(restarted, service.ID), "undo must never roll back the root deployment") + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, restarted, "acme/app"), "undo must never roll back the root deployment") require.NoError(t, restarted.ApplyVersion(ctx, head)) require.Equal(t, 1, restartCalls, "redo must reuse the repaired target graph") _, err = restarted.GetEntry(regapi.NewID("user.settings", "theme")) @@ -243,11 +357,6 @@ func TestDependencyHandler_PersistedResolutionBootRollbackRedoAndLongHistory(t * registryimpl.WithKindDirective(regapi.NamespaceDependency, regexp.NewDependencyDirective(handler.Expand).WithResolutionTransition(handler.ReconcileResolution)), ) } - residentVersion := func(reg *registryimpl.Reg, id regapi.ID) string { - record, ok := reg.EntryProvenance(id) - require.True(t, ok, "every entry of the live state has a provenance record") - return record.Version - } dbPath := filepath.Join(t.TempDir(), "registry.db") history, err := historysqlite.NewSQLite(dbPath, zap.NewNop()) @@ -303,22 +412,21 @@ func TestDependencyHandler_PersistedResolutionBootRollbackRedoAndLongHistory(t * }, } restored := newRegistry(history, newHandler(offline)) - require.NoError(t, restored.LoadState(ctx, regapi.ProvenancedState{}, v2)) + require.NoError(t, restored.LoadState(ctx, nil, v2)) require.Zero(t, manifestCalls) - entry, err := restored.GetEntry(regapi.NewID("acme.crm", "service")) + _, err = restored.GetEntry(regapi.NewID("acme.crm", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(restored, entry.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, restored, "acme/crm")) // Undo and redo select stored graphs and never resolve them again. require.NoError(t, restored.ApplyVersion(ctx, v1)) - entry, err = restored.GetEntry(regapi.NewID("acme.crm", "service")) + _, err = restored.GetEntry(regapi.NewID("acme.crm", "service")) require.NoError(t, err) - require.Equal(t, "v1.0.0", residentVersion(restored, entry.ID), - "undo reconstructs the resident identity of the version it restores") + require.Equal(t, "v1.0.0", snapshotModuleVersion(t, restored, "acme/crm")) require.NoError(t, restored.ApplyVersion(ctx, v2)) - entry, err = restored.GetEntry(regapi.NewID("acme.crm", "service")) + _, err = restored.GetEntry(regapi.NewID("acme.crm", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(restored, entry.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, restored, "acme/crm")) require.Zero(t, manifestCalls) } @@ -328,15 +436,13 @@ func TestDependencyHandler_ApplyVersionReconciliationSemantics(t *testing.T) { defer func() { require.NoError(t, packRegistry.Close()) }() ctx := embedapi.WithRegistry(newTestContext(), packRegistry) reg, runner, baselineVersion, hostID := newLegacyArtifactRollbackRegistry(ctx, t) + ctx = regapi.WithRegistry(ctx, reg) addonRoot := regapi.Entry{ - ID: regapi.NewID("app.deps", "addon"), - Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/deployment", fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.New(map[string]any{"component": "acme/addon", "version": "v1.0.0"}), + ID: regapi.NewID("app.deps", "addon"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/deployment", Root: true}, + Data: payload.New(map[string]any{"component": "acme/addon", "version": "v1.0.0"}), } _, err := reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: addonRoot}}) require.NoError(t, err) @@ -382,6 +488,7 @@ func TestDependencyHandler_ApplyVersionReconciliationSemantics(t *testing.T) { defer func() { require.NoError(t, packRegistry.Close()) }() ctx := embedapi.WithRegistry(newTestContext(), packRegistry) reg, runner, baselineVersion, hostID := newLegacyArtifactRollbackRegistry(ctx, t) + ctx = regapi.WithRegistry(ctx, reg) current, err := reg.GetEntry(hostID) require.NoError(t, err) @@ -461,9 +568,7 @@ modules: require.NoError(t, err) require.Len(t, loadedRuntime, 1) legacyHost := loadedRuntime[0] - legacyHost.Meta = attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/runtime", fixtureModuleVersionKey: "v1.0.0", - }) + legacyHost.Registry.Owner = "acme/runtime" hubClient := &fakeHub{ getManifest: func(_ context.Context, org, module, _ string) (*ModuleManifest, error) { @@ -508,48 +613,61 @@ modules: ) root := regapi.Entry{ ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/deployment", fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.New(map[string]any{"component": "acme/app", "version": "v1.0.0"}), + Registry: regapi.EntryMetadata{Owner: "acme/deployment", Root: true}, + Data: payload.New(map[string]any{"component": "acme/app", "version": "v1.0.0"}), } baseline := regapi.State{ root, { ID: regapi.NewID("app.security", "admin_all_access"), Kind: "security.policy", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/deployment", fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.New(map[string]any{"allow": true}), + Data: payload.New(map[string]any{"allow": true}), + Registry: regapi.EntryMetadata{Owner: "acme/deployment"}, }, { ID: regapi.NewID("acme.app", "marker"), Kind: regapi.EntryKind, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/app", fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.New(map[string]any{"enabled": true}), + Data: payload.New(map[string]any{"enabled": true}), + Registry: regapi.EntryMetadata{Owner: "acme/app"}, }, legacyHost, } baselineVersion := version.FromParent(nil, 0) - require.NoError(t, reg.LoadState(ctx, fixtureState(baseline), baselineVersion)) - _, err = reg.GetEntry(hostID) - require.NoError(t, err) - // The fixture entry is resident from an artifact that predates recorded - // digests. The baseline reconciliation establishes its artifact identity - // from the lock, which the later subtests rely on. - hostRecord, ok := reg.EntryProvenance(hostID) - require.True(t, ok) - require.Equal(t, "acme/runtime", hostRecord.Module) - require.Equal(t, digests["runtime"], hostRecord.Digest) + require.NoError(t, reg.LoadState(ctx, baseline, baselineVersion)) + persistedHost, err := reg.GetEntry(hostID) + require.NoError(t, err) + require.Equal(t, "acme/runtime", persistedHost.Registry.Owner) runner.transitions = nil return reg, runner, baselineVersion, hostID } -type bootDirectiveFunc func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) +func snapshotModuleVersion(t *testing.T, reg regapi.Registry, module string) string { + t.Helper() + resolution := reg.Snapshot().Registry.Resolution + require.NotNil(t, resolution) + for _, selected := range resolution.Modules { + if selected.Name == module { + return selected.Version + } + } + t.Fatalf("module %q is absent from registry resolution", module) + return "" +} + +func snapshotModuleDigest(t *testing.T, reg regapi.Registry, module string) string { + t.Helper() + resolution := reg.Snapshot().Registry.Resolution + require.NotNil(t, resolution) + for _, selected := range resolution.Modules { + if selected.Name == module { + return selected.Digest + } + } + t.Fatalf("module %q is absent from registry resolution", module) + return "" +} + +type bootDirectiveFunc func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) -func (f bootDirectiveFunc) Expand(ctx context.Context, op regapi.Operation, state regapi.ProvenancedState) (regapi.DirectiveResult, error) { +func (f bootDirectiveFunc) Expand(ctx context.Context, op regapi.Operation, state regapi.State) (regapi.DirectiveResult, error) { return f(ctx, op, state) } @@ -634,7 +752,7 @@ func TestDependencyHandler_BootExpandsSourceRootBeforeUnrelatedInstall(t *testin Kind: regapi.NamespaceDependency, Data: payload.NewPayload(`{"component":"acme/analysis","version":"v1.0.0"}`, payload.JSON), } - require.NoError(t, reg.LoadState(ctx, fixtureState(regapi.State{analysisRoot}), version.FromParent(nil, 0))) + require.NoError(t, reg.LoadState(ctx, regapi.State{analysisRoot}, version.FromParent(nil, 0))) _, err = reg.GetEntry(regapi.NewID("acme.analysis", "runtime")) require.NoError(t, err, "boot must not publish a dependency root without its module entries") diff --git a/boot/deps/hub/dependency_control.go b/boot/deps/hub/dependency_control.go index e5abf2890..db56fb22c 100644 --- a/boot/deps/hub/dependency_control.go +++ b/boot/deps/hub/dependency_control.go @@ -9,36 +9,29 @@ import ( regapi "github.com/wippyai/runtime/api/registry" ) -// isRootDependency reports a deployment root. Deployment ingestion selects -// source-owned roots explicitly. A dependency authored through the registry API -// is owned by no module and is a user overlay root. Module-owned declarations -// that were not selected are transitive. -func isRootDependency(entry regapi.Entry, prov regapi.EntryProvenance) bool { - return entry.Kind == regapi.NamespaceDependency && (prov.Root || prov.HostAuthored()) +func isRootDependency(entry regapi.Entry) bool { + // Deployment ingestion marks source-owned roots explicitly. A dependency + // authored through the registry API has no module owner and is a user overlay + // root. Module-owned declarations without the root flag are transitive. + return entry.Kind == regapi.NamespaceDependency && (entry.Registry.Root || entryModule(entry) == "") } // collectControlledModules returns the dependency graph reachable from the -// state's deployment roots. A root's package owner is provenance, not part of +// state's deployment roots. A root's package owner is registry metadata, not part of // that graph: the root controls its declared component, while ordinary owned // dependencies extend the graph from owner to component. func (h *DependencyHandler) collectControlledModules( ctx context.Context, - snapshot regapi.ProvenancedState, + snapshot regapi.State, transcoder payload.Transcoder, ) (map[string]struct{}, error) { controlled := make(map[string]struct{}) dependencyLinks := make(map[string][]string) - prov, err := stateProvenance(snapshot) - if err != nil { - return nil, err - } - - for _, entry := range snapshot.Entries { + for _, entry := range snapshot { if entry.Kind != regapi.NamespaceDependency { continue } - record := prov[idKey(entry.ID)] def, err := decodeDependency(ctx, transcoder, entry) if err != nil { return nil, err @@ -47,12 +40,12 @@ func (h *DependencyHandler) collectControlledModules( return nil, NewDependencyEntryInvalidError(entry.ID.String(), "component is required", "") } - if isRootDependency(entry, record) { + if isRootDependency(entry) { controlled[def.Component] = struct{}{} continue } - if record.Module != "" { - dependencyLinks[record.Module] = append(dependencyLinks[record.Module], def.Component) + if owner := entryModule(entry); owner != "" { + dependencyLinks[owner] = append(dependencyLinks[owner], def.Component) } } @@ -78,8 +71,8 @@ func (h *DependencyHandler) collectControlledModules( // are not dependency graph members remain outside reconciliation authority. func (h *DependencyHandler) reconciliationControlledModules( ctx context.Context, - current regapi.ProvenancedState, - target regapi.ProvenancedState, + current regapi.State, + target regapi.State, transcoder payload.Transcoder, desired map[string]struct{}, ) (map[string]struct{}, error) { diff --git a/boot/deps/hub/dependency_handler.go b/boot/deps/hub/dependency_handler.go index 3a7d1ac22..233d7e7f6 100644 --- a/boot/deps/hub/dependency_handler.go +++ b/boot/deps/hub/dependency_handler.go @@ -66,6 +66,7 @@ type DependencyHandler struct { logger *zap.Logger lock *lock.Lock artifacts *artifact.Registry + deployment *regapi.Deployment artifactRoot string replacements map[string]lock.Replacement vendorDir string @@ -168,17 +169,106 @@ func NewDependencyHandler(opts DependencyHandlerOptions) (*DependencyHandler, er }, nil } -// Expand plans a dependency operation against the resident state. snapshot -// carries the provenance of that state: the registry owns it, so the handler -// never reads ownership or resident identity from entry payload. -func (h *DependencyHandler) Expand(ctx context.Context, op regapi.Operation, snapshot regapi.ProvenancedState) (regapi.DirectiveResult, error) { +// PrepareRestore materializes the exact module artifacts recorded for the +// current registry version. Version selection remains the stored resolution; +// the Hub is used only when a verified artifact is absent locally. +func (h *DependencyHandler) PrepareRestore(ctx context.Context, history regapi.History) error { + resolutions, ok := history.(regapi.ResolutionHistory) + if h == nil || !ok { + return nil + } + configured, err := h.deploymentFromLock() + if err != nil { + return err + } + head, err := history.Head() + if err != nil { + var categorized apierror.Error + if errors.As(err, &categorized) && categorized.Kind() == apierror.NotFound { + h.deployment = configured + return nil + } + return fmt.Errorf("read registry head for dependency restore: %w", err) + } + resolution, err := resolutions.GetDependencyResolution(head) + if errors.Is(err, regapi.ErrDependencyResolutionNotFound) { + h.deployment = configured + return nil + } + if err != nil { + return fmt.Errorf("read dependency resolution for restore: %w", err) + } + deployment := configured + if deployment == nil { + deployment = resolution.Deployment.Canonical() + } + effective, err := resolvedModulesFromStored(resolution) + if err != nil { + return err + } + if deployment == nil { + // A source checkout has an unrooted lock: its ordinary lock loader has + // already published the deployment sources. History may add modules, but + // it must not turn that development shape into a persisted Hub root. + if h.lock != nil { + h.deployment = nil + ctx = regapi.WithDependencyAccess(ctx, regapi.DependencyAccessOnline) + return h.materializeRestoreModules(ctx, effective) + } + return fmt.Errorf("persisted deployment baseline is unavailable; start once with its lock file") + } + h.deployment = deployment + baseline, err := resolvedModulesFromRecords(deployment.Modules) + if err != nil { + return err + } + ctx = regapi.WithDependencyAccess(ctx, regapi.DependencyAccessOnline) + if err := h.prepareRestoreSources(ctx, baseline); err != nil { + return err + } + return h.materializeRestoreModules(ctx, effective) +} + +func (h *DependencyHandler) deploymentFromLock() (*regapi.Deployment, error) { + if h == nil || h.lock == nil { + return nil, nil + } + roots := h.lock.GetRootModules() + if len(roots) != 1 { + return nil, nil + } + if len(h.replacements) != 0 { + return nil, nil + } + deployment := ®api.Deployment{Root: roots[0], Modules: make([]regapi.ResolvedModule, 0, len(h.lock.GetModules()))} + for _, module := range h.lock.GetModules() { + algorithm, digest, err := parseExpectedDigest(module.Hash) + if err != nil || algorithm != "sha256" || len(digest) != 64 { + return nil, fmt.Errorf("deployment module %s has invalid artifact digest", module.Name) + } + deployment.Modules = append(deployment.Modules, regapi.ResolvedModule{ + Name: module.Name, Version: module.Version, VersionID: module.Version, + Source: moduleSourceHub, Digest: "sha256:" + strings.ToLower(digest), + }) + } + return deployment.Canonical(), nil +} + +func (h *DependencyHandler) isDeploymentRoot(module string) bool { + if h.deployment != nil { + return module == h.deployment.Root + } + return h.lock != nil && h.lock.IsRootModule(module) +} + +func (h *DependencyHandler) Expand(ctx context.Context, op regapi.Operation, snapshot regapi.State) (regapi.DirectiveResult, error) { return h.expand(ctx, op, snapshot, nil, nil, nil) } func (h *DependencyHandler) expand( ctx context.Context, op regapi.Operation, - snapshot regapi.ProvenancedState, + snapshot regapi.State, extraControlled map[string]struct{}, extraMutable map[string]struct{}, freshRoots map[string]struct{}, @@ -190,19 +280,14 @@ func (h *DependencyHandler) expand( return regapi.DirectiveResult{}, err } - resident, err := stateProvenance(snapshot) - if err != nil { - return regapi.DirectiveResult{}, err - } - - entry, ok := resolveOperationEntry(op, snapshot.Entries) + entry, ok := resolveOperationEntry(op, snapshot) if !ok { return regapi.DirectiveResult{}, nil } if entry.Kind != regapi.NamespaceDependency { return regapi.DirectiveResult{}, nil } - if !isRootDependency(entry, operationProvenance(op, resident)) { + if !isRootDependency(entry) { return regapi.DirectiveResult{}, nil } @@ -268,7 +353,7 @@ func (h *DependencyHandler) expand( } desiredRoots := dependencyDefinitions(desiredDeps) - resolved, err := h.resolveEffectiveModules(ctx, desiredRoots, lockedVersions) + resolved, err := h.resolveEffectiveModules(ctx, desiredRoots, lockedVersions, h.currentResolution(ctx)) if err != nil { return regapi.DirectiveResult{}, err } @@ -289,10 +374,11 @@ func (h *DependencyHandler) expand( break } } + _, installedDigests := h.currentModuleIdentities(ctx) strictModules := touchedModuleIdentities( resolved, lockedVersions, - residentModuleDigests(snapshot.Provenance), + installedDigests, opComponent, ) strictSet := stringSet(strictModules) @@ -321,60 +407,40 @@ func (h *DependencyHandler) expand( desiredModules := resolvedModuleSet(resolved) addModuleSet(controlledModules, desiredModules) - moduleEntries, moduleProv, unpackPlan, err := h.loadModuleEntries(ctx, filterResolvedModules(resolved, touchedModules), resolved, snapshot, transcoder) + moduleEntries, unpackPlan, err := h.loadModuleEntries(ctx, filterResolvedModules(resolved, touchedModules), snapshot, transcoder) if err != nil { return regapi.DirectiveResult{}, err } defer func() { _ = unpackPlan.cleanup() }() linkDeps := mergeLinkDependencies(desiredDepEntries, moduleEntries) - combined := make([]regapi.Entry, 0, len(snapshot.Entries)+len(moduleEntries)) - combinedProv := make(regapi.ProvenanceMap, len(snapshot.Entries)+len(moduleEntries)) - for _, e := range snapshot.Entries { - record := resident[idKey(e.ID)] - if record.Module != "" { - if _, controlled := controlledModules[record.Module]; controlled { - if _, desired := desiredModules[record.Module]; !desired { + combined := make([]regapi.Entry, 0, len(snapshot)+len(moduleEntries)) + for _, e := range snapshot { + if module := entryModule(e); module != "" { + if _, controlled := controlledModules[module]; controlled { + if _, desired := desiredModules[module]; !desired { continue } - if _, touched := touchedModules[record.Module]; touched { + if _, touched := touchedModules[module]; touched { continue } } } combined = append(combined, e) - combinedProv[e.ID] = record } combined = append(combined, moduleEntries...) - for id, record := range moduleProv { - combinedProv[id] = record - } - // A declaration this operation introduces is linked before it is resident. - // The link stage attributes every entry it is handed, so the declarations - // carry the record the operation gives them. - for _, dep := range desiredDepEntries { - if _, known := combinedProv[dep.ID]; known { - continue - } - if record, known := resident[idKey(dep.ID)]; known { - combinedProv[dep.ID] = record - continue - } - combinedProv[dep.ID] = operationProvenance(op, resident) - } pipeline := build.New( stages.Override(stages.WithMissingOverrideEntriesIgnored()), stages.Disable(), - stages.Link(combinedProv, stages.WithDependencies(linkDeps), stages.WithStrictRequirementModules(strictModules)), + stages.Link(stages.WithDependencies(linkDeps), stages.WithStrictRequirementModules(strictModules)), stages.Override(stages.WithMissingOverrideEntriesIgnored()), ) if err := pipeline.Execute(ctx, &combined); err != nil { return regapi.DirectiveResult{}, NewDependencyPipelineError(err) } - desired := regapi.ProvenancedState{Entries: combined, Provenance: combinedProv} - additional, err := (operationPlanner{resolver: h.resolver}).plan(snapshot, desired, operationPlanOptions{ + additional, err := (operationPlanner{resolver: h.resolver}).plan(snapshot, combined, operationPlanOptions{ originalKey: idKey(op.Entry.ID), controlledModules: controlledModules, mutableModules: mutableModules, @@ -392,11 +458,11 @@ func (h *DependencyHandler) expand( } var effects []regapi.Effect - artifactEffect, err := h.buildArtifactEffect(ctx, resolved, desired) + artifactEffect, err := h.buildArtifactEffect(ctx, resolved, combined) if err != nil { return regapi.DirectiveResult{}, err } - packEffect, err := h.buildEmbedPackEffect(ctx, resolved, snapshot, controlledModules) + packEffect, err := h.buildEmbedPackEffect(ctx, resolved, controlledModules) if err != nil { return regapi.DirectiveResult{}, err } @@ -417,14 +483,13 @@ func (h *DependencyHandler) expand( // The graph describes the state this operation produces; its baseline // binding must be computed over that state, never over the one being left, // or a later version transition sees a digest that names the wrong side. - selectedResolution, err := h.resolutionForSnapshot(ctx, applyOperationToState(snapshot, op, resident), rootDeps, refDeps, resolved, transcoder) + selectedResolution, err := h.resolutionForSnapshot(ctx, applyOperationToState(snapshot, op), rootDeps, refDeps, resolved, transcoder) if err != nil { return regapi.DirectiveResult{}, err } return regapi.DirectiveResult{ Applied: true, Resolution: selectedResolution, - Provenance: residentProvenanceAdvance(snapshot, desired, additional, idKey(op.Entry.ID)), Additional: scoped, Effects: effects, }, nil @@ -434,7 +499,7 @@ func (h *DependencyHandler) expand( // transaction. Earlier operations are folded into a temporary snapshot and // the last operation drives the existing expansion path, so agents retain the // same simple entry-update surface for both single and multi-root updates. -func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.ChangeSet, snapshot regapi.ProvenancedState) (regapi.DirectiveResult, error) { +func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.ChangeSet, snapshot regapi.State) (regapi.DirectiveResult, error) { if len(changes) == 0 { return regapi.DirectiveResult{}, nil } @@ -445,19 +510,14 @@ func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.Ch if transcoder == nil { return regapi.DirectiveResult{}, ErrDependencyTranscoderMissing } - resident, err := stateProvenance(snapshot) - if err != nil { - return regapi.DirectiveResult{}, err - } // Planner batches every ns.dependency operation, including dependencies // owned by module manifests. Only authored roots drive whole-graph solving. // Filter against a rolling state so a delete is recognized from its old // entry and a create/update from its new entry. - rollingMap := make(regapi.StateMap, len(snapshot.Entries)+len(changes)) - for _, entry := range snapshot.Entries { + rollingMap := make(regapi.StateMap, len(snapshot)+len(changes)) + for _, entry := range snapshot { rollingMap[entry.ID] = entry } - rollingProv := provByKey(snapshot.Provenance) rootChanges := make(regapi.ChangeSet, 0, len(changes)) for _, op := range changes { old, hadOld := rollingMap[op.Entry.ID] @@ -465,26 +525,22 @@ func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.Ch for _, entry := range rollingMap { rolling = append(rolling, entry) } - opRecord := operationProvenance(op, rollingProv) - resolvedEntry, hasResolved := resolveOperationEntry(op, rolling) - if (hadOld && isRootDependency(old, rollingProv[idKey(old.ID)])) || - (hasResolved && isRootDependency(resolvedEntry, opRecord)) { + resolved, hasResolved := resolveOperationEntry(op, rolling) + if (hadOld && isRootDependency(old)) || (hasResolved && isRootDependency(resolved)) { rootChanges = append(rootChanges, op) } switch op.Kind { case regapi.EntryCreate, regapi.EntryUpdate: rollingMap[op.Entry.ID] = op.Entry - rollingProv[idKey(op.Entry.ID)] = opRecord case regapi.EntryDelete: delete(rollingMap, op.Entry.ID) - delete(rollingProv, idKey(op.Entry.ID)) } } if len(rootChanges) == 0 { return regapi.DirectiveResult{}, nil } - originalIDs := make(map[string]struct{}, len(snapshot.Entries)) - for _, entry := range snapshot.Entries { + originalIDs := make(map[string]struct{}, len(snapshot)) + for _, entry := range snapshot { originalIDs[idKey(entry.ID)] = struct{}{} } freshRoots := make(map[string]struct{}, len(rootChanges)) @@ -498,7 +554,7 @@ func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.Ch } } if len(rootChanges) == 1 { - driver, ok := rootExpansionDriver(rootChanges[0], snapshot, resident) + driver, ok := rootExpansionDriver(rootChanges[0], snapshot) if !ok { return regapi.DirectiveResult{}, nil } @@ -513,22 +569,14 @@ func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.Ch return regapi.DirectiveResult{}, err } extraMutable := make(map[string]struct{}) - stateMap := make(regapi.StateMap, len(snapshot.Entries)+len(changes)-1) - for _, entry := range snapshot.Entries { + stateMap := make(regapi.StateMap, len(snapshot)+len(changes)-1) + for _, entry := range snapshot { stateMap[entry.ID] = entry } - workingProv := snapshot.Provenance.Clone() - if workingProv == nil { - workingProv = make(regapi.ProvenanceMap, len(changes)) - } - workingIndex := provByKey(workingProv) for _, op := range changes[:len(changes)-1] { - rolling := regapi.ProvenancedState{ - Entries: make(regapi.State, 0, len(stateMap)), - Provenance: workingProv, - } + rolling := make(regapi.State, 0, len(stateMap)) for _, entry := range stateMap { - rolling.Entries = append(rolling.Entries, entry) + rolling = append(rolling, entry) } affected, opErr := h.operationModules(ctx, op, rolling, transcoder) if opErr != nil { @@ -537,75 +585,55 @@ func (h *DependencyHandler) ExpandChanges(ctx context.Context, changes regapi.Ch for module := range affected { extraMutable[module] = struct{}{} } - record := operationProvenance(op, workingIndex) switch op.Kind { case regapi.EntryCreate, regapi.EntryUpdate: stateMap[op.Entry.ID] = op.Entry - workingProv[op.Entry.ID] = record - workingIndex[idKey(op.Entry.ID)] = record case regapi.EntryDelete: delete(stateMap, op.Entry.ID) - delete(workingProv, op.Entry.ID) - delete(workingIndex, idKey(op.Entry.ID)) } } - working := regapi.ProvenancedState{ - Entries: make(regapi.State, 0, len(stateMap)), - Provenance: workingProv, - } + working := make(regapi.State, 0, len(stateMap)) for _, entry := range stateMap { - working.Entries = append(working.Entries, entry) + working = append(working, entry) } // A root retagged as module-owned is declaratively a root deletion. Drive // expansion with that deletion so cleanup cannot be skipped merely because // the final form is still an ns.dependency entry. - driver, ok := rootExpansionDriver(changes[len(changes)-1], working, workingIndex) + driver, ok := rootExpansionDriver(changes[len(changes)-1], working) if !ok { return regapi.DirectiveResult{}, nil } return h.expand(ctx, driver, working, extraControlled, extraMutable, freshRoots) } -// applyOperationToState materializes the state and provenance an operation -// produces, so a recorded graph can bind to the deployment identity of its own -// version. -func applyOperationToState(state regapi.ProvenancedState, op regapi.Operation, resident provIndex) regapi.ProvenancedState { - next := regapi.ProvenancedState{ - Entries: make(regapi.State, 0, len(state.Entries)+1), - Provenance: state.Provenance.Clone(), - } - if next.Provenance == nil { - next.Provenance = make(regapi.ProvenanceMap, 1) - } +// applyOperationToState materializes the state an operation produces, so a +// recorded graph can bind to the deployment identity of its own version. +func applyOperationToState(snapshot regapi.State, op regapi.Operation) regapi.State { + next := make(regapi.State, 0, len(snapshot)+1) replaced := false - for _, entry := range state.Entries { + for _, entry := range snapshot { if idsEqual(entry.ID, op.Entry.ID) { if op.Kind == regapi.EntryCreate || op.Kind == regapi.EntryUpdate { - next.Entries = append(next.Entries, op.Entry) + next = append(next, op.Entry) replaced = true - continue } - delete(next.Provenance, entry.ID) continue } - next.Entries = append(next.Entries, entry) + next = append(next, entry) } if !replaced && (op.Kind == regapi.EntryCreate || op.Kind == regapi.EntryUpdate) { - next.Entries = append(next.Entries, op.Entry) - } - if op.Kind == regapi.EntryCreate || op.Kind == regapi.EntryUpdate { - next.Provenance[op.Entry.ID] = operationProvenance(op, resident) + next = append(next, op.Entry) } return next } -func rootExpansionDriver(op regapi.Operation, state regapi.ProvenancedState, resident provIndex) (regapi.Operation, bool) { - if entry, ok := resolveOperationEntry(op, state.Entries); ok && isRootDependency(entry, operationProvenance(op, resident)) { +func rootExpansionDriver(op regapi.Operation, snapshot regapi.State) (regapi.Operation, bool) { + if entry, ok := resolveOperationEntry(op, snapshot); ok && isRootDependency(entry) { return op, true } - for _, entry := range state.Entries { - if idsEqual(entry.ID, op.Entry.ID) && isRootDependency(entry, resident[idKey(entry.ID)]) { - return regapi.Operation{Kind: regapi.EntryDelete, Entry: entry, Provenance: provenancePtr(resident[idKey(entry.ID)])}, true + for _, entry := range snapshot { + if idsEqual(entry.ID, op.Entry.ID) && isRootDependency(entry) { + return regapi.Operation{Kind: regapi.EntryDelete, Entry: entry}, true } } return regapi.Operation{}, false @@ -616,7 +644,7 @@ func rootExpansionDriver(op regapi.Operation, state regapi.ProvenancedState, res // installed, and locked versions so an unchanged module keeps its selection. func (h *DependencyHandler) refreshResolvedModules( ctx context.Context, - current regapi.ProvenancedState, + current regapi.State, transcoder payload.Transcoder, resolution *regapi.DependencyResolution, desiredDeps []desiredDependency, @@ -636,7 +664,7 @@ func (h *DependencyHandler) refreshResolvedModules( } } } - return h.resolveEffectiveModules(ctx, dependencyDefinitions(desiredDeps), lockedVersions) + return h.resolveEffectiveModules(ctx, dependencyDefinitions(desiredDeps), lockedVersions, resolution) } // ReconcileResolution materializes a previously selected graph. An unchanged @@ -646,8 +674,8 @@ func (h *DependencyHandler) refreshResolvedModules( // operation rather than one expansion per historical version. func (h *DependencyHandler) ReconcileResolution( ctx context.Context, - current regapi.ProvenancedState, - target regapi.ProvenancedState, + current regapi.State, + target regapi.State, resolution *regapi.DependencyResolution, ) (regapi.DirectiveResult, error) { if h == nil || h.hub == nil { @@ -660,10 +688,6 @@ func (h *DependencyHandler) ReconcileResolution( if transcoder == nil { return regapi.DirectiveResult{}, ErrDependencyTranscoderMissing } - targetProv, err := stateProvenance(target) - if err != nil { - return regapi.DirectiveResult{}, err - } snapshotDeps, err := h.collectSnapshotDependencies(ctx, target, transcoder) if err != nil { @@ -748,18 +772,17 @@ func (h *DependencyHandler) ReconcileResolution( if err := h.refreshReplacementModuleIdentities(resolved); err != nil { return regapi.DirectiveResult{}, err } + effectiveResolution.Deployment = h.deployment.Canonical() + effectiveResolution = effectiveResolution.Canonical() // A local replacement is a mutable development source. Reconciliation uses // its current identity to decide which resident entries must be reloaded, // while effectiveResolution remains the immutable checkpoint for this // history version. desiredModules := resolvedModuleSet(resolved) - // Reconciliation converges resident onto selected. Resident is the artifact - // identity recorded for the entries in state; selected is the stored graph, - // authoritative by content identity rather than version alone. A module whose - // resident digest differs from the selected one reloads. - installedDigests := residentModuleDigests(target.Provenance) - lockedDigests := h.lockedModuleDigests() + // A stored graph is authoritative by content identity, not merely version. + _, installedDigests := h.currentModuleIdentities(ctx) + baselineDigests := h.baselineModuleDigests() touched := make(map[string]struct{}, len(desiredModules)) mutable := make(map[string]struct{}, len(desiredModules)) parameterModules, err := changedDependencyParameterModules(ctx, current, target, transcoder) @@ -779,10 +802,7 @@ func (h *DependencyHandler) ReconcileResolution( module := mod.Org + "/" + mod.Name installedDigest := installedDigests[module] if installedDigest == "" { - // A module whose records carry no digest has no resident identity of - // its own. The exact name@version lock hash is still authoritative and - // prevents a history restore from rewriting every resident module. - installedDigest = lockedDigests[module+"@"+mod.Version] + installedDigest = baselineDigests[module+"@"+mod.Version] } if !artifactDigestsEqual(installedDigest, mod.Digest) { mutable[module] = struct{}{} @@ -796,7 +816,7 @@ func (h *DependencyHandler) ReconcileResolution( return regapi.DirectiveResult{}, err } - moduleEntries, moduleProv, unpackPlan, err := h.loadModuleEntries(ctx, filterResolvedModules(resolved, touched), resolved, target, transcoder) + moduleEntries, unpackPlan, err := h.loadModuleEntries(ctx, filterResolvedModules(resolved, touched), target, transcoder) if err != nil { return regapi.DirectiveResult{}, err } @@ -805,45 +825,31 @@ func (h *DependencyHandler) ReconcileResolution( for _, dep := range desiredDeps { desiredDepEntries = append(desiredDepEntries, dep.entry) } - combined := make([]regapi.Entry, 0, len(target.Entries)+len(moduleEntries)) - combinedProv := make(regapi.ProvenanceMap, len(target.Entries)+len(moduleEntries)) - for _, entry := range target.Entries { - record := targetProv[idKey(entry.ID)] - if record.Module != "" { - if _, dependencyOwned := controlled[record.Module]; dependencyOwned { - if _, desired := desiredModules[record.Module]; !desired { + combined := make([]regapi.Entry, 0, len(target)+len(moduleEntries)) + for _, entry := range target { + if module := entryModule(entry); module != "" { + if _, dependencyOwned := controlled[module]; dependencyOwned { + if _, desired := desiredModules[module]; !desired { continue } - if _, changed := touched[record.Module]; changed { + if _, changed := touched[module]; changed { continue } } } combined = append(combined, entry) - combinedProv[entry.ID] = record } combined = append(combined, moduleEntries...) - for id, record := range moduleProv { - combinedProv[id] = record - } - // The link stage attributes every entry it is handed, including declarations - // this reconciliation replaced with a freshly loaded copy. - for _, dep := range desiredDepEntries { - if _, known := combinedProv[dep.ID]; !known { - combinedProv[dep.ID] = targetProv[idKey(dep.ID)] - } - } pipeline := build.New( stages.Override(stages.WithMissingOverrideEntriesIgnored()), stages.Disable(), - stages.Link(combinedProv, stages.WithDependencies(mergeLinkDependencies(desiredDepEntries, moduleEntries)), stages.WithStrictRequirementModules(sortedSetKeys(touched))), + stages.Link(stages.WithDependencies(mergeLinkDependencies(desiredDepEntries, moduleEntries)), stages.WithStrictRequirementModules(sortedSetKeys(touched))), stages.Override(stages.WithMissingOverrideEntriesIgnored()), ) if err := pipeline.Execute(ctx, &combined); err != nil { return regapi.DirectiveResult{}, NewDependencyPipelineError(err) } - desired := regapi.ProvenancedState{Entries: combined, Provenance: combinedProv} // Reconciliation owns the whole graph for deletes, but only artifacts whose // content identity or authored root parameters changed are mutable. A module @@ -851,7 +857,7 @@ func (h *DependencyHandler) ReconcileResolution( // that artifact must not turn harmless normalization into registry updates // and restart unrelated services during undo/redo (including the governance // worker itself). - additional, err := (operationPlanner{resolver: h.resolver}).plan(target, desired, operationPlanOptions{ + additional, err := (operationPlanner{resolver: h.resolver}).plan(target, combined, operationPlanOptions{ controlledModules: controlled, mutableModules: mutable, }) @@ -862,12 +868,12 @@ func (h *DependencyHandler) ReconcileResolution( for _, op := range additional { scoped = append(scoped, regapi.ScopedOperation{Operation: op, Scope: regapi.ScopeBaseline}) } - packEffect, err := h.buildEmbedPackEffect(ctx, resolved, current, controlled) + packEffect, err := h.buildEmbedPackEffect(ctx, resolved, controlled) if err != nil { return regapi.DirectiveResult{}, err } var effects []regapi.Effect - artifactEffect, err := h.buildArtifactEffect(ctx, resolved, desired) + artifactEffect, err := h.buildArtifactEffect(ctx, resolved, combined) if err != nil { return regapi.DirectiveResult{}, err } @@ -887,7 +893,6 @@ func (h *DependencyHandler) ReconcileResolution( return regapi.DirectiveResult{ Applied: true, Resolution: effectiveResolution, - Provenance: residentProvenanceAdvance(target, desired, additional, ""), Additional: scoped, Effects: effects, }, nil @@ -899,8 +904,8 @@ func (h *DependencyHandler) ReconcileResolution( // module, including through a fully qualified requirement ID. func changedDependencyParameterModules( ctx context.Context, - current regapi.ProvenancedState, - target regapi.ProvenancedState, + current regapi.State, + target regapi.State, transcoder payload.Transcoder, ) (map[string]struct{}, error) { type declaration struct { @@ -908,14 +913,10 @@ func changedDependencyParameterModules( present bool } - decodeRoots := func(state regapi.ProvenancedState) (map[string]declaration, error) { - prov, err := stateProvenance(state) - if err != nil { - return nil, err - } + decodeRoots := func(state regapi.State) (map[string]declaration, error) { roots := make(map[string]declaration) - for _, entry := range state.Entries { - if !isRootDependency(entry, prov[idKey(entry.ID)]) { + for _, entry := range state { + if !isRootDependency(entry) { continue } definition, err := decodeDependency(ctx, transcoder, entry) @@ -1039,18 +1040,14 @@ func dependencyInputDigest(roots []desiredDependency) string { func (h *DependencyHandler) collectResolutionDependencies( ctx context.Context, - snapshot regapi.ProvenancedState, + snapshot regapi.State, transcoder payload.Transcoder, roots []regapi.DependencyRoot, references []regapi.DependencyRoot, ) ([]desiredDependency, []desiredDependency, error) { - prov, err := stateProvenance(snapshot) - if err != nil { - return nil, nil, err - } - byID := make(map[string]regapi.Entry, len(snapshot.Entries)) - for _, entry := range snapshot.Entries { - if isRootDependency(entry, prov[idKey(entry.ID)]) { + byID := make(map[string]regapi.Entry, len(snapshot)) + for _, entry := range snapshot { + if isRootDependency(entry) { byID[idKey(entry.ID)] = entry } } @@ -1134,21 +1131,17 @@ func (h *DependencyHandler) collectResolutionDependencies( func (h *DependencyHandler) collectSnapshotDependencies( ctx context.Context, - snapshot regapi.ProvenancedState, + snapshot regapi.State, transcoder payload.Transcoder, ) ([]desiredDependency, error) { - prov, err := stateProvenance(snapshot) - if err != nil { - return nil, err - } deps := make([]desiredDependency, 0) - for _, entry := range snapshot.Entries { - if !isRootDependency(entry, prov[idKey(entry.ID)]) { + for _, entry := range snapshot { + if !isRootDependency(entry) { continue } - def, decodeErr := decodeDependency(ctx, transcoder, entry) - if decodeErr != nil { - return nil, decodeErr + def, err := decodeDependency(ctx, transcoder, entry) + if err != nil { + return nil, err } if def.Component == "" { return nil, NewDependencyEntryInvalidError(entry.ID.String(), "component is required", "") @@ -1172,17 +1165,13 @@ func dependencyDefinitions(deps []desiredDependency) []DependencyDefinition { func (h *DependencyHandler) collectDesiredDependencies( ctx context.Context, op regapi.Operation, - snapshot regapi.ProvenancedState, + snapshot regapi.State, transcoder payload.Transcoder, freshRoots map[string]struct{}, ) ([]desiredDependency, []desiredDependency, error) { deps := make(map[string]desiredDependency) operationID := op.Entry.ID - resident, err := stateProvenance(snapshot) - if err != nil { - return nil, nil, err - } current, err := h.collectSnapshotDependencies(ctx, snapshot, transcoder) if err != nil { return nil, nil, err @@ -1195,11 +1184,11 @@ func (h *DependencyHandler) collectDesiredDependencies( case regapi.EntryDelete: delete(deps, idKey(op.Entry.ID)) case regapi.EntryCreate, regapi.EntryUpdate: - entry, ok := resolveOperationEntry(op, snapshot.Entries) + entry, ok := resolveOperationEntry(op, snapshot) if !ok { return nil, nil, NewDependencyEntryMissingError(op.Entry.ID.String()) } - if !isRootDependency(entry, operationProvenance(op, resident)) { + if !isRootDependency(entry) { break } def, err := decodeDependency(ctx, transcoder, entry) @@ -1340,8 +1329,8 @@ func dependencyParametersEqual(a, b []Parameter) bool { return canonical(a) == canonical(b) } -func (h *DependencyHandler) installedModuleVersions(ctx context.Context, transcoder payload.Transcoder, snapshot regapi.ProvenancedState) (map[string]string, error) { - versions := residentModuleVersions(snapshot.Provenance) +func (h *DependencyHandler) installedModuleVersions(ctx context.Context, transcoder payload.Transcoder, snapshot regapi.State) (map[string]string, error) { + versions, _ := h.currentModuleIdentities(ctx) if h.lock == nil { return versions, nil } @@ -1389,7 +1378,12 @@ func mergeLinkDependencies(explicitDeps, moduleEntries []regapi.Entry) []regapi. return merged } -func (h *DependencyHandler) resolveModules(ctx context.Context, deps []DependencyDefinition, lockedVersions map[string]string) ([]ResolvedModule, error) { +func (h *DependencyHandler) resolveModules( + ctx context.Context, + deps []DependencyDefinition, + lockedVersions map[string]string, + resolution *regapi.DependencyResolution, +) ([]ResolvedModule, error) { roots := make([]DependencySpec, 0, len(deps)) for _, dep := range deps { name, err := graph.ParseName(dep.Component) @@ -1410,19 +1404,19 @@ func (h *DependencyHandler) resolveModules(ctx context.Context, deps []Dependenc if h.manifestCache != nil { provider = h.manifestCache } - lockedDigests := h.lockedModuleDigests() + baselineDigests := h.baselineModuleDigests() if regapi.DependencyAccessFromContext(ctx) == regapi.DependencyAccessVerifiedOffline { - provider = newLockedManifestProvider(h, lockedVersions, lockedDigests) + provider = newLockedManifestProvider(h, h.offlineModules(resolution)) } provider = &replacementManifestProvider{ base: provider, handler: h, lockedVersions: lockedVersions, - lockedDigests: lockedDigests, + lockedDigests: baselineDigests, } result, err := Resolve(resolveCtx, provider, roots, &ResolveOptions{ LockedVersions: lockedVersions, - LockedDigests: lockedDigests, + LockedDigests: baselineDigests, }) if err != nil { if h.logger != nil { @@ -1460,6 +1454,25 @@ func (h *DependencyHandler) resolveModules(ctx context.Context, deps []Dependenc return result.Modules, nil } +// ResolveWorkspaceDependencies resolves source declarations through the same +// manifest stack used by live dependency operations. In particular, local +// replacements supply their declarations while the Hub supplies a release +// version when the source tree does not declare one. +func (h *DependencyHandler) ResolveWorkspaceDependencies( + ctx context.Context, + deps []DependencyDefinition, +) ([]ResolvedModule, error) { + lockedVersions := make(map[string]string) + if h.lock != nil { + for _, module := range h.lock.GetModules() { + if module.Name != "" && module.Version != "" { + lockedVersions[module.Name] = module.Version + } + } + } + return h.resolveModules(ctx, deps, lockedVersions, nil) +} + // resolveEffectiveModules returns the complete module selection controlled by // the current deployment plus authored registry roots. Lock-selected root // modules are implicit deployment inputs: a history overlay may replace one, @@ -1469,6 +1482,7 @@ func (h *DependencyHandler) resolveEffectiveModules( ctx context.Context, deps []DependencyDefinition, lockedVersions map[string]string, + resolution *regapi.DependencyResolution, ) ([]ResolvedModule, error) { if regapi.DependencyAccessFromContext(ctx) == regapi.DependencyAccessVerifiedOffline { if resolved, ok := h.lockedResolution(deps, lockedVersions); ok { @@ -1481,7 +1495,7 @@ func (h *DependencyHandler) resolveEffectiveModules( } } - resolved, err := h.resolveModules(ctx, deps, lockedVersions) + resolved, err := h.resolveModules(ctx, deps, lockedVersions, resolution) if err != nil { return nil, err } @@ -1954,69 +1968,44 @@ func filterResolvedModules(modules []ResolvedModule, keep map[string]struct{}) [ return out } -// loadModuleEntries materializes the entries of the selected modules and the -// provenance of each: the loading module's identity resolved through the -// ownership rule, plus the deployment-root selection this deployment makes. -func (h *DependencyHandler) loadModuleEntries( - ctx context.Context, - modules []ResolvedModule, - ownerModules []ResolvedModule, - snapshot regapi.ProvenancedState, - transcoder payload.Transcoder, -) ([]regapi.Entry, regapi.ProvenanceMap, *unpackPlan, error) { +func (h *DependencyHandler) loadModuleEntries(ctx context.Context, modules []ResolvedModule, snapshot regapi.State, transcoder payload.Transcoder) ([]regapi.Entry, *unpackPlan, error) { entries := make([]regapi.Entry, 0) - prov := make(regapi.ProvenanceMap) - staging := make(map[string]loadedProvenance) plan := &unpackPlan{} - owners := moduleOwnersByNamespace(ownerModules) - resident, err := stateProvenance(snapshot) - if err != nil { - return nil, nil, nil, err - } - snapshotByID := entriesByID(snapshot.Entries) + snapshotByID := entriesByID(snapshot) installedRoots, err := rootDependencyModules(ctx, transcoder, snapshot) if err != nil { - return nil, nil, nil, err + return nil, nil, err } for _, mod := range modules { moduleName := mod.Org + "/" + mod.Name - loading := moduleOwner{name: moduleName, version: mod.Version, digest: mod.Digest} - _, replaced := h.replacementPath(moduleName) - replacement := replaced || mod.Source == moduleSourceReplacementTreeV1 - deploymentRootModule := h.lock != nil && h.lock.IsRootModule(moduleName) + deploymentRootModule := h.isDeploymentRoot(moduleName) moduleEntries, staged, err := h.loadEntriesForModulePlan(ctx, transcoder, mod) if err != nil { _ = plan.cleanup() - return nil, nil, nil, err + return nil, nil, err } plan.add(staged) for i := range moduleEntries { - key := idKey(moduleEntries[i].ID) - if keep, ok := preserveHostSnapshotEntry(moduleEntries[i], moduleName, snapshotByID, resident, installedRoots); ok { + // Cold boot marks dependency declarations from the selected root + // application as deployment roots. Loading that same application + // through a live Hub update must produce the identical topology; + // otherwise the update silently turns its application dependencies + // into transitive module entries and the next update loses their + // host bindings. + if deploymentRootModule && moduleEntries[i].Kind == regapi.NamespaceDependency { + moduleEntries[i].Registry.Root = true + } + if keep, ok := preserveHostSnapshotEntry(moduleEntries[i], moduleName, snapshotByID, installedRoots); ok { moduleEntries[i] = keep - claim := loadedProvenance{record: regapi.EntryProvenance{}} - staging[key] = claim - prov[moduleEntries[i].ID] = claim.record continue } - existing, hasExisting := staging[key] - claim := claimEntryProvenance(moduleEntries[i], loading, replacement, existing, hasExisting, resident, owners) - // Cold boot selects dependency declarations of the root application - // as deployment roots. Loading that same application through a live - // Hub update must produce the identical topology; otherwise the - // update silently turns its application dependencies into transitive - // module entries and the next update loses their host bindings. - if deploymentRootModule && moduleEntries[i].Kind == regapi.NamespaceDependency { - claim.record.Root = true - } - staging[key] = claim - prov[moduleEntries[i].ID] = claim.record + moduleEntries[i] = markModuleEntry(moduleEntries[i], moduleName) } entries = append(entries, moduleEntries...) } - return entries, prov, plan, nil + return entries, plan, nil } func entriesByID(entries regapi.State) map[string]regapi.Entry { @@ -2027,14 +2016,10 @@ func entriesByID(entries regapi.State) map[string]regapi.Entry { return byID } -func rootDependencyModules(ctx context.Context, transcoder payload.Transcoder, state regapi.ProvenancedState) (map[string]struct{}, error) { - prov, err := stateProvenance(state) - if err != nil { - return nil, err - } +func rootDependencyModules(ctx context.Context, transcoder payload.Transcoder, entries regapi.State) (map[string]struct{}, error) { modules := make(map[string]struct{}) - for _, entry := range state.Entries { - if !isRootDependency(entry, prov[idKey(entry.ID)]) { + for _, entry := range entries { + if !isRootDependency(entry) { continue } def, err := decodeDependency(ctx, transcoder, entry) @@ -2048,26 +2033,15 @@ func rootDependencyModules(ctx context.Context, transcoder payload.Transcoder, s return modules, nil } -// preserveHostSnapshotEntry keeps a host-authored entry that a root -// application's artifact also declares. Every entry a pack produces is owned by -// the module that loaded it, so only the resident record can make the id -// host-authored, and the host copy stays authoritative. -func preserveHostSnapshotEntry( - entry regapi.Entry, - moduleName string, - snapshot map[string]regapi.Entry, - resident provIndex, - installedRoots map[string]struct{}, -) (regapi.Entry, bool) { +func preserveHostSnapshotEntry(entry regapi.Entry, moduleName string, snapshot map[string]regapi.Entry, installedRoots map[string]struct{}) (regapi.Entry, bool) { if _, installed := installedRoots[moduleName]; !installed { return regapi.Entry{}, false } - existing, ok := snapshot[idKey(entry.ID)] - if !ok { + if entryModule(entry) != "" { return regapi.Entry{}, false } - record, ok := resident[idKey(entry.ID)] - if !ok || record.Module != "" { + existing, ok := snapshot[idKey(entry.ID)] + if !ok || entryModule(existing) != "" { return regapi.Entry{}, false } return existing, true @@ -2538,10 +2512,9 @@ func (h *DependencyHandler) freshDownloadInfo(ctx context.Context, mod ResolvedM defer cancel() return h.hub.GetDownloadURL(downloadURLCtx, &DownloadParams{ - Org: mod.Org, - Module: mod.Name, - Version: mod.Version, - VersionID: mod.VersionID, + Org: mod.Org, + Module: mod.Name, + Version: mod.Version, }) } @@ -2633,16 +2606,12 @@ func (h *DependencyHandler) moduleUsesDirectoryMode(moduleName string) bool { func (h *DependencyHandler) operationModules( ctx context.Context, op regapi.Operation, - snapshot regapi.ProvenancedState, + snapshot regapi.State, transcoder payload.Transcoder, ) (map[string]struct{}, error) { modules := make(map[string]struct{}) - resident, err := stateProvenance(snapshot) - if err != nil { - return nil, err - } - entry, ok := resolveOperationEntry(op, snapshot.Entries) - if !ok || !isRootDependency(entry, operationProvenance(op, resident)) { + entry, ok := resolveOperationEntry(op, snapshot) + if !ok || !isRootDependency(entry) { return modules, nil } def, err := decodeDependency(ctx, transcoder, entry) @@ -2780,24 +2749,30 @@ func sha256FileHex(path string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -func (h *DependencyHandler) lockedModuleDigests() map[string]string { - if h.lock == nil { +func (h *DependencyHandler) baselineModuleDigests() map[string]string { + if h == nil { return nil } - modules := h.lock.GetModules() - if len(modules) == 0 { - return nil + modules := make([]regapi.ResolvedModule, 0) + if h.deployment != nil { + modules = append(modules, h.deployment.Modules...) + } else if h.lock != nil { + for _, module := range h.lock.GetModules() { + modules = append(modules, regapi.ResolvedModule{ + Name: module.Name, Version: module.Version, Digest: module.Hash, + }) + } } digests := make(map[string]string, len(modules)) for _, mod := range modules { - if mod.Hash == "" || mod.Name == "" || mod.Version == "" { + if mod.Digest == "" || mod.Name == "" || mod.Version == "" { continue } // Key by name@version so the integrity check only fires when resolving - // the exact version the lock pins. A version-agnostic key would compare - // a new version's digest against the locked old version's and wrongly + // the exact version the baseline pins. A version-agnostic key would compare + // a new version's digest against the baseline's old version and wrongly // block updates. - digests[mod.Name+"@"+mod.Version] = mod.Hash + digests[mod.Name+"@"+mod.Version] = mod.Digest } if len(digests) == 0 { return nil diff --git a/boot/deps/hub/dependency_handler_edge_test.go b/boot/deps/hub/dependency_handler_edge_test.go index e050c62c8..df35fff58 100644 --- a/boot/deps/hub/dependency_handler_edge_test.go +++ b/boot/deps/hub/dependency_handler_edge_test.go @@ -3,6 +3,7 @@ package hub import ( + "context" "errors" "fmt" "os" @@ -17,6 +18,26 @@ import ( regapi "github.com/wippyai/runtime/api/registry" ) +func ownedEntry(entry regapi.Entry, module string) regapi.Entry { + entry.Registry.Owner = module + return entry +} + +type resolutionSnapshotRegistry struct { + regapi.Registry + resolution *regapi.DependencyResolution +} + +func (r resolutionSnapshotRegistry) Snapshot() regapi.Snapshot { + return regapi.Snapshot{Registry: regapi.StateMetadata{Resolution: r.resolution}} +} + +func withCurrentResolution(ctx context.Context, modules ...regapi.ResolvedModule) context.Context { + return regapi.WithRegistry(ctx, resolutionSnapshotRegistry{ + resolution: ®api.DependencyResolution{Modules: modules}, + }) +} + // --- resolveOperationEntry --- func TestResolveOperationEntry_FullEntry(t *testing.T) { @@ -113,177 +134,71 @@ func TestEntriesEqual_DifferentMeta(t *testing.T) { // --- entryConflict --- func TestEntryConflict_NoModuleOnDesired(t *testing.T) { - assert.False(t, entryConflict(regapi.EntryProvenance{}, regapi.EntryProvenance{})) + existing := regapi.Entry{ID: regapi.NewID("ns", "a")} + desired := regapi.Entry{ID: regapi.NewID("ns", "a")} + assert.False(t, entryConflict(existing, desired)) } -func TestEntryConflict_ExistingIsHostAuthored(t *testing.T) { - assert.True(t, entryConflict(regapi.EntryProvenance{}, regapi.EntryProvenance{Module: "acme/http"})) +func TestEntryConflict_ExistingHasNoModule(t *testing.T) { + existing := regapi.Entry{ID: regapi.NewID("ns", "a")} + desired := regapi.Entry{ + ID: regapi.NewID("ns", "a"), + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + } + assert.True(t, entryConflict(existing, desired)) } func TestEntryConflict_SameModule(t *testing.T) { - assert.False(t, entryConflict( - regapi.EntryProvenance{Module: "acme/http"}, - regapi.EntryProvenance{Module: "acme/http"}, - )) + existing := regapi.Entry{ + ID: regapi.NewID("ns", "a"), + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + } + desired := regapi.Entry{ + ID: regapi.NewID("ns", "a"), + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + } + assert.False(t, entryConflict(existing, desired)) } func TestEntryConflict_DifferentModules(t *testing.T) { - assert.True(t, entryConflict( - regapi.EntryProvenance{Module: "acme/http"}, - regapi.EntryProvenance{Module: "acme/grpc"}, - )) -} - -// --- deployment roots --- - -func TestIsRootDependencyTruthTable(t *testing.T) { - dependency := regapi.Entry{ID: regapi.NewID("workspace.modules", "search"), Kind: regapi.NamespaceDependency} - other := regapi.Entry{ID: regapi.NewID("workspace.modules", "search"), Kind: "service"} - - cases := []struct { - name string - entry regapi.Entry - record regapi.EntryProvenance - root bool - }{ - {"selected root owned by a module", dependency, regapi.EntryProvenance{Module: "acme/app", Root: true}, true}, - {"selected root owned by no module", dependency, regapi.EntryProvenance{Root: true}, true}, - {"module-owned transitive", dependency, regapi.EntryProvenance{Module: "acme/app"}, false}, - {"registry-authored overlay", dependency, regapi.EntryProvenance{}, true}, - {"another kind is never a root", other, regapi.EntryProvenance{Root: true}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.root, isRootDependency(tc.entry, tc.record)) - }) - } -} - -func TestCollectControlledModules_TruthTableOverProvenance(t *testing.T) { - ctx := newTestContext() - transcoder := payload.GetTranscoder(ctx) - handler := &DependencyHandler{} - - dependency := func(name, component string) regapi.Entry { - return regapi.Entry{ - ID: regapi.NewID("app.deps", name), - Kind: regapi.NamespaceDependency, - Data: payload.New(map[string]any{"component": component, "version": "v1.0.0"}), - } - } - ownedRoot := dependency("owned_root", "acme/owned_root") - unownedRoot := dependency("unowned_root", "acme/unowned_root") - transitive := dependency("transitive", "acme/transitive") - overlay := dependency("overlay", "acme/overlay") - - state := regapi.ProvenancedState{ - Entries: regapi.State{ownedRoot, unownedRoot, transitive, overlay}, - Provenance: regapi.ProvenanceMap{ - ownedRoot.ID: {Module: "acme/deployment", Root: true}, - unownedRoot.ID: {Root: true}, - transitive.ID: {Module: "acme/deployment"}, - overlay.ID: {}, - }, + existing := regapi.Entry{ + ID: regapi.NewID("ns", "a"), + Registry: regapi.EntryMetadata{Owner: "acme/http"}, } - - controlled, err := handler.collectControlledModules(ctx, state, transcoder) - require.NoError(t, err) - assert.Contains(t, controlled, "acme/owned_root", "a selected root controls its component whoever owns the declaration") - assert.Contains(t, controlled, "acme/unowned_root") - assert.Contains(t, controlled, "acme/overlay", "a registry-authored declaration is a user overlay root") - assert.NotContains(t, controlled, "acme/transitive", - "a module-owned declaration extends the graph from its owner, which no root reaches here") - - missing := state - missing.Provenance = regapi.ProvenanceMap{ - ownedRoot.ID: {Module: "acme/deployment", Root: true}, - unownedRoot.ID: {Root: true}, - transitive.ID: {Module: "acme/deployment"}, + desired := regapi.Entry{ + ID: regapi.NewID("ns", "a"), + Registry: regapi.EntryMetadata{Owner: "acme/grpc"}, } - _, err = handler.collectControlledModules(ctx, missing, transcoder) - require.ErrorIs(t, err, regapi.ErrMissingProvenance, "an entry without a record must fail loud, never read as host-authored") + assert.True(t, entryConflict(existing, desired)) } -// --- loader ownership rule --- - -func TestClaimEntryProvenance_NamespaceOwnerWinsOverLoadingModule(t *testing.T) { - entry := regapi.Entry{ID: regapi.NewID("acme.session", "delete_session_service")} - owners := moduleOwnersByNamespace([]ResolvedModule{ - {Org: "acme", Name: "session", Version: "v1.0.0"}, - {Org: "example", Name: "uploads", Version: "v2.0.0"}, - }) - - claim := claimEntryProvenance( - entry, - moduleOwner{name: "example/uploads", version: "v2.0.0"}, - false, loadedProvenance{}, false, nil, owners, - ) +// --- entryModule --- - assert.Equal(t, "acme/session", claim.record.Module) - assert.Equal(t, "v1.0.0", claim.record.Version) +func TestEntryModule_EmptyOwner(t *testing.T) { + assert.Empty(t, entryModule(regapi.Entry{})) } -func TestClaimEntryProvenance_ResidentOwnerWinsOverNamespaceOwner(t *testing.T) { - id := regapi.NewID("acme.llm.openai_compat", "client") - entry := regapi.Entry{ID: id} - resident := provIndex{idKey(id): {Module: "acme/llm", Version: "v4.0.0"}} - - claim := claimEntryProvenance( - entry, - moduleOwner{name: "example/skills", version: "v2.0.0"}, - false, loadedProvenance{}, false, resident, nil, - ) - - assert.Equal(t, "acme/llm", claim.record.Module) - assert.Equal(t, "v4.0.0", claim.record.Version) +func TestEntryModule_ValidModule(t *testing.T) { + e := regapi.Entry{Registry: regapi.EntryMetadata{Owner: "acme/http"}} + assert.Equal(t, "acme/http", entryModule(e)) } -func TestClaimEntryProvenance_ResidentOwnerRefreshesToLoadingIdentity(t *testing.T) { - id := regapi.NewID("acme.llm", "client") - entry := regapi.Entry{ID: id} - resident := provIndex{idKey(id): {Module: "acme/llm", Version: "v4.0.0", Digest: "sha256:old"}} - - claim := claimEntryProvenance( - entry, - moduleOwner{name: "acme/llm", version: "v5.0.0", digest: "sha256:new"}, - false, loadedProvenance{}, false, resident, nil, - ) - - assert.Equal(t, regapi.EntryProvenance{Module: "acme/llm", Version: "v5.0.0", Digest: "sha256:new"}, claim.record) -} - -func TestClaimEntryProvenance_ReplacementWinsUnconditionally(t *testing.T) { - id := regapi.NewID("acme.llm", "client") - entry := regapi.Entry{ID: id} - resident := provIndex{idKey(id): {Module: "acme/other", Version: "v4.0.0"}} - owners := moduleOwnersByNamespace([]ResolvedModule{{Org: "acme", Name: "llm", Version: "v9.0.0"}}) - - claim := claimEntryProvenance( - entry, - moduleOwner{name: "acme/dev", digest: "sha256-tree-v1:abc"}, - true, loadedProvenance{}, false, resident, owners, - ) - - require.True(t, claim.replacement) - assert.Equal(t, "acme/dev", claim.record.Module) - assert.Empty(t, claim.record.Version, "an unpublished development tree has no version; the digest is its identity") -} - -func TestClaimEntryProvenance_NonReplacementNeverDisplacesReplacement(t *testing.T) { - id := regapi.NewID("acme.llm", "client") - entry := regapi.Entry{ID: id} - staged := loadedProvenance{ - record: regapi.EntryProvenance{Module: "acme/dev", Digest: "sha256-tree-v1:abc"}, - replacement: true, +func TestIsRootDependencyKeepsOwnershipSeparate(t *testing.T) { + root := regapi.Entry{ + ID: regapi.NewID("workspace.modules", "search"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/app", Root: true}, } + assert.True(t, isRootDependency(root)) + assert.Equal(t, "acme/app", entryModule(root)) - claim := claimEntryProvenance( - entry, - moduleOwner{name: "acme/llm", version: "v1.0.0"}, - false, staged, true, nil, nil, - ) + transitive := root + transitive.Registry.Root = false + assert.False(t, isRootDependency(transitive)) - assert.Equal(t, staged, claim) + unowned := transitive + unowned.Registry.Owner = "" + assert.True(t, isRootDependency(unowned), "registry-authored dependencies are user overlay roots") } func TestPreserveHostSnapshotEntry_KeepsExistingUnownedEntry(t *testing.T) { @@ -304,7 +219,6 @@ func TestPreserveHostSnapshotEntry_KeepsExistingUnownedEntry(t *testing.T) { loaded, "acme/security", map[string]regapi.Entry{idKey(id): existing}, - provIndex{idKey(id): {}}, map[string]struct{}{"acme/security": {}}, ) @@ -312,22 +226,6 @@ func TestPreserveHostSnapshotEntry_KeepsExistingUnownedEntry(t *testing.T) { assert.Equal(t, existing.Data, result.Data) } -func TestPreserveHostSnapshotEntry_ModuleOwnedResidentEntryIsNotHost(t *testing.T) { - id := regapi.NewID("app", "api") - existing := regapi.Entry{ID: id, Kind: "http.router", Data: payload.NewPayload(`{"name":"resident"}`, payload.JSON)} - loaded := regapi.Entry{ID: id, Kind: "http.router", Data: payload.NewPayload(`{"name":"packed"}`, payload.JSON)} - - _, ok := preserveHostSnapshotEntry( - loaded, - "acme/security", - map[string]regapi.Entry{idKey(id): existing}, - provIndex{idKey(id): {Module: "acme/security", Version: "v1.0.0"}}, - map[string]struct{}{"acme/security": {}}, - ) - - assert.False(t, ok, "a module-owned resident entry is not a host entry") -} - // --- parseExpectedDigest --- func TestParseExpectedDigest_Empty(t *testing.T) { @@ -411,7 +309,7 @@ func planTestOperations( controlledModules map[string]struct{}, mutableModules map[string]struct{}, ) ([]regapi.Operation, error) { - return (operationPlanner{}).plan(fixtureState(current), fixtureState(desired), operationPlanOptions{ + return (operationPlanner{}).plan(current, desired, operationPlanOptions{ originalKey: idKey(originalID), controlledModules: controlledModules, mutableModules: mutableModules, @@ -441,9 +339,9 @@ func TestOperationPlanner_DeletedEntries(t *testing.T) { current := regapi.State{ {ID: regapi.NewID("app", "dep"), Kind: "ns.dependency"}, { - ID: regapi.NewID("app", "old-svc"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/http"}), + ID: regapi.NewID("app", "old-svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, }, } desired := []regapi.Entry{ @@ -461,14 +359,14 @@ func TestOperationPlanner_DeletesOnlyControlledModules(t *testing.T) { current := regapi.State{ {ID: regapi.NewID("app", "dep"), Kind: "ns.dependency"}, { - ID: regapi.NewID("app", "old-svc"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/http"}), + ID: regapi.NewID("app", "old-svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, }, { - ID: regapi.NewID("example.tools", "dependencies"), - Kind: "function.lua", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "example/keeper"}), + ID: regapi.NewID("example.tools", "dependencies"), + Kind: "function.lua", + Registry: regapi.EntryMetadata{Owner: "example/keeper"}, }, } desired := []regapi.Entry{ @@ -486,18 +384,18 @@ func TestOperationPlanner_DeletesOnlyControlledModules(t *testing.T) { func TestOperationPlanner_UpdatedEntries(t *testing.T) { current := regapi.State{ { - ID: regapi.NewID("app", "svc"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/http"}), - Data: payload.New("old"), + ID: regapi.NewID("app", "svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + Data: payload.New("old"), }, } desired := []regapi.Entry{ { - ID: regapi.NewID("app", "svc"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/http"}), - Data: payload.New("new"), + ID: regapi.NewID("app", "svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + Data: payload.New("new"), }, } @@ -508,21 +406,17 @@ func TestOperationPlanner_UpdatedEntries(t *testing.T) { } func TestOperationPlanner_ReconcileSkipsUntouchedImmutableModuleUpdates(t *testing.T) { - moduleMeta := attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/http", - fixtureModuleVersionKey: "v1.0.0", - }) current := regapi.State{{ - ID: regapi.NewID("app", "svc"), - Kind: "service", - Meta: moduleMeta, - Data: payload.New("resident"), + ID: regapi.NewID("app", "svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + Data: payload.New("resident"), }} desired := []regapi.Entry{{ - ID: regapi.NewID("app", "svc"), - Kind: "service", - Meta: moduleMeta, - Data: payload.New("normalized"), + ID: regapi.NewID("app", "svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + Data: payload.New("normalized"), }} ops, err := planTestOperations(current, desired, regapi.ID{}, map[string]struct{}{"acme/http": {}}, map[string]struct{}{}) @@ -538,16 +432,16 @@ func TestOperationPlanner_ReconcileSkipsUntouchedImmutableModuleUpdates(t *testi func TestOperationPlanner_KindChangeUsesDeleteCreate(t *testing.T) { id := regapi.NewID("ui", "assets") current := regapi.State{{ - ID: id, - Kind: "fs.embed", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/ui"}), - Data: payload.New(map[string]any{}), + ID: id, + Kind: "fs.embed", + Registry: regapi.EntryMetadata{Owner: "acme/ui"}, + Data: payload.New(map[string]any{}), }} desired := []regapi.Entry{{ - ID: id, - Kind: "fs.directory", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/ui"}), - Data: payload.New(map[string]any{"directory": "assets", "base": "module"}), + ID: id, + Kind: "fs.directory", + Registry: regapi.EntryMetadata{Owner: "acme/ui"}, + Data: payload.New(map[string]any{"directory": "assets", "base": "module"}), }} ops, err := planTestOperations(current, desired, regapi.NewID("app", "dep"), nil, nil) @@ -579,10 +473,10 @@ func TestOperationPlanner_ConflictError(t *testing.T) { } desired := []regapi.Entry{ { - ID: regapi.NewID("app", "svc"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/http"}), - Data: payload.New("module"), + ID: regapi.NewID("app", "svc"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/http"}, + Data: payload.New("module"), }, } @@ -591,7 +485,7 @@ func TestOperationPlanner_ConflictError(t *testing.T) { assert.Contains(t, err.Error(), "conflict") } -func TestOperationPlanner_SkipsHostAuthoredDeletes(t *testing.T) { +func TestOperationPlanner_SkipsNonModuleDeletes(t *testing.T) { current := regapi.State{ {ID: regapi.NewID("app", "dep"), Kind: "ns.dependency"}, {ID: regapi.NewID("app", "local-svc"), Kind: "service", Data: payload.New("local")}, @@ -602,22 +496,8 @@ func TestOperationPlanner_SkipsHostAuthoredDeletes(t *testing.T) { ops, err := planTestOperations(current, desired, regapi.NewID("app", "dep"), nil, nil) require.NoError(t, err) - assert.Empty(t, ops, "no module owns local-svc, so dependency reconciliation cannot delete it") -} - -func TestOperationPlanner_MissingProvenanceIsAHardError(t *testing.T) { - orphan := regapi.Entry{ID: regapi.NewID("app", "orphan"), Kind: "service", Data: payload.New("orphan")} - current := regapi.ProvenancedState{ - Entries: regapi.State{orphan}, - Provenance: regapi.ProvenanceMap{}, - } - - _, err := (operationPlanner{}).plan(current, regapi.ProvenancedState{}, operationPlanOptions{ - controlledModules: map[string]struct{}{"acme/http": {}}, - }) - require.ErrorIs(t, err, regapi.ErrMissingProvenance, - "an entry without a record is neither host-authored nor deletable; it is an invariant violation") - assert.Contains(t, err.Error(), orphan.ID.String()) + // local-svc has no module meta, so it won't be deleted + assert.Empty(t, ops) } // --- formatResolutionErrors --- @@ -762,13 +642,13 @@ func TestExists_False(t *testing.T) { func TestDependencyHandler_Expand_NilHandler(t *testing.T) { var h *DependencyHandler - _, err := h.Expand(newTestContext(), regapi.Operation{}, regapi.ProvenancedState{}) + _, err := h.Expand(newTestContext(), regapi.Operation{}, nil) require.Error(t, err) } func TestDependencyHandler_Expand_NilHub(t *testing.T) { h := &DependencyHandler{} - _, err := h.Expand(newTestContext(), regapi.Operation{}, regapi.ProvenancedState{}) + _, err := h.Expand(newTestContext(), regapi.Operation{}, nil) require.Error(t, err) } @@ -782,7 +662,7 @@ func TestDependencyHandler_Expand_NonDependencyKind(t *testing.T) { } op := regapi.Operation{Kind: regapi.EntryCreate, Entry: entry} - result, err := h.Expand(newTestContext(), op, regapi.ProvenancedState{}) + result, err := h.Expand(newTestContext(), op, nil) require.NoError(t, err) assert.False(t, result.Applied) } @@ -795,7 +675,7 @@ func TestDependencyHandler_Expand_EntryNotFound(t *testing.T) { Entry: regapi.Entry{ID: regapi.NewID("app", "missing")}, } - result, err := h.Expand(newTestContext(), op, regapi.ProvenancedState{}) + result, err := h.Expand(newTestContext(), op, nil) require.NoError(t, err) assert.False(t, result.Applied) } diff --git a/boot/deps/hub/dependency_handler_test.go b/boot/deps/hub/dependency_handler_test.go index 191136838..a68a92e7d 100644 --- a/boot/deps/hub/dependency_handler_test.go +++ b/boot/deps/hub/dependency_handler_test.go @@ -85,7 +85,7 @@ func TestDependencyHandler_ResolveErrors(t *testing.T) { Data: payload.NewPayload(`{"component":"acme/http","version":"v1.0.0"}`, payload.JSON), } - _, err = handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, regapi.ProvenancedState{}) + _, err = handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, nil) require.Error(t, err) var apiErr apierror.Error @@ -100,51 +100,6 @@ func TestDependencyHandler_ResolveErrors(t *testing.T) { } } -func TestDependencyHandler_UnchangedLoadedRootProducesCheckpointResolution(t *testing.T) { - ctx := newTestContext() - manifestCalls := 0 - vendorDir := t.TempDir() - artifact := buildWappBytes(t, []wapp.Entry{{ID: wapp.NewID("acme.http", "service"), Kind: "service"}}) - handler, err := NewDependencyHandler(DependencyHandlerOptions{ - Hub: &fakeHub{ - getManifest: func(_ context.Context, org, module, _ string) (*ModuleManifest, error) { - manifestCalls++ - return &ModuleManifest{Org: org, Name: module, Version: "v1.4.2", URL: "memory://http"}, nil - }, - downloadFile: func(_ context.Context, _ string, dest string) error { - require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755)) - return os.WriteFile(dest, artifact, 0o600) - }, - }, - Logger: zap.NewNop(), - LockPath: filepath.Join(t.TempDir(), "wippy.lock"), - VendorDir: vendorDir, - }) - require.NoError(t, err) - - depEntry := regapi.Entry{ - ID: regapi.NewID("app.deps", "http"), - Kind: regapi.NamespaceDependency, - Data: payload.NewPayload(`{"component":"acme/http","version":">=v1.0.0 =v0.1.0"}}, map[string]string{}, + nil, ) require.NoError(t, err, "a replaced module with a range constraint and no locked version must resolve from its local wippy.yaml") @@ -3059,6 +2795,7 @@ replacements: modules, err := handler.resolveModules(ctx, []DependencyDefinition{{Component: "local/mod", Version: ">=v0.1.0"}}, map[string]string{}, + nil, ) require.NoError(t, err) @@ -3144,19 +2881,16 @@ func TestDependencyHandler_CollectDesiredDependencies_DoesNotPinUpdatedDependenc snapshot := regapi.State{ oldDep, { - ID: regapi.NewID("wippy.facade", "public_files"), - Kind: "fs.directory", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "wippy/facade", - fixtureModuleVersionKey: "0.5.39", - }), - Data: payload.NewPayload(`{"path":"./static"}`, payload.JSON), + ID: regapi.NewID("wippy.facade", "public_files"), + Kind: "fs.directory", + Registry: regapi.EntryMetadata{Owner: "wippy/facade"}, + Data: payload.NewPayload(`{"path":"./static"}`, payload.JSON), }, } deps, _, err := handler.collectDesiredDependencies(ctx, regapi.Operation{Kind: regapi.EntryUpdate, Entry: updatedDep}, - fixtureState(snapshot), + snapshot, transcoder, nil, ) @@ -3179,32 +2913,31 @@ func TestDependencyHandler_CollectControlledModules_FollowsInstalledDependencyLi require.NoError(t, err) rootDep := regapi.Entry{ - ID: regapi.NewID("app.deps", "root"), - Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/deployment"}), - Data: payload.NewPayload(`{"component":"acme/app","version":"1.0.0"}`, payload.JSON), + ID: regapi.NewID("app.deps", "root"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/deployment", Root: true}, + Data: payload.NewPayload(`{"component":"acme/app","version":"1.0.0"}`, payload.JSON), } appDep := regapi.Entry{ - ID: regapi.NewID("acme.app", "lib_dep"), - Kind: regapi.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/app"}), - Data: payload.NewPayload(`{"component":"acme/lib","version":"1.0.0"}`, payload.JSON), + ID: regapi.NewID("acme.app", "lib_dep"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/app"}, + Data: payload.NewPayload(`{"component":"acme/lib","version":"1.0.0"}`, payload.JSON), } libDep := regapi.Entry{ - ID: regapi.NewID("acme.lib", "core_dep"), - Kind: regapi.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/lib"}), - Data: payload.NewPayload(`{"component":"acme/core","version":"1.0.0"}`, payload.JSON), + ID: regapi.NewID("acme.lib", "core_dep"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/lib"}, + Data: payload.NewPayload(`{"component":"acme/core","version":"1.0.0"}`, payload.JSON), } lockLoadedDep := regapi.Entry{ - ID: regapi.NewID("keeper.internal", "helper_dep"), - Kind: regapi.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "keeper/keeper"}), - Data: payload.NewPayload(`{"component":"keeper/helper","version":"1.0.0"}`, payload.JSON), + ID: regapi.NewID("keeper.internal", "helper_dep"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "keeper/keeper"}, + Data: payload.NewPayload(`{"component":"keeper/helper","version":"1.0.0"}`, payload.JSON), } - controlled, err := handler.collectControlledModules(ctx, fixtureState(regapi.State{rootDep, appDep, libDep, lockLoadedDep}), transcoder) + controlled, err := handler.collectControlledModules(ctx, regapi.State{rootDep, appDep, libDep, lockLoadedDep}, transcoder) require.NoError(t, err) assert.Contains(t, controlled, "acme/app") assert.Contains(t, controlled, "acme/lib") @@ -3225,26 +2958,25 @@ func TestDependencyHandler_ReconciliationControlSpansBothGraphsWithoutOwningDepl root := func(name, component string) regapi.Entry { return regapi.Entry{ - ID: regapi.NewID("app.deps", name), - Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "acme/deployment"}), - Data: payload.New(map[string]any{"component": component, "version": "v1.0.0"}), + ID: regapi.NewID("app.deps", name), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/deployment", Root: true}, + Data: payload.New(map[string]any{"component": component, "version": "v1.0.0"}), } } child := func(owner, component string) regapi.Entry { return regapi.Entry{ - ID: regapi.NewID(strings.ReplaceAll(owner, "/", "."), "child_dep"), - Kind: regapi.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: owner}), - Data: payload.New(map[string]any{"component": component, "version": "v1.0.0"}), + ID: regapi.NewID(strings.ReplaceAll(owner, "/", "."), "child_dep"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: owner}, + Data: payload.New(map[string]any{"component": component, "version": "v1.0.0"}), } } current := regapi.State{root("old", "acme/old"), child("acme/old", "acme/old-child")} target := regapi.State{root("new", "acme/new"), child("acme/new", "acme/new-child")} controlled, err := handler.reconciliationControlledModules( - ctx, fixtureState(current), fixtureState(target), transcoder, map[string]struct{}{"acme/new": {}}, + ctx, current, target, transcoder, map[string]struct{}{"acme/new": {}}, ) require.NoError(t, err) assert.Contains(t, controlled, "acme/old") @@ -3285,13 +3017,10 @@ func TestDependencyHandler_Expand_PreservesLockLoadedModuleEntries(t *testing.T) lockLoadedID := regapi.NewID("keeper.hub.tools", "dependencies") snapshot := regapi.State{ { - ID: lockLoadedID, - Kind: "function.lua", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "keeper/keeper", - fixtureModuleVersionKey: "0.5.2", - }), - Data: payload.New("return {}"), + ID: lockLoadedID, + Kind: "function.lua", + Registry: regapi.EntryMetadata{Owner: "keeper/keeper"}, + Data: payload.New("return {}"), }, } @@ -3301,7 +3030,7 @@ func TestDependencyHandler_Expand_PreservesLockLoadedModuleEntries(t *testing.T) Data: payload.NewPayload(`{"component":"acme/http","version":"v1.0.0"}`, payload.JSON), } - result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, fixtureState(snapshot)) + result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, snapshot) require.NoError(t, err) assert.True(t, result.Applied) @@ -3331,10 +3060,6 @@ func TestDependencyHandler_Expand_UsesLockReplacementForExistingRootDependency(t { "name": "ui_static_fs", "kind": "fs.directory", - "meta": { - "module": "keeper/keeper", - "module_version": "0.5.4" - }, "path": "./static/keeper" } ] @@ -3401,13 +3126,10 @@ replacements: Data: payload.NewPayload(`{"component":"keeper/keeper","version":"0.5.4"}`, payload.JSON), }, { - ID: uiStaticID, - Kind: "fs.directory", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "keeper/keeper", - fixtureModuleVersionKey: "0.5.4", - }), - Data: payload.NewPayload(`{"path":"./static/keeper"}`, payload.JSON), + ID: uiStaticID, + Kind: "fs.directory", + Registry: regapi.EntryMetadata{Owner: "keeper/keeper"}, + Data: payload.NewPayload(`{"path":"./static/keeper"}`, payload.JSON), }, } depEntry := regapi.Entry{ @@ -3416,7 +3138,7 @@ replacements: Data: payload.NewPayload(`{"component":"acme/http","version":"v1.0.0"}`, payload.JSON), } - result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, fixtureState(snapshot)) + result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: depEntry}, snapshot) require.NoError(t, err) assert.True(t, result.Applied) diff --git a/boot/deps/hub/dependency_handler_uninstall_repro_test.go b/boot/deps/hub/dependency_handler_uninstall_repro_test.go index ea0e58e04..93ab26e43 100644 --- a/boot/deps/hub/dependency_handler_uninstall_repro_test.go +++ b/boot/deps/hub/dependency_handler_uninstall_repro_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/attrs" "github.com/wippyai/runtime/api/payload" regapi "github.com/wippyai/runtime/api/registry" regtop "github.com/wippyai/runtime/system/registry/topology" @@ -52,29 +51,27 @@ func mcpServerWappEntries() []wapp.Entry { } // installedMcpSnapshotEntries is the post-install registry state for -// butschster/mcp-server installed with the router parameter: entries are stamped -// with the owning module and the endpoints carry the parameterized router. +// butschster/mcp-server installed with the router parameter. func installedMcpSnapshotEntries(routerValue string) []regapi.Entry { - mod := map[string]any{fixtureModuleKey: "butschster/mcp-server", fixtureModuleVersionKey: "1.6.2"} endpoint := func(name, method string) regapi.Entry { return regapi.Entry{ - ID: regapi.NewID("mcp", name), - Kind: "http.endpoint", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "butschster/mcp-server", fixtureModuleVersionKey: "1.6.2", "router": routerValue}), - Data: payload.NewPayload(fmt.Sprintf(`{"method":%q,"path":"/mcp"}`, method), payload.JSON), + ID: regapi.NewID("mcp", name), + Kind: "http.endpoint", + Registry: regapi.EntryMetadata{Owner: "butschster/mcp-server"}, + Data: payload.NewPayload(fmt.Sprintf(`{"method":%q,"path":"/mcp"}`, method), payload.JSON), } } return []regapi.Entry{ { - ID: regapi.NewID("mcp", "definition"), - Kind: regapi.NamespaceDefinition, - Meta: attrs.NewBagFrom(mod), + ID: regapi.NewID("mcp", "definition"), + Kind: regapi.NamespaceDefinition, + Registry: regapi.EntryMetadata{Owner: "butschster/mcp-server"}, }, { - ID: regapi.NewID("mcp", "router"), - Kind: regapi.NamespaceRequirement, - Meta: attrs.NewBagFrom(mod), - Data: payload.NewPayload(`{"targets":[{"entry":"mcp:sse_endpoint_post","path":"meta.router"},{"entry":"mcp:sse_endpoint_get","path":"meta.router"},{"entry":"mcp:sse_endpoint_delete","path":"meta.router"}]}`, payload.JSON), + ID: regapi.NewID("mcp", "router"), + Kind: regapi.NamespaceRequirement, + Registry: regapi.EntryMetadata{Owner: "butschster/mcp-server"}, + Data: payload.NewPayload(`{"targets":[{"entry":"mcp:sse_endpoint_post","path":"meta.router"},{"entry":"mcp:sse_endpoint_get","path":"meta.router"},{"entry":"mcp:sse_endpoint_delete","path":"meta.router"}]}`, payload.JSON), }, endpoint("sse_endpoint_post", "POST"), endpoint("sse_endpoint_get", "GET"), @@ -113,7 +110,7 @@ func TestReproDeleteParameterizedMcpRootRemovesEndpoints(t *testing.T) { result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: root.ID}}, - fixtureState(snapshot), + snapshot, ) require.NoError(t, err) require.True(t, result.Applied) @@ -173,16 +170,16 @@ func TestReproDeleteUnrelatedRootPreservesMcpRouterParam(t *testing.T) { Data: payload.NewPayload(`{"component":"wippy/dummy","version":"v1.0.0","parameters":[{"name":"dummy:router_req","value":"app:api"}]}`, payload.JSON), } dummyReq := regapi.Entry{ - ID: regapi.NewID("dummy", "router_req"), - Kind: regapi.NamespaceRequirement, - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "wippy/dummy", fixtureModuleVersionKey: "v1.0.0"}), - Data: payload.NewPayload(`{"targets":[{"entry":"dummy:endpoint","path":"meta.router"}]}`, payload.JSON), + ID: regapi.NewID("dummy", "router_req"), + Kind: regapi.NamespaceRequirement, + Registry: regapi.EntryMetadata{Owner: "wippy/dummy"}, + Data: payload.NewPayload(`{"targets":[{"entry":"dummy:endpoint","path":"meta.router"}]}`, payload.JSON), } dummyEndpoint := regapi.Entry{ - ID: regapi.NewID("dummy", "endpoint"), - Kind: "http.endpoint", - Meta: attrs.NewBagFrom(map[string]any{fixtureModuleKey: "wippy/dummy", fixtureModuleVersionKey: "v1.0.0", "router": "app:api"}), - Data: payload.NewPayload(`{"method":"POST","path":"/dummy"}`, payload.JSON), + ID: regapi.NewID("dummy", "endpoint"), + Kind: "http.endpoint", + Registry: regapi.EntryMetadata{Owner: "wippy/dummy"}, + Data: payload.NewPayload(`{"method":"POST","path":"/dummy"}`, payload.JSON), } snapshot := regapi.State{mcpRoot, dummyRoot, dummyReq, dummyEndpoint} @@ -190,7 +187,7 @@ func TestReproDeleteUnrelatedRootPreservesMcpRouterParam(t *testing.T) { result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: dummyRoot.ID}}, - fixtureState(snapshot), + snapshot, ) require.NoError(t, err) require.True(t, result.Applied) @@ -222,39 +219,27 @@ func TestDeleteRootDependencyKeepsSharedLibraryImportedByBaselineEntry(t *testin Data: payload.NewPayload(`{"component":"acme/plugin","version":"v1.0.0"}`, payload.JSON), } pluginDep := regapi.Entry{ - ID: regapi.NewID("acme.plugin", "dependency.migration"), - Kind: regapi.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/plugin", - fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.NewPayload(`{"component":"acme/migration","version":"v1.0.0"}`, payload.JSON), + ID: regapi.NewID("acme.plugin", "dependency.migration"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Owner: "acme/plugin"}, + Data: payload.NewPayload(`{"component":"acme/migration","version":"v1.0.0"}`, payload.JSON), } pluginService := regapi.Entry{ - ID: regapi.NewID("acme.plugin", "service"), - Kind: "service", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/plugin", - fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.NewPayload(`{"ok":true}`, payload.JSON), + ID: regapi.NewID("acme.plugin", "service"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/plugin"}, + Data: payload.NewPayload(`{"ok":true}`, payload.JSON), } sharedLibrary := regapi.Entry{ - ID: regapi.NewID("acme.migration", "runner"), - Kind: "function.lua", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/migration", - fixtureModuleVersionKey: "v1.0.0", - }), - Data: payload.NewPayload(`{"source":"return {}"}`, payload.JSON), + ID: regapi.NewID("acme.migration", "runner"), + Kind: "function.lua", + Registry: regapi.EntryMetadata{Owner: "acme/migration"}, + Data: payload.NewPayload(`{"source":"return {}"}`, payload.JSON), } baselineMigration := regapi.Entry{ - ID: regapi.NewID("acme.app.migrations", "01_bootstrap"), - Kind: "function.lua", - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/app-core", - fixtureModuleVersionKey: "v1.0.0", - }), + ID: regapi.NewID("acme.app.migrations", "01_bootstrap"), + Kind: "function.lua", + Registry: regapi.EntryMetadata{Owner: "acme/app-core"}, Data: payload.New(map[string]any{ "imports": map[string]any{"migration": "acme.migration:runner"}, "source": "return {}", @@ -270,7 +255,7 @@ func TestDeleteRootDependencyKeepsSharedLibraryImportedByBaselineEntry(t *testin result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: rootDep.ID}}, - fixtureState(regapi.State{rootDep, pluginDep, pluginService, sharedLibrary, baselineMigration}), + regapi.State{rootDep, pluginDep, pluginService, sharedLibrary, baselineMigration}, ) require.NoError(t, err) require.True(t, result.Applied) diff --git a/boot/deps/hub/deployment_baseline.go b/boot/deps/hub/deployment_baseline.go index 4c58d7bbf..1e075f838 100644 --- a/boot/deps/hub/deployment_baseline.go +++ b/boot/deps/hub/deployment_baseline.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" "github.com/wippyai/runtime/api/payload" regapi "github.com/wippyai/runtime/api/registry" @@ -21,7 +22,7 @@ import ( // after the lock or source-owned roots change. func (h *DependencyHandler) deploymentBaselineDigest( ctx context.Context, - baseline regapi.ProvenancedState, + baseline regapi.State, transcoder payload.Transcoder, ) (string, error) { type lockedModule struct { @@ -38,7 +39,17 @@ func (h *DependencyHandler) deploymentBaselineDigest( } modules := make([]lockedModule, 0) - if h != nil && h.lock != nil { + if h != nil && h.deployment != nil { + for _, mod := range h.deployment.Modules { + if mod.Name == "" || mod.Version == "" { + continue + } + modules = append(modules, lockedModule{ + Name: mod.Name, Version: mod.Version, Digest: strings.TrimPrefix(strings.ToLower(mod.Digest), "sha256:"), + Root: mod.Name == h.deployment.Root, Replacement: mod.Source == moduleSourceReplacementTreeV1, + }) + } + } else if h != nil && h.lock != nil { for _, mod := range h.lock.GetModules() { if mod.Name == "" || mod.Version == "" { continue @@ -52,16 +63,11 @@ func (h *DependencyHandler) deploymentBaselineDigest( } sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name }) - prov, err := stateProvenance(baseline) - if err != nil { - return "", err - } - roots := make([]deploymentRoot, 0) - for _, entry := range baseline.Entries { - // Root selection is assigned by source/lock ingestion. Unowned roots + for _, entry := range baseline { + // Registry.Root is assigned by source/lock ingestion. Unowned roots // created through the registry API remain history-owned overlays. - if entry.Kind != regapi.NamespaceDependency || !prov[idKey(entry.ID)].Root { + if entry.Kind != regapi.NamespaceDependency || !entry.Registry.Root { continue } definition, err := decodeDependency(ctx, transcoder, entry) @@ -99,7 +105,7 @@ func (h *DependencyHandler) deploymentBaselineDigest( func (h *DependencyHandler) resolutionForSnapshot( ctx context.Context, - baseline regapi.ProvenancedState, + baseline regapi.State, roots []desiredDependency, references []desiredDependency, modules []ResolvedModule, @@ -111,12 +117,13 @@ func (h *DependencyHandler) resolutionForSnapshot( } resolution := dependencyResolution(roots, references, modules) resolution.BaselineDigest = baselineDigest + resolution.Deployment = h.deployment.Canonical() return resolution.Canonical(), nil } func (h *DependencyHandler) resolutionRefreshReason( ctx context.Context, - baseline regapi.ProvenancedState, + baseline regapi.State, roots []desiredDependency, references []desiredDependency, resolution *regapi.DependencyResolution, @@ -139,12 +146,8 @@ func (h *DependencyHandler) resolutionRefreshReason( return "legacy resolution predates folded reference declarations", nil } if dependencyInputDigest(roots) != resolution.InputDigest { - prov, err := stateProvenance(baseline) - if err != nil { - return "", err - } - for _, entry := range baseline.Entries { - if entry.Kind == regapi.NamespaceDependency && prov[idKey(entry.ID)].Root { + for _, entry := range baseline { + if entry.Kind == regapi.NamespaceDependency && entry.Registry.Root { return "legacy resolution root declarations differ from deployment baseline", nil } } @@ -165,7 +168,7 @@ func (h *DependencyHandler) resolutionRefreshReason( // deployment roots; history-owned roots remain governed by the stored graph. func (h *DependencyHandler) legacyResolutionConflictsWithBaseline( ctx context.Context, - baseline regapi.ProvenancedState, + baseline regapi.State, resolution *regapi.DependencyResolution, transcoder payload.Transcoder, ) (bool, error) { @@ -177,20 +180,16 @@ func (h *DependencyHandler) legacyResolutionConflictsWithBaseline( for _, mod := range resolution.Modules { stored[mod.Name] = mod } - versions := residentModuleVersions(baseline.Provenance) - digests := residentModuleDigests(baseline.Provenance) + versions, digests := h.currentModuleIdentities(ctx) if h != nil && h.lock != nil { - // The lock answers only for a module whose records carry no identity of - // their own. It never overrides a resident fact: a replacement resident - // at a tree identity the lock does not know is not a conflict. for _, mod := range h.lock.GetModules() { if _, owned := controlled[mod.Name]; !owned { continue } - if _, resident := versions[mod.Name]; !resident && mod.Version != "" { + if mod.Version != "" { versions[mod.Name] = mod.Version } - if _, resident := digests[mod.Name]; !resident && mod.Hash != "" { + if mod.Hash != "" { digests[mod.Name] = mod.Hash } } @@ -222,7 +221,7 @@ func (h *DependencyHandler) legacyResolutionConflictsWithBaseline( // standard refresh. func (h *DependencyHandler) upgradeLegacyReferencedResolution( ctx context.Context, - baseline regapi.ProvenancedState, + baseline regapi.State, declarations []desiredDependency, resolution *regapi.DependencyResolution, baselineDigest string, @@ -295,9 +294,13 @@ func storedResolutionVersions(resolution *regapi.DependencyResolution) map[strin } func resolvedModulesFromStored(resolution *regapi.DependencyResolution) ([]ResolvedModule, error) { - resolved := make([]ResolvedModule, 0, len(resolution.Modules)) - seen := make(map[string]struct{}, len(resolution.Modules)) - for _, mod := range resolution.Modules { + return resolvedModulesFromRecords(resolution.Modules) +} + +func resolvedModulesFromRecords(modules []regapi.ResolvedModule) ([]ResolvedModule, error) { + resolved := make([]ResolvedModule, 0, len(modules)) + seen := make(map[string]struct{}, len(modules)) + for _, mod := range modules { name, err := graph.ParseName(mod.Name) if err != nil || mod.Version == "" { return nil, NewDependencyResolutionError(fmt.Errorf("invalid stored module %q@%s", mod.Name, mod.Version)) diff --git a/boot/deps/hub/deployment_baseline_test.go b/boot/deps/hub/deployment_baseline_test.go index 0fc064aea..3219b61a0 100644 --- a/boot/deps/hub/deployment_baseline_test.go +++ b/boot/deps/hub/deployment_baseline_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "github.com/stretchr/testify/require" @@ -105,19 +106,19 @@ modules: require.NoError(t, err) appRoot := regapi.Entry{ - ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, DependencyRoot: true, + ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, Registry: regapi.EntryMetadata{Root: true}, Data: payload.New(map[string]any{"component": "acme/app", "version": "v2.0.0"}), } - appService := fixtureOwned(regapi.Entry{ + appService := ownedEntry(regapi.Entry{ ID: regapi.NewID("acme.app", "service"), Kind: "service", - }, "acme/app", "v2.0.0", digests[selection{"acme/app", "v2.0.0"}]) - appShared := fixtureOwned(regapi.Entry{ + }, "acme/app") + appShared := ownedEntry(regapi.Entry{ ID: regapi.NewID("acme.app", "shared"), Kind: regapi.NamespaceDependency, Data: payload.New(map[string]any{"component": "acme/shared", "version": ">=v1.0.0"}), - }, "acme/app", "v2.0.0", digests[selection{"acme/app", "v2.0.0"}]) - sharedService := fixtureOwned(regapi.Entry{ + }, "acme/app") + sharedService := ownedEntry(regapi.Entry{ ID: regapi.NewID("acme.shared", "service"), Kind: "service", - }, "acme/shared", "v2.0.0", digests[selection{"acme/shared", "v2.0.0"}]) + }, "acme/shared") baseline := regapi.State{appRoot, appService, appShared, sharedService} pluginRoot := regapi.Entry{ ID: regapi.NewID("user.deps", "plugin"), Kind: regapi.NamespaceDependency, @@ -146,7 +147,7 @@ modules: }, }).Canonical() - result, err := handler.ReconcileResolution(ctx, fixtureState(baseline), fixtureState(target), stored) + result, err := handler.ReconcileResolution(ctx, baseline, target, stored) require.NoError(t, err) selected := make([]string, 0, len(result.Resolution.Modules)) for _, mod := range result.Resolution.Modules { @@ -163,56 +164,37 @@ modules: require.Equal(t, 1, manifestCalls[selection{"acme/shared", "v2.0.0"}]) } -// The lock answers for a module whose records carry no identity of their own. -// It never overrides one: a replacement resident at a tree identity the lock -// cannot know is not a conflict with the stored selection. -func TestLegacyResolutionConflict_LockIsAFallbackNotAnOverride(t *testing.T) { +func TestDeploymentBaselineDigestIsIndependentOfLockPresence(t *testing.T) { ctx := newTestContext() - tmpDir := t.TempDir() - lockPath := filepath.Join(tmpDir, "wippy.lock") - const ( - treeDigest = "sha256-tree-v1:1111111111111111111111111111111111111111111111111111111111111111" - lockDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" - ) + digest := strings.Repeat("a", 64) + lockPath := filepath.Join(t.TempDir(), "wippy.lock") require.NoError(t, os.WriteFile(lockPath, []byte(fmt.Sprintf(`directories: modules: vendor modules: - - name: acme/local - version: v1.0.0 + - name: acme/app + version: 1.0.0 hash: %s -replace: - - from: acme/local - to: ./local -`, lockDigest)), 0o600)) - + root: true +`, digest)), 0o600)) handler, err := NewDependencyHandler(DependencyHandlerOptions{ Hub: &fakeHub{}, Logger: zap.NewNop(), LockPath: lockPath, - VendorDir: filepath.Join(tmpDir, "vendor"), }) require.NoError(t, err) - - root := regapi.Entry{ - ID: regapi.NewID("app.deps", "local"), Kind: regapi.NamespaceDependency, - Data: payload.New(map[string]any{"component": "acme/local", "version": "v1.0.0"}), - } - service := fixtureOwned(regapi.Entry{ - ID: regapi.NewID("acme.local", "service"), Kind: "service", - }, "acme/local", "v1.0.0", treeDigest) - baseline := fixtureState(regapi.State{root, service}) - - stored := ®api.DependencyResolution{ - Modules: []regapi.ResolvedModule{{Name: "acme/local", Version: "v1.0.0", Digest: treeDigest}}, - } - conflict, err := handler.legacyResolutionConflictsWithBaseline(ctx, baseline, stored, payload.GetTranscoder(ctx)) + baseline := regapi.State{{ + ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Root: true}, + Data: payload.New(map[string]any{"component": "acme/app", "version": "1.0.0"}), + }} + transcoder := payload.GetTranscoder(ctx) + withLock, err := handler.deploymentBaselineDigest(ctx, baseline, transcoder) require.NoError(t, err) - require.False(t, conflict, "the resident tree identity is authoritative over the lock hash") - // With no resident digest the lock is the only identity available, and the - // stored selection is then judged against it. - unrecorded := fixtureState(regapi.State{root, fixtureOwned(regapi.Entry{ - ID: regapi.NewID("acme.local", "service"), Kind: "service", - }, "acme/local", "v1.0.0", "")}) - conflict, err = handler.legacyResolutionConflictsWithBaseline(ctx, unrecorded, stored, payload.GetTranscoder(ctx)) + deployment, err := handler.deploymentFromLock() require.NoError(t, err) - require.True(t, conflict, "without a resident digest the lock hash decides") + handler.deployment = deployment + handler.lock = nil + withoutLock, err := handler.deploymentBaselineDigest(ctx, baseline, transcoder) + require.NoError(t, err) + + require.Equal(t, withLock, withoutLock) } diff --git a/boot/deps/hub/embed_effect.go b/boot/deps/hub/embed_effect.go index b8e31edb4..8a7ede49c 100644 --- a/boot/deps/hub/embed_effect.go +++ b/boot/deps/hub/embed_effect.go @@ -11,7 +11,6 @@ import ( "github.com/wippyai/runtime/api/attrs" apierror "github.com/wippyai/runtime/api/error" - regapi "github.com/wippyai/runtime/api/registry" embedpkg "github.com/wippyai/runtime/service/fs/embed" "github.com/wippyai/wapp" "go.uber.org/zap" @@ -21,13 +20,13 @@ import ( // lifecycle of module-owned .wapp packs. It is satisfied by // *service/fs/embed.Registry and stubbed in tests. type embedPackRegistry interface { - RegisterPack(packPath, module, version string, reader *wapp.Reader, file *os.File) error + StagePack(packPath, module, version string, reader *wapp.Reader, file *os.File) error + ActivatePacks(packPaths []string) error UnregisterPack(packPath string) error UnregisterModule(module, version string) error - RetargetModule(module, fromVersion, toVersion string) error } -// stagedPack describes a new module pack to register during Prepare and to +// stagedPack describes a new module pack to stage during Prepare and to // roll back (unregister + close) if the apply fails. type stagedPack struct { packPath string @@ -42,25 +41,13 @@ type obsoletePack struct { version string } -// packRetarget repoints filesystems served for a module from the superseded -// pack generation to the adopted one before the superseded pack closes. -type packRetarget struct { - module string - from string - to string -} - // embedPackEffect ties embedded-pack lifecycle to a module operation. Embedded // packs are module resources owned by dependency expansion, not by fs.embed // entries, so this effect runs alongside the registry changeset: // -// - Prepare registers newly resolved packs so the fs.embed entries created in -// the same changeset resolve during apply. New packs are keyed by their own -// .wapp path, so a version update stages the new pack without disturbing the -// pack still serving the old version. -// - Commit activates the staged set but remains reversible. -// - Finalize repoints the consumers of a superseded generation, then -// unregisters and closes obsolete packs, only after the registry +// - Prepare opens newly resolved packs without exposing their resources. +// - Commit activates the staged set after the registry transition succeeds. +// - Finalize unregisters and closes obsolete packs only after the registry // history/head is durable. The new packs stay registered. // - Rollback unregisters and closes the packs staged in Prepare, restoring the // pre-operation set. Obsolete packs are left untouched because they are only @@ -69,7 +56,6 @@ type embedPackEffect struct { reg embedPackRegistry staged []stagedPack obsolete []obsoletePack - retarget []packRetarget logger *zap.Logger prepared []string // pack paths registered by Prepare, for rollback @@ -90,7 +76,7 @@ func (e *embedPackEffect) Prepare(_ context.Context) error { return newReadPackError(sp.packPath, err) } - if err := e.reg.RegisterPack(sp.packPath, sp.module, sp.version, reader, f); err != nil { + if err := e.reg.StagePack(sp.packPath, sp.module, sp.version, reader, f); err != nil { f.Close() e.rollbackPrepared() return newRegisterPackError(sp.packPath, err) @@ -106,37 +92,15 @@ func (e *embedPackEffect) Prepare(_ context.Context) error { } func (e *embedPackEffect) Commit(_ context.Context) error { + if err := e.reg.ActivatePacks(e.prepared); err != nil { + return newRegisterPackError("embedded packs", err) + } return nil } func (e *embedPackEffect) Finalize(_ context.Context) error { var errs []error - // Entries whose content did not change receive no event during the - // transition, so filesystems cached for them still serve the superseded - // pack. Repointing them precedes closing that pack. - retained := make(map[string]struct{}) - for _, rt := range e.retarget { - if err := e.reg.RetargetModule(rt.module, rt.from, rt.to); err != nil { - // Consumers still resolve the superseded generation. Its pack stays - // open: serving stale files is recoverable, serving a closed reader - // is not. Other modules still finalize. - retained[rt.module] = struct{}{} - e.logger.Warn("failed to retarget embedded pack consumers; retaining the superseded pack", - zap.String("module", rt.module), - zap.String("from", rt.from), - zap.String("to", rt.to), - zap.Error(err)) - errs = append(errs, fmt.Errorf("retarget embedded pack %s %s->%s: %w", rt.module, rt.from, rt.to, err)) - } - } for _, op := range e.obsolete { - if _, keep := retained[op.module]; keep { - errs = append(errs, fmt.Errorf( - "retained embedded pack %s@%s: consumers still resolve the superseded generation", - op.module, op.version, - )) - continue - } if err := e.reg.UnregisterModule(op.module, op.version); err != nil { // The changeset is already durable, so callers only report this as a // cleanup warning. Return it for observability instead of pretending @@ -172,8 +136,8 @@ func (e *embedPackEffect) rollbackPrepared() { } // buildEmbedPackEffect computes the pack-lifecycle effect for a module -// operation. resolved are the desired modules after the operation; snapshot is -// the registry state before it. controlled limits removals to modules owned by +// operation. resolved are the desired modules after the operation. controlled +// limits removals to modules owned by // dependency roots participating in this operation. staged packs are the // resolved modules backed by a .wapp on disk; obsolete packs are controlled // modules whose version is no longer desired (removed) or changed (updated). @@ -183,7 +147,6 @@ func (e *embedPackEffect) rollbackPrepared() { func (h *DependencyHandler) buildEmbedPackEffect( ctx context.Context, resolved []ResolvedModule, - snapshot regapi.ProvenancedState, controlled map[string]struct{}, ) (*embedPackEffect, error) { reg := embedpkg.GetRegistryFromContext(ctx) @@ -192,8 +155,7 @@ func (h *DependencyHandler) buildEmbedPackEffect( } desired := make(map[string]string, len(resolved)) - installed := residentModuleVersions(snapshot.Provenance) - installedDigests := residentModuleDigests(snapshot.Provenance) + installed, installedDigests := h.currentModuleIdentities(ctx) staged := make([]stagedPack, 0, len(resolved)) for _, mod := range resolved { name := mod.Org + "/" + mod.Name @@ -237,15 +199,6 @@ func (h *DependencyHandler) buildEmbedPackEffect( obsolete := obsoletePacksFor(installed, desired, controlled) - // A superseded generation is repointed only where a newer one is desired for - // the same module. A module that leaves the graph has nothing to point at. - retargets := make([]packRetarget, 0, len(obsolete)) - for _, op := range obsolete { - if to := desired[op.module]; to != "" && to != op.version { - retargets = append(retargets, packRetarget{module: op.module, from: op.version, to: to}) - } - } - if len(staged) == 0 && len(obsolete) == 0 { return nil, nil } @@ -259,17 +212,16 @@ func (h *DependencyHandler) buildEmbedPackEffect( reg: reg, staged: staged, obsolete: obsolete, - retarget: retargets, logger: logger.Named("embed_pack"), }, nil } -// obsoletePacksFor returns controlled resident modules whose version is not the +// obsoletePacksFor returns controlled snapshot modules whose version is not the // desired version (changed) or which are no longer desired at all (removed). // Packs outside controlled are unrelated to this dependency operation and must // remain live. A staged pack for the new version is keyed by its own path, so // unregistering the old version never affects the new pack. -func obsoletePacksFor(current map[string]string, desired map[string]string, controlled map[string]struct{}) []obsoletePack { +func obsoletePacksFor(current, desired map[string]string, controlled map[string]struct{}) []obsoletePack { obsolete := make([]obsoletePack, 0) for module, version := range current { if _, ok := controlled[module]; !ok { diff --git a/boot/deps/hub/embed_effect_test.go b/boot/deps/hub/embed_effect_test.go index d00e65a11..d8a37c435 100644 --- a/boot/deps/hub/embed_effect_test.go +++ b/boot/deps/hub/embed_effect_test.go @@ -5,7 +5,6 @@ package hub import ( "bytes" "context" - "errors" "io/fs" "os" "path/filepath" @@ -15,7 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/attrs" ctxapi "github.com/wippyai/runtime/api/context" moduleapi "github.com/wippyai/runtime/api/modules" "github.com/wippyai/runtime/api/payload" @@ -29,17 +27,15 @@ import ( "go.uber.org/zap" ) -// stubPackRegistry records pack lifecycle calls for assertions. calls keeps the -// order across kinds, which the retarget-before-close contract depends on. +// stubPackRegistry records pack lifecycle calls for assertions. type stubPackRegistry struct { registerErr error + activateErr error registered map[string]*wapp.Reader + active map[string]bool files map[string]*os.File unregistered []string modulesDropped []droppedModule - retargeted []retargetedModule - retargetErr map[string]error - calls []string } type droppedModule struct { @@ -47,20 +43,15 @@ type droppedModule struct { version string } -type retargetedModule struct { - module string - from string - to string -} - func newStubPackRegistry() *stubPackRegistry { return &stubPackRegistry{ registered: make(map[string]*wapp.Reader), + active: make(map[string]bool), files: make(map[string]*os.File), } } -func (s *stubPackRegistry) RegisterPack(packPath, _, _ string, reader *wapp.Reader, file *os.File) error { +func (s *stubPackRegistry) StagePack(packPath, _, _ string, reader *wapp.Reader, file *os.File) error { if s.registerErr != nil { return s.registerErr } @@ -69,28 +60,34 @@ func (s *stubPackRegistry) RegisterPack(packPath, _, _ string, reader *wapp.Read return nil } +func (s *stubPackRegistry) ActivatePacks(packPaths []string) error { + if s.activateErr != nil { + return s.activateErr + } + for _, packPath := range packPaths { + if _, ok := s.registered[packPath]; !ok { + return os.ErrNotExist + } + } + for _, packPath := range packPaths { + s.active[packPath] = true + } + return nil +} + func (s *stubPackRegistry) UnregisterPack(packPath string) error { s.unregistered = append(s.unregistered, packPath) if f := s.files[packPath]; f != nil { _ = f.Close() } delete(s.registered, packPath) + delete(s.active, packPath) delete(s.files, packPath) return nil } func (s *stubPackRegistry) UnregisterModule(module, version string) error { s.modulesDropped = append(s.modulesDropped, droppedModule{module: module, version: version}) - s.calls = append(s.calls, "unregister:"+module+"@"+version) - return nil -} - -func (s *stubPackRegistry) RetargetModule(module, fromVersion, toVersion string) error { - s.retargeted = append(s.retargeted, retargetedModule{module: module, from: fromVersion, to: toVersion}) - s.calls = append(s.calls, "retarget:"+module+"@"+fromVersion+"->"+toVersion) - if err := s.retargetErr[module]; err != nil { - return err - } return nil } @@ -104,6 +101,7 @@ func (s *stubPackRegistry) Close() error { } delete(s.files, packPath) delete(s.registered, packPath) + delete(s.active, packPath) } return firstErr } @@ -139,90 +137,6 @@ func newEffect(reg embedPackRegistry, staged []stagedPack, obsolete []obsoletePa } } -func TestEmbedPackEffect_FailedRetargetRetainsTheSupersededPack(t *testing.T) { - reg := newStubPackRegistry() - reg.retargetErr = map[string]error{"org/broken": errors.New("repoint refused")} - eff := newEffect(reg, nil, []obsoletePack{ - {module: "org/broken", version: "1.0.0"}, - {module: "org/healthy", version: "1.0.0"}, - }) - eff.retarget = []packRetarget{ - {module: "org/broken", from: "1.0.0", to: "2.0.0"}, - {module: "org/healthy", from: "1.0.0", to: "2.0.0"}, - } - - err := eff.Finalize(context.Background()) - require.Error(t, err) - assert.Contains(t, err.Error(), "repoint refused") - assert.Contains(t, err.Error(), "retained embedded pack org/broken@1.0.0") - - assert.Equal(t, []droppedModule{{module: "org/healthy", version: "1.0.0"}}, reg.modulesDropped, - "a generation whose consumers still resolve it stays open; unrelated modules still finalize") -} - -func TestEmbedPackEffect_RetargetsConsumersBeforeClosingSupersededPack(t *testing.T) { - reg := newStubPackRegistry() - eff := newEffect(reg, nil, []obsoletePack{{module: "org/mod", version: "1.0.0"}}) - eff.retarget = []packRetarget{{module: "org/mod", from: "1.0.0", to: "2.0.0"}} - - require.NoError(t, eff.Finalize(context.Background())) - - assert.Equal(t, []string{"retarget:org/mod@1.0.0->2.0.0", "unregister:org/mod@1.0.0"}, reg.calls, - "consumers of the superseded generation move before its pack closes") -} - -func TestBuildEmbedPackEffect_RetargetsOnlyWhenANewerGenerationIsDesired(t *testing.T) { - dir := t.TempDir() - newPack := filepath.Join(dir, "org", "mod-v2.0.0.wapp") - writeResourceWapp(t, newPack, "ui", "app", map[string]string{"v.txt": "new"}) - - packRegistry := embedpkg.NewRegistry() - defer func() { require.NoError(t, packRegistry.Close()) }() - ctx := embedapi.WithRegistry(newTestContext(), packRegistry) - - handler, err := NewDependencyHandler(DependencyHandlerOptions{ - Hub: &fakeHub{ - getDownload: func(context.Context, *DownloadParams) (*DownloadInfo, error) { - return &DownloadInfo{URL: "memory://new"}, nil - }, - downloadFile: func(_ context.Context, _ string, dest string) error { - data, readErr := os.ReadFile(newPack) - if readErr != nil { - return readErr - } - if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { - return err - } - return os.WriteFile(dest, data, 0600) - }, - }, - Logger: zap.NewNop(), - VendorDir: dir, - }) - require.NoError(t, err) - - snapshot := regapi.State{ - moduleEntry("ui", "app", "org/mod", "1.0.0"), - moduleEntry("ui", "gone", "org/gone", "1.0.0"), - } - controlled := map[string]struct{}{"org/mod": {}, "org/gone": {}} - - eff, err := handler.buildEmbedPackEffect( - ctx, - []ResolvedModule{{Org: "org", Name: "mod", Version: "2.0.0"}}, - fixtureState(snapshot), - controlled, - ) - require.NoError(t, err) - require.NotNil(t, eff) - assert.ElementsMatch(t, []obsoletePack{ - {module: "org/mod", version: "1.0.0"}, - {module: "org/gone", version: "1.0.0"}, - }, eff.obsolete) - assert.Equal(t, []packRetarget{{module: "org/mod", from: "1.0.0", to: "2.0.0"}}, eff.retarget, - "a module leaving the graph has no generation to point at") -} - func TestEmbedPackEffect_PrepareCommit(t *testing.T) { dir := t.TempDir() packPath := filepath.Join(dir, "org", "mod-v1.0.0.wapp") @@ -234,9 +148,11 @@ func TestEmbedPackEffect_PrepareCommit(t *testing.T) { require.NoError(t, eff.Prepare(context.Background())) assert.Contains(t, reg.registered, packPath) + assert.False(t, reg.active[packPath]) require.Len(t, eff.prepared, 1) require.NoError(t, eff.Commit(context.Background())) + assert.True(t, reg.active[packPath]) require.NoError(t, eff.Finalize(context.Background())) // Commit must not unregister the staged pack on a fresh install. assert.Empty(t, reg.unregistered) @@ -273,8 +189,10 @@ func TestEmbedPackEffect_Update(t *testing.T) { require.NoError(t, eff.Prepare(context.Background())) assert.Contains(t, reg.registered, newPack) + assert.False(t, reg.active[newPack]) require.NoError(t, eff.Commit(context.Background())) + assert.True(t, reg.active[newPack]) require.NoError(t, eff.Finalize(context.Background())) // Old version is dropped only after durable finalization; new pack remains registered. require.Len(t, reg.modulesDropped, 1) @@ -346,16 +264,12 @@ func TestEmbedPackEffect_PreparePartialFailureRollsBackStagedPacks(t *testing.T) } func TestObsoletePacksFor(t *testing.T) { - snapshot := regapi.State{ - moduleEntry("ui", "old-app", "org/mod", "1.0.0"), - moduleEntry("ui", "stable", "org/other", "3.0.0"), - moduleEntry("ui", "noversion", "org/nover", ""), - } + current := map[string]string{"org/mod": "1.0.0", "org/other": "3.0.0"} t.Run("update marks changed version obsolete", func(t *testing.T) { desired := map[string]string{"org/mod": "2.0.0", "org/other": "3.0.0"} controlled := map[string]struct{}{"org/mod": {}, "org/other": {}} - obs := obsoletePacksFor(residentModuleVersions(fixtureState(snapshot).Provenance), desired, controlled) + obs := obsoletePacksFor(current, desired, controlled) require.Len(t, obs, 1) assert.Equal(t, obsoletePack{module: "org/mod", version: "1.0.0"}, obs[0]) }) @@ -363,7 +277,7 @@ func TestObsoletePacksFor(t *testing.T) { t.Run("removal marks dropped module obsolete", func(t *testing.T) { desired := map[string]string{"org/other": "3.0.0"} controlled := map[string]struct{}{"org/mod": {}, "org/other": {}} - obs := obsoletePacksFor(residentModuleVersions(fixtureState(snapshot).Provenance), desired, controlled) + obs := obsoletePacksFor(current, desired, controlled) require.Len(t, obs, 1) assert.Equal(t, obsoletePack{module: "org/mod", version: "1.0.0"}, obs[0]) }) @@ -371,14 +285,14 @@ func TestObsoletePacksFor(t *testing.T) { t.Run("unchanged versions are not obsolete", func(t *testing.T) { desired := map[string]string{"org/mod": "1.0.0", "org/other": "3.0.0"} controlled := map[string]struct{}{"org/mod": {}, "org/other": {}} - obs := obsoletePacksFor(residentModuleVersions(fixtureState(snapshot).Provenance), desired, controlled) + obs := obsoletePacksFor(current, desired, controlled) assert.Empty(t, obs) }) t.Run("unrelated modules remain live", func(t *testing.T) { desired := map[string]string{"org/mod": "2.0.0"} controlled := map[string]struct{}{"org/mod": {}} - obs := obsoletePacksFor(residentModuleVersions(fixtureState(snapshot).Provenance), desired, controlled) + obs := obsoletePacksFor(current, desired, controlled) require.Len(t, obs, 1) assert.Equal(t, obsoletePack{module: "org/mod", version: "1.0.0"}, obs[0]) }) @@ -393,7 +307,7 @@ func TestBuildEmbedPackEffect_NoRegistry(t *testing.T) { require.NoError(t, err) // No embed registry installed in context: effect is skipped. - eff, err := handler.buildEmbedPackEffect(newTestContext(), nil, regapi.ProvenancedState{}, nil) + eff, err := handler.buildEmbedPackEffect(newTestContext(), nil, nil) require.NoError(t, err) assert.Nil(t, eff) } @@ -421,9 +335,8 @@ func TestBuildEmbedPackEffect_SkipsUnchangedResolvedPack(t *testing.T) { require.NoError(t, err) resolved := []ResolvedModule{{Org: "org", Name: "mod", Version: "1.0.0"}} - snapshot := regapi.State{moduleEntry("ui", "app", "org/mod", "1.0.0")} - - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), map[string]struct{}{"org/mod": {}}) + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "org/mod", Version: "1.0.0"}) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{"org/mod": {}}) require.NoError(t, err) assert.Nil(t, eff) } @@ -452,12 +365,12 @@ func TestBuildEmbedPackEffect_RejectsSameVersionDifferentDigest(t *testing.T) { const oldDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" const newDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" - snapshotEntry := fixtureOwned(moduleEntry("ui", "app", "org/mod", "1.0.0"), "org/mod", "1.0.0", oldDigest) + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "org/mod", Version: "1.0.0", Digest: oldDigest}) resolved := []ResolvedModule{{ Org: "org", Name: "mod", Version: "1.0.0", Source: moduleSourceHub, Digest: newDigest, }} - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(regapi.State{snapshotEntry}), map[string]struct{}{"org/mod": {}}) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{"org/mod": {}}) require.Error(t, err) assert.Nil(t, eff) assert.Equal(t, "old", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "1.0.0"), "v.txt")) @@ -471,16 +384,10 @@ func TestBuildEmbedPackEffectAcceptsEquivalentDigestEncoding(t *testing.T) { createHubResourceReader(t, "ui", "app", map[string]string{"v.txt": "old"}), nil)) handler := &DependencyHandler{logger: zap.NewNop()} digestValue := strings.Repeat("1", 64) - snapshotEntry := fixtureOwned( - moduleEntry("ui", "app", "org/mod", "1.0.0"), - "org/mod", - "1.0.0", - digestValue, - ) - + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "org/mod", Version: "1.0.0", Digest: digestValue}) effect, err := handler.buildEmbedPackEffect(ctx, []ResolvedModule{{ Org: "org", Name: "mod", Version: "1.0.0", Digest: "sha256:" + digestValue, - }}, fixtureState(regapi.State{snapshotEntry}), map[string]struct{}{"org/mod": {}}) + }}, map[string]struct{}{"org/mod": {}}) require.NoError(t, err) assert.Nil(t, effect) } @@ -511,9 +418,7 @@ func TestBuildEmbedPackEffect_StagesUnchangedPackWhenRegistryMissing(t *testing. require.NoError(t, err) resolved := []ResolvedModule{{Org: "org", Name: "mod", Version: "1.0.0"}} - snapshot := regapi.State{moduleEntry("ui", "app", "org/mod", "1.0.0")} - - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), map[string]struct{}{"org/mod": {}}) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{"org/mod": {}}) require.NoError(t, err) require.NotNil(t, eff) assert.Equal(t, []stagedPack{{packPath: immutablePackPath, module: "org/mod", version: "1.0.0"}}, eff.staged) @@ -549,9 +454,7 @@ func TestBuildEmbedPackEffect_DropsPackWhenResolvedModuleIsDirectory(t *testing. require.NoError(t, err) resolved := []ResolvedModule{{Org: "org", Name: "mod", Version: "1.0.0"}} - snapshot := regapi.State{moduleEntry("ui", "app", "org/mod", "1.0.0")} - - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), map[string]struct{}{"org/mod": {}}) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{"org/mod": {}}) require.NoError(t, err) require.NotNil(t, eff) assert.Empty(t, eff.staged) @@ -559,7 +462,7 @@ func TestBuildEmbedPackEffect_DropsPackWhenResolvedModuleIsDirectory(t *testing. require.NoError(t, eff.Commit(context.Background())) require.NoError(t, eff.Finalize(context.Background())) - _, err = reg.GetFSForEntry(moduleFS(moduleEntry("ui", "app", "org/mod", "1.0.0"))) + _, err = reg.GetFS(regapi.NewID("ui", "app")) require.Error(t, err) } @@ -599,9 +502,7 @@ func TestBuildEmbedPackEffect_DropsPackWhenUnpackModulesEnabled(t *testing.T) { require.NoError(t, err) resolved := []ResolvedModule{{Org: "org", Name: "mod", Version: "1.0.0", Source: moduleSourceHub, Digest: digest}} - snapshot := regapi.State{moduleEntry("ui", "app", "org/mod", "1.0.0")} - - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), map[string]struct{}{"org/mod": {}}) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{"org/mod": {}}) require.NoError(t, err) require.NotNil(t, eff) assert.Empty(t, eff.staged) @@ -609,7 +510,7 @@ func TestBuildEmbedPackEffect_DropsPackWhenUnpackModulesEnabled(t *testing.T) { require.NoError(t, eff.Commit(context.Background())) require.NoError(t, eff.Finalize(context.Background())) - _, err = reg.GetFSForEntry(moduleFS(moduleEntry("ui", "app", "org/mod", "1.0.0"))) + _, err = reg.GetFS(regapi.NewID("ui", "app")) require.Error(t, err) } @@ -647,16 +548,15 @@ func TestBuildEmbedPackEffect_StagesOnlyChangedPacks(t *testing.T) { require.NoError(t, err) resolved := []ResolvedModule{ - {Org: "org", Name: "mod", Version: "2.0.0"}, + {Org: "org", Name: "mod", Version: "2.0.0", Digest: newDigest}, {Org: "org", Name: "stable", Version: "1.0.0"}, } - snapshot := regapi.State{ - moduleEntry("ui", "app", "org/mod", "1.0.0"), - moduleEntry("ui", "stable", "org/stable", "1.0.0"), - moduleEntry("ui", "removed", "org/removed", "3.0.0"), - } - - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), map[string]struct{}{ + ctx = withCurrentResolution(ctx, + regapi.ResolvedModule{Name: "org/mod", Version: "1.0.0"}, + regapi.ResolvedModule{Name: "org/stable", Version: "1.0.0"}, + regapi.ResolvedModule{Name: "org/removed", Version: "3.0.0"}, + ) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, map[string]struct{}{ "org/mod": {}, "org/stable": {}, "org/removed": {}, }) require.NoError(t, err) @@ -668,13 +568,14 @@ func TestBuildEmbedPackEffect_StagesOnlyChangedPacks(t *testing.T) { }, eff.obsolete) require.NoError(t, eff.Prepare(context.Background())) - assert.Equal(t, "1", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "1.0.0"), "v.txt")) + assert.Equal(t, "2", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "2.0.0"), "v.txt")) assert.Equal(t, "2", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "2.0.0"), "v.txt")) assert.Equal(t, "stable", readHubResource(t, reg, moduleEntry("ui", "stable", "org/stable", "1.0.0"), "v.txt")) require.NoError(t, eff.Commit(context.Background())) - // Commit is still reversible; obsolete packs remain until history/head is durable. - assert.Equal(t, "1", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "1.0.0"), "v.txt")) + // The active entry ID switches to the staged pack; finalization only removes + // obsolete packs after the history transition is durable. + assert.Equal(t, "2", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "2.0.0"), "v.txt")) require.NoError(t, eff.Finalize(context.Background())) assert.Equal(t, "2", readHubResource(t, reg, moduleEntry("ui", "app", "org/mod", "2.0.0"), "v.txt")) assert.Equal(t, "stable", readHubResource(t, reg, moduleEntry("ui", "stable", "org/stable", "1.0.0"), "v.txt")) @@ -712,10 +613,9 @@ func TestBuildEmbedPackEffect_InstallPreservesUnrelatedApplicationPack(t *testin require.NoError(t, err) resolved := []ResolvedModule{{Org: "spiralscout", Name: "crm", Version: "0.1.18"}} - snapshot := regapi.State{moduleEntry("app", "app_fs", "kickside/kickside", "0.1.63")} controlled := map[string]struct{}{"spiralscout/crm": {}} - eff, err := handler.buildEmbedPackEffect(ctx, resolved, fixtureState(snapshot), controlled) + eff, err := handler.buildEmbedPackEffect(ctx, resolved, controlled) require.NoError(t, err) require.NotNil(t, eff) assert.Empty(t, eff.obsolete) @@ -910,9 +810,9 @@ func TestSourceEffectUnpackedModulePrepareAndRollback(t *testing.T) { "org/unrelated": {LoadPath: "/old/unrelated", ResourceRoot: "/old/unrelated", Owner: "org/unrelated", Sequence: 2}, }) var loadedSources moduleapi.Sources - sourceRegistry.SetLoader(func(_ context.Context, sources moduleapi.Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + sourceRegistry.SetLoader(func(_ context.Context, sources moduleapi.Sources) ([]regapi.Entry, error) { loadedSources = sources - return nil, nil, nil + return nil, nil }) resolved := ResolvedModule{Org: "org", Name: "mod", Version: "1.0.0", Digest: packDigest, SizeBytes: packSize} @@ -986,9 +886,9 @@ func TestSourceEffectPackedModuleTracksLoadIdentityWithoutExposingRoot(t *testin sourceRegistry := moduleapi.NewSourceRegistry() ctx = moduleapi.WithSourceRegistry(ctx, sourceRegistry) var loadedSources moduleapi.Sources - sourceRegistry.SetLoader(func(_ context.Context, sources moduleapi.Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { + sourceRegistry.SetLoader(func(_ context.Context, sources moduleapi.Sources) ([]regapi.Entry, error) { loadedSources = sources - return nil, nil, nil + return nil, nil }) packPath := filepath.Join(vendorDir, "org", "mod-1.0.0.wapp") @@ -1022,20 +922,9 @@ func TestSourceEffectPackedModuleTracksLoadIdentityWithoutExposingRoot(t *testin assert.NotContains(t, loadedSources, "org/mod") } -// moduleFS pairs a fixture entry with the provenance the embed registry pins -// its filesystem to. -func moduleFS(entry regapi.Entry) (regapi.Entry, *regapi.EntryProvenance) { - clean, record := fixtureEntryProvenance(entry) - return clean, &record -} - func moduleEntry(ns, name, module, version string) regapi.Entry { - meta := attrs.NewBag() - meta.Set(fixtureModuleKey, module) - if version != "" { - meta.Set(fixtureModuleVersionKey, version) - } - return regapi.Entry{ID: regapi.NewID(ns, name), Meta: meta} + _ = version + return regapi.Entry{ID: regapi.NewID(ns, name), Registry: regapi.EntryMetadata{Owner: module}} } func createHubResourceReader(t *testing.T, ns, name string, files map[string]string) *wapp.Reader { @@ -1081,7 +970,7 @@ func writeEmbeddedFSWapp(t *testing.T, path, ns, name string, files map[string]s func readHubResource(t *testing.T, reg *embedpkg.Registry, entry regapi.Entry, name string) string { t.Helper() - fsys, err := reg.GetFSForEntry(moduleFS(entry)) + fsys, err := reg.GetFS(entry.ID) require.NoError(t, err) data, err := fs.ReadFile(fsys, name) require.NoError(t, err) diff --git a/boot/deps/hub/entry_metadata.go b/boot/deps/hub/entry_metadata.go new file mode 100644 index 000000000..dbe36360d --- /dev/null +++ b/boot/deps/hub/entry_metadata.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "context" + + regapi "github.com/wippyai/runtime/api/registry" +) + +func entryModule(entry regapi.Entry) string { + return entry.Registry.Owner +} + +func markModuleEntry(entry regapi.Entry, module string) regapi.Entry { + entry.Registry.Owner = module + return entry +} + +func moduleIdentities(resolution *regapi.DependencyResolution) (map[string]string, map[string]string) { + versions := make(map[string]string) + digests := make(map[string]string) + if resolution == nil { + return versions, digests + } + for _, module := range resolution.Modules { + versions[module.Name] = module.Version + digests[module.Name] = module.Digest + } + return versions, digests +} + +func (h *DependencyHandler) currentModuleIdentities(ctx context.Context) (map[string]string, map[string]string) { + if resolution := h.currentResolution(ctx); resolution != nil { + return moduleIdentities(resolution) + } + versions, digests := moduleIdentities(nil) + if h == nil { + return versions, digests + } + if h.deployment != nil { + for _, module := range h.deployment.Modules { + versions[module.Name] = module.Version + digests[module.Name] = module.Digest + } + return versions, digests + } + if h.lock == nil { + return versions, digests + } + for _, module := range h.lock.GetModules() { + if _, exists := versions[module.Name]; !exists { + versions[module.Name] = module.Version + } + if _, exists := digests[module.Name]; !exists { + digests[module.Name] = module.Hash + } + } + return versions, digests +} + +func (h *DependencyHandler) currentResolution(ctx context.Context) *regapi.DependencyResolution { + if reg := regapi.GetRegistry(ctx); reg != nil { + return reg.Snapshot().Registry.Resolution + } + return nil +} + +func (h *DependencyHandler) offlineModules(resolution *regapi.DependencyResolution) []regapi.ResolvedModule { + capacity := 0 + if resolution != nil { + capacity += len(resolution.Modules) + } + if h != nil && h.lock != nil { + capacity += len(h.lock.GetModules()) + } else if h != nil && h.deployment != nil { + capacity += len(h.deployment.Modules) + } + modules := make([]regapi.ResolvedModule, 0, capacity) + if resolution != nil { + modules = append(modules, resolution.Modules...) + } + if h != nil && h.lock != nil { + for _, locked := range h.lock.GetModules() { + modules = append(modules, regapi.ResolvedModule{ + Name: locked.Name, Version: locked.Version, VersionID: locked.Version, + Source: moduleSourceHub, Digest: locked.Hash, + }) + } + } else if h != nil && h.deployment != nil { + modules = append(modules, h.deployment.Modules...) + } + return modules +} diff --git a/boot/deps/hub/entry_provenance.go b/boot/deps/hub/entry_provenance.go deleted file mode 100644 index f48b8eec9..000000000 --- a/boot/deps/hub/entry_provenance.go +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package hub - -import ( - regapi "github.com/wippyai/runtime/api/registry" -) - -// Provenance is registry-owned. Ownership, resident artifact identity and -// deployment-root selection come from the ProvenanceMap that travels with a state; -// entry Data and Meta are author payload and are never read for them. - -type moduleOwner struct { - name string - version string - digest string -} - -func moduleOwnersByNamespace(modules []ResolvedModule) map[string]moduleOwner { - owners := make(map[string]moduleOwner, len(modules)) - for _, mod := range modules { - if mod.Org == "" || mod.Name == "" { - continue - } - namespace := mod.Org + "." + mod.Name - owners[namespace] = moduleOwner{ - name: mod.Org + "/" + mod.Name, - version: mod.Version, - digest: mod.Digest, - } - } - return owners -} - -// provIndex holds a state's provenance keyed the way the planner keys entries, -// so a directive input carrying ids that are not canonical yet still resolves. -type provIndex map[string]regapi.EntryProvenance - -func provByKey(prov regapi.ProvenanceMap) provIndex { - index := make(provIndex, len(prov)) - for id, record := range prov { - index[idKey(id)] = record - } - return index -} - -// lookup returns the record for one entry. A missing record violates the -// total-map invariant: it is an error, never a host-authored default. -func (p provIndex) lookup(id regapi.ID) (regapi.EntryProvenance, error) { - record, ok := p[idKey(id)] - if !ok { - return regapi.EntryProvenance{}, regapi.NewMissingProvenanceError(id) - } - return record, nil -} - -// stateProvenance indexes a provenanced state and enforces the total-map -// invariant over its entries, so every later lookup in the same pass resolves. -func stateProvenance(state regapi.ProvenancedState) (provIndex, error) { - index := provByKey(state.Provenance) - for _, entry := range state.Entries { - if _, err := index.lookup(entry.ID); err != nil { - return nil, err - } - } - return index, nil -} - -// operationProvenance returns the effective record for an operation's entry: -// the operation's own record when it carries one, the record already resident -// for that id otherwise, and an explicit host record for an entry that does not -// exist yet. -func operationProvenance(op regapi.Operation, resident provIndex) regapi.EntryProvenance { - if op.Provenance != nil { - return *op.Provenance - } - if record, ok := resident[idKey(op.Entry.ID)]; ok { - return record - } - return regapi.EntryProvenance{} -} - -// residentModuleVersions folds a state's provenance into the version of the -// artifact resident for each module. Entries of one module that disagree leave -// it without a resident version — the same unknown as a module whose records -// carry none. -func residentModuleVersions(prov regapi.ProvenanceMap) map[string]string { - versions := make(map[string]string) - ambiguous := make(map[string]struct{}) - for _, record := range prov { - if record.Module == "" || record.Version == "" { - continue - } - if _, bad := ambiguous[record.Module]; bad { - continue - } - if existing, seen := versions[record.Module]; seen && existing != record.Version { - delete(versions, record.Module) - ambiguous[record.Module] = struct{}{} - continue - } - versions[record.Module] = record.Version - } - return versions -} - -// residentModuleDigests folds a state's provenance into the digest of the -// artifact resident for each module, with the same disagreement rule as -// residentModuleVersions. -func residentModuleDigests(prov regapi.ProvenanceMap) map[string]string { - digests := make(map[string]string) - ambiguous := make(map[string]struct{}) - for _, record := range prov { - if record.Module == "" || record.Digest == "" { - continue - } - if _, bad := ambiguous[record.Module]; bad { - continue - } - if existing, seen := digests[record.Module]; seen && !artifactDigestsEqual(existing, record.Digest) { - delete(digests, record.Module) - ambiguous[record.Module] = struct{}{} - continue - } - digests[record.Module] = record.Digest - } - return digests -} - -// residentProvenanceAdvance returns the resident records that move for entries -// no operation touches. A module update whose entries are byte-identical -// produces no operation, yet its artifact identity did move: reporting it here -// advances the resident record with no entry event, so the next reconciliation -// compares an identity matching the selection and does not reload. -// -// An entry the planner deliberately left resident while its desired content -// differs is excluded: its bytes still come from the old artifact, so its -// record must keep naming that artifact. -func residentProvenanceAdvance( - current, desired regapi.ProvenancedState, - ops []regapi.Operation, - skipKey string, -) regapi.ProvenanceMap { - touched := make(map[string]struct{}, len(ops)) - for _, op := range ops { - touched[idKey(op.Entry.ID)] = struct{}{} - } - currentEntries := entriesByID(current.Entries) - currentProv := provByKey(current.Provenance) - desiredProv := provByKey(desired.Provenance) - - var advance regapi.ProvenanceMap - for _, entry := range desired.Entries { - key := idKey(entry.ID) - if key == skipKey { - continue - } - if _, changed := touched[key]; changed { - continue - } - resident, ok := currentEntries[key] - if !ok { - continue - } - record := desiredProv[key] - if record == currentProv[key] || !entriesEqual(resident, entry) { - continue - } - if advance == nil { - advance = make(regapi.ProvenanceMap) - } - advance[entry.ID] = record - } - return advance -} - -// loadedProvenance is an ownership claim made while loading module artifacts: -// the record itself plus whether the claiming source is a local replacement. -type loadedProvenance struct { - record regapi.EntryProvenance - replacement bool -} - -// claimEntryProvenance resolves the owner of an entry a module artifact just -// produced. Ownership is decided in one pass over provenance data: -// -// - a replacement is a mutable development source and is authoritative over -// hub selection: its claim wins unconditionally, and no non-replacement -// claim ever displaces a replacement-owned record; -// - otherwise the record resident for that id keeps its owner, refreshed to -// the loading module's identity when that owner is the loading module; -// - otherwise the module declaring the entry's namespace owns it; -// - otherwise the loading module owns it. -// -// Root is deployment-context state and is applied by the loader, which knows -// whether the loading module is the deployed application. -func claimEntryProvenance( - entry regapi.Entry, - loading moduleOwner, - loadingIsReplacement bool, - staged loadedProvenance, - hasStaged bool, - resident provIndex, - namespaceOwners map[string]moduleOwner, -) loadedProvenance { - claim := loadedProvenance{ - record: regapi.EntryProvenance{Module: loading.name, Version: loading.version, Digest: loading.digest}, - replacement: loadingIsReplacement, - } - if loadingIsReplacement { - return claim - } - if hasStaged && staged.replacement { - return staged - } - if record, ok := resident[idKey(entry.ID)]; ok && record.Module != "" { - if record.Module != loading.name { - claim.record = regapi.EntryProvenance{Module: record.Module, Version: record.Version, Digest: record.Digest} - } - return claim - } - if owner, ok := namespaceOwners[entry.ID.NS]; ok && owner.name != "" { - claim.record = regapi.EntryProvenance{Module: owner.name, Version: owner.version, Digest: owner.digest} - } - return claim -} diff --git a/boot/deps/hub/legacy_root_onboarding_test.go b/boot/deps/hub/legacy_root_onboarding_test.go deleted file mode 100644 index 003c85f69..000000000 --- a/boot/deps/hub/legacy_root_onboarding_test.go +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package hub - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/payload" - regapi "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" - registryimpl "github.com/wippyai/runtime/system/registry" - regexp "github.com/wippyai/runtime/system/registry/expansion" - historymem "github.com/wippyai/runtime/system/registry/history/memory" - "github.com/wippyai/runtime/system/registry/topology" - "github.com/wippyai/wapp" - "go.uber.org/zap" -) - -// TestDependencyHandlerColdBootInstallsLegacyPromotedRoot covers the complete -// onboarding boundary. A pre-provenance history update promotes a dependency -// declared by an installed module to an application root. Cold boot must both -// retain that promotion and materialize the newly required module from Hub. -func TestDependencyHandlerColdBootInstallsLegacyPromotedRoot(t *testing.T) { - ctx := newTestContext() - vendorDir := filepath.Join(t.TempDir(), "vendor") - artifact := buildWappBytes(t, []wapp.Entry{{ - ID: wapp.NewID("kickside.kb10", "service"), - Kind: "service", - Data: map[string]any{"ready": true}, - }}) - sum := sha256.Sum256(artifact) - digest := "sha256:" + hex.EncodeToString(sum[:]) - downloads := 0 - client := &fakeHub{ - getManifest: func(_ context.Context, org, module, constraint string) (*ModuleManifest, error) { - require.Equal(t, "kickside", org) - require.Equal(t, "kb10", module) - require.Equal(t, "1.0.0", constraint) - return &ModuleManifest{ - Org: org, Name: module, Version: constraint, VersionID: constraint, - Digest: digest, SizeBytes: uint64(len(artifact)), URL: "memory://kickside/kb10", - }, nil - }, - downloadFile: func(_ context.Context, _ string, destination string) error { - downloads++ - if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { - return err - } - return os.WriteFile(destination, artifact, 0o600) - }, - } - - dependencyID := regapi.NewID("kickside.knowledge.requirements", "kb10") - dependency := regapi.Entry{ - ID: dependencyID, - Kind: regapi.NamespaceDependency, - Data: payload.New(map[string]any{ - "component": "kickside/kb10", - "version": "1.0.0", - }), - } - baseline := regapi.ProvenancedState{ - Entries: regapi.State{dependency}, - Provenance: regapi.ProvenanceMap{ - dependencyID: {Module: "kickside/knowledge", Version: "1.0.0", Digest: "sha256:knowledge"}, - }, - } - legacyUpdate := dependency - legacyUpdate.DependencyRoot = true - v0 := version.New(regapi.RootVersion) - v1 := version.FromParent(v0, 1) - history := historymem.New() - require.NoError(t, history.Save(v1, regapi.ChangeSet{{Kind: regapi.EntryUpdate, Entry: legacyUpdate}}, true)) - - resolver := topology.NewResolver() - handler, err := NewDependencyHandler(DependencyHandlerOptions{ - Hub: client, Logger: zap.NewNop(), Resolver: resolver, - LockPath: filepath.Join(t.TempDir(), "wippy.lock"), VendorDir: vendorDir, - }) - require.NoError(t, err) - reg := registryimpl.NewRegistry( - history, - &bootRecordingRunner{}, - topology.NewStateBuilder(zap.NewNop(), resolver), - resolver, - zap.NewNop(), - registryimpl.WithKindDirective( - regapi.NamespaceDependency, - regexp.NewDependencyDirective(handler.Expand).WithResolutionTransition(handler.ReconcileResolution), - ), - ) - - require.NoError(t, reg.LoadState(ctx, baseline, v1)) - require.Equal(t, []regapi.ID{dependencyID}, reg.DependencyRoots()) - require.Equal(t, 1, downloads, "cold onboarding must fetch the newly required KB10 artifact") - installed, err := reg.GetEntry(regapi.NewID("kickside.kb10", "service")) - require.NoError(t, err) - require.Equal(t, true, installed.Data.Data().(map[string]any)["ready"]) - record, ok := reg.EntryProvenance(installed.ID) - require.True(t, ok) - require.Equal(t, "kickside/kb10", record.Module) -} diff --git a/boot/deps/hub/locked_manifest_provider.go b/boot/deps/hub/locked_manifest_provider.go index 28bf29473..f3be0c02a 100644 --- a/boot/deps/hub/locked_manifest_provider.go +++ b/boot/deps/hub/locked_manifest_provider.go @@ -5,6 +5,8 @@ package hub import ( "context" "fmt" + "sort" + "strings" "github.com/wippyai/runtime/api/payload" regapi "github.com/wippyai/runtime/api/registry" @@ -16,37 +18,42 @@ import ( // replacements without granting that resolver any network capability. type lockedManifestProvider struct { handler *DependencyHandler - modules map[string]ResolvedModule + modules map[string]map[string]ResolvedModule } func newLockedManifestProvider( handler *DependencyHandler, - materializedVersions map[string]string, - lockedDigests map[string]string, + selectedModules []regapi.ResolvedModule, ) ManifestProvider { provider := &lockedManifestProvider{ handler: handler, - modules: make(map[string]ResolvedModule), + modules: make(map[string]map[string]ResolvedModule), } - if handler == nil || handler.lock == nil { + if handler == nil { return provider } - for _, locked := range handler.lock.GetModules() { - name, err := graph.ParseName(locked.Name) - if err != nil || locked.Version == "" || materializedVersions[locked.Name] != locked.Version { + for _, selected := range selectedModules { + name, err := graph.ParseName(selected.Name) + if err != nil || selected.Version == "" { continue } - digest := lockedDigests[locked.Name+"@"+locked.Version] - if err := validateModuleArtifactIdentity(name, locked.Version, digest); err != nil || digest == "" { + if err := validateStoredModuleArtifactIdentity(name, selected.Version, selected.Source, selected.Digest); err != nil { continue } - provider.modules[locked.Name] = ResolvedModule{ + versions := provider.modules[selected.Name] + if versions == nil { + versions = make(map[string]ResolvedModule) + provider.modules[selected.Name] = versions + } + versions[strings.TrimPrefix(selected.Version, "v")] = ResolvedModule{ Org: name.Organization, Name: name.Module, - Version: locked.Version, - VersionID: locked.Version, - Source: moduleSourceHub, - Digest: digest, + Version: selected.Version, + VersionID: selected.VersionID, + Source: selected.Source, + Digest: selected.Digest, + SizeBytes: selected.SizeBytes, + Protected: selected.Protected, } } return provider @@ -54,8 +61,9 @@ func newLockedManifestProvider( func (p *lockedManifestProvider) GetManifest(ctx context.Context, org, module, constraint string) (*ModuleManifest, error) { name := org + "/" + module - mod, ok := p.modules[name] - if !ok || !storedVersionSatisfies(mod.Version, constraint) { + versions := p.modules[name] + mod, ok := versions[strings.TrimPrefix(constraint, "v")] + if !ok { return nil, NewDependencyOfflineError("resolve manifest", name) } transcoder := payload.GetTranscoder(ctx) @@ -83,11 +91,16 @@ func (p *lockedManifestProvider) GetManifest(ctx context.Context, org, module, c func (p *lockedManifestProvider) ListAllVersions(_ context.Context, org, module string) ([]VersionInfo, error) { name := org + "/" + module - mod, ok := p.modules[name] - if !ok { + modules := p.modules[name] + if len(modules) == 0 { return nil, NewDependencyOfflineError("list versions", name) } - return []VersionInfo{{Version: mod.Version}}, nil + versions := make([]VersionInfo, 0, len(modules)) + for _, mod := range modules { + versions = append(versions, VersionInfo{Version: mod.Version}) + } + sort.Slice(versions, func(i, j int) bool { return versions[i].Version < versions[j].Version }) + return versions, nil } func manifestDependenciesFromEntries( diff --git a/boot/deps/hub/locked_resolution_test.go b/boot/deps/hub/locked_resolution_test.go index bde6b622c..558c20409 100644 --- a/boot/deps/hub/locked_resolution_test.go +++ b/boot/deps/hub/locked_resolution_test.go @@ -67,6 +67,7 @@ func TestResolveEffectiveModulesDoesNotCallHubForExactLock(t *testing.T) { ctx, []DependencyDefinition{{Component: "acme/app", Version: "1.2.3"}}, map[string]string{"acme/app": "1.2.3"}, + nil, ) require.NoError(t, err) require.Len(t, resolved, 1) @@ -141,6 +142,7 @@ func TestVerifiedOfflineResolutionNeverFallsBackToHub(t *testing.T) { ctx, []DependencyDefinition{{Component: "acme/app", Version: "1.2.3"}}, map[string]string{"acme/app": "1.2.3"}, + nil, ) require.Error(t, err) var apiErr apierror.Error @@ -211,6 +213,7 @@ func TestVerifiedOfflineResolverUsesInstalledModuleGraph(t *testing.T) { resolved, err := handler.resolveEffectiveModules(ctx, []DependencyDefinition{{Component: "acme/app", Version: "v1.0.0"}}, map[string]string{"acme/app": "v1.0.0", "acme/lib": "v1.0.0"}, + nil, ) require.NoError(t, err) require.Len(t, resolved, 2) diff --git a/boot/deps/hub/operation_planner.go b/boot/deps/hub/operation_planner.go index f5fd5a5cc..daf5919f7 100644 --- a/boot/deps/hub/operation_planner.go +++ b/boot/deps/hub/operation_planner.go @@ -10,10 +10,8 @@ import ( regapi "github.com/wippyai/runtime/api/registry" ) -// entriesEqual compares author content only. Ownership and deployment-root -// selection are provenance, compared separately. func entriesEqual(a, b regapi.Entry) bool { - if !idsEqual(a.ID, b.ID) || a.Kind != b.Kind { + if !idsEqual(a.ID, b.ID) || a.Kind != b.Kind || a.Registry != b.Registry { return false } if !reflect.DeepEqual(a.Meta, b.Meta) { @@ -41,33 +39,18 @@ type operationPlanner struct { resolver regapi.DependencyResolver } -// plan diffs a provenanced current state against a provenanced desired state. -// Both provenance maps are total over their entries; a missing record is a hard -// error, so ownership is never inferred from an absent one. -func (p operationPlanner) plan(current, desired regapi.ProvenancedState, opts operationPlanOptions) ([]regapi.Operation, error) { - currentProv, err := stateProvenance(current) - if err != nil { - return nil, err - } - desiredProv, err := stateProvenance(desired) - if err != nil { - return nil, err +func (p operationPlanner) plan(current regapi.State, desired []regapi.Entry, opts operationPlanOptions) ([]regapi.Operation, error) { + currentByID := make(map[string]regapi.Entry, len(current)) + for _, entry := range current { + currentByID[idKey(entry.ID)] = entry } - currentByID := make(map[string]regapi.Entry, len(current.Entries)) - currentOwners := make(map[string]string, len(current.Entries)) - for _, entry := range current.Entries { - key := idKey(entry.ID) - currentByID[key] = entry - currentOwners[key] = currentProv[key].Module - } - - desiredByID := make(map[string]regapi.Entry, len(desired.Entries)) - for _, entry := range desired.Entries { + desiredByID := make(map[string]regapi.Entry, len(desired)) + for _, entry := range desired { desiredByID[idKey(entry.ID)] = entry } - recreate, err := p.kindReplacementClosure(currentByID, desiredByID, currentOwners, opts) + recreate, err := p.kindReplacementClosure(currentByID, desiredByID, opts) if err != nil { return nil, err } @@ -78,29 +61,25 @@ func (p operationPlanner) plan(current, desired regapi.ProvenancedState, opts op if key == originalKey { continue } - desiredRecord := desiredProv[key] if existing, ok := currentByID[key]; ok { - existingRecord := currentProv[key] - if entryConflict(existingRecord, desiredRecord) { - return nil, NewDependencyEntryConflictError(entry.ID.String(), existingRecord.Module, desiredRecord.Module) + if entryConflict(existing, entry) { + return nil, NewDependencyEntryConflictError(entry.ID.String(), entryModule(existing), entryModule(entry)) } if _, replace := recreate[key]; replace { ops = append(ops, - regapi.Operation{Kind: regapi.EntryDelete, Entry: existing, Provenance: provenancePtr(existingRecord)}, - regapi.Operation{Kind: regapi.EntryCreate, Entry: entry, Provenance: provenancePtr(desiredRecord)}, + regapi.Operation{Kind: regapi.EntryDelete, Entry: existing}, + regapi.Operation{Kind: regapi.EntryCreate, Entry: entry}, ) continue } - // A deployment-root transition changes no author content: it is a - // provenance delta and still produces exactly one operation. - if !entriesEqual(existing, entry) || existingRecord.Root != desiredRecord.Root { - if preserveImmutableResidentEntry(existingRecord, desiredRecord, opts.mutableModules) { + if !entriesEqual(existing, entry) { + if preserveImmutableResidentEntry(existing, entry, opts.mutableModules) { continue } - ops = append(ops, regapi.Operation{Kind: regapi.EntryUpdate, Entry: entry, Provenance: provenancePtr(desiredRecord)}) + ops = append(ops, regapi.Operation{Kind: regapi.EntryUpdate, Entry: entry}) } } else { - ops = append(ops, regapi.Operation{Kind: regapi.EntryCreate, Entry: entry, Provenance: provenancePtr(desiredRecord)}) + ops = append(ops, regapi.Operation{Kind: regapi.EntryCreate, Entry: entry}) } } @@ -111,31 +90,22 @@ func (p operationPlanner) plan(current, desired regapi.ProvenancedState, opts op if _, ok := desiredByID[key]; ok { continue } - if module := currentOwners[key]; module != "" { + if module := entryModule(entry); module != "" { if opts.controlledModules != nil { if _, ok := opts.controlledModules[module]; !ok { continue } } - if hasLiveDependent(entry.ID, currentByID, desiredByID, currentOwners, opts.controlledModules, p.resolver) { + if hasLiveDependent(entry.ID, currentByID, desiredByID, opts.controlledModules, p.resolver) { continue } - record := currentProv[key] - ops = append(ops, regapi.Operation{ - Kind: regapi.EntryDelete, - Entry: regapi.Entry{ID: entry.ID}, - Provenance: provenancePtr(record), - }) + ops = append(ops, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: entry.ID}}) } } return ops, nil } -func provenancePtr(record regapi.EntryProvenance) *regapi.EntryProvenance { - return &record -} - // kindReplacementClosure returns the entries that must be recreated when an // entry changes kind. Registry kinds are immutable, so the provider must be // deleted and created. Every still-live dependent must cross that boundary in @@ -144,7 +114,6 @@ func provenancePtr(record regapi.EntryProvenance) *regapi.EntryProvenance { func (p operationPlanner) kindReplacementClosure( currentByID map[string]regapi.Entry, desiredByID map[string]regapi.Entry, - currentOwners map[string]string, opts operationPlanOptions, ) (map[string]struct{}, error) { recreate := make(map[string]struct{}) @@ -167,7 +136,7 @@ func (p operationPlanner) kindReplacementClosure( } effective, present := desiredByID[key] if !present { - if missingDesiredEntryWillBeDeleted(currentOwners[key], opts.controlledModules) { + if missingDesiredEntryWillBeDeleted(current, opts.controlledModules) { continue } effective = current @@ -223,7 +192,6 @@ func hasLiveDependent( target regapi.ID, currentByID map[string]regapi.Entry, desiredByID map[string]regapi.Entry, - currentOwners map[string]string, controlledModules map[string]struct{}, resolver regapi.DependencyResolver, ) bool { @@ -239,7 +207,7 @@ func hasLiveDependent( check := current if desired, ok := desiredByID[key]; ok { check = desired - } else if missingDesiredEntryWillBeDeleted(currentOwners[key], controlledModules) { + } else if missingDesiredEntryWillBeDeleted(current, controlledModules) { continue } if entryDependsOn(check, target, universe, resolver) { @@ -249,7 +217,8 @@ func hasLiveDependent( return false } -func missingDesiredEntryWillBeDeleted(module string, controlledModules map[string]struct{}) bool { +func missingDesiredEntryWillBeDeleted(entry regapi.Entry, controlledModules map[string]struct{}) bool { + module := entryModule(entry) if module == "" { return false } @@ -326,33 +295,30 @@ func resolveDependencyRef(sourceNS string, dep string) regapi.ID { return regapi.NewID(sourceNS, dep) } -// preserveImmutableResidentEntry prevents normalization from rewriting an -// installed artifact that was not selected as mutable. The comparison is -// per-entry resident identity: the same module at the same resident version. -func preserveImmutableResidentEntry(existing, desired regapi.EntryProvenance, mutableModules map[string]struct{}) bool { +// preserveImmutableResidentEntry prevents metadata normalization from +// rewriting an installed artifact that was not selected as mutable. +func preserveImmutableResidentEntry(existing, desired regapi.Entry, mutableModules map[string]struct{}) bool { if mutableModules == nil { return false } - if existing.Root != desired.Root { - return false - } - if desired.Module == "" || desired.Module != existing.Module { + if existing.Registry != desired.Registry { return false } - if _, mutable := mutableModules[desired.Module]; mutable { + module := entryModule(desired) + if module == "" || module != entryModule(existing) { return false } - if existing.Version != "" && desired.Version != "" && existing.Version != desired.Version { + if _, mutable := mutableModules[module]; mutable { return false } return true } -// entryConflict reports a module claiming an id that another module or the host -// already owns. Adoption of a host entry by a module is not a flow. -func entryConflict(existing, desired regapi.EntryProvenance) bool { - if desired.Module == "" { +func entryConflict(existing, desired regapi.Entry) bool { + desiredModule := entryModule(desired) + if desiredModule == "" { return false } - return existing.Module == "" || existing.Module != desired.Module + existingModule := entryModule(existing) + return existingModule == "" || existingModule != desiredModule } diff --git a/boot/deps/hub/operation_planner_test.go b/boot/deps/hub/operation_planner_test.go index 357f3034e..4c682221d 100644 --- a/boot/deps/hub/operation_planner_test.go +++ b/boot/deps/hub/operation_planner_test.go @@ -31,24 +31,6 @@ func TestOperationPlanner_MutableArtifactBoundary(t *testing.T) { return entry }, }, - { - name: "immutable digest metadata drift", - mutate: func(entry regapi.Entry) regapi.Entry { - entry.Meta.Set(fixtureModuleDigestKey, "sha256:new") - return entry - }, - }, - { - // Resident identity is not author content: a module that ships the - // same bytes at a new artifact identity produces no operation, even - // while it is being updated. - name: "mutable digest change alone", - mutable: true, - mutate: func(entry regapi.Entry) regapi.Entry { - entry.Meta.Set(fixtureModuleDigestKey, "sha256:new") - return entry - }, - }, { name: "immutable data normalization", mutate: func(entry regapi.Entry) regapi.Entry { @@ -65,17 +47,10 @@ func TestOperationPlanner_MutableArtifactBoundary(t *testing.T) { }, wantKinds: []string{regapi.EntryUpdate}, }, - { - name: "version change alone", - mutate: func(entry regapi.Entry) regapi.Entry { - entry.Meta.Set(fixtureModuleVersionKey, "v2.0.0") - return entry - }, - }, { name: "owner conflict remains effective", mutate: func(entry regapi.Entry) regapi.Entry { - entry.Meta.Set(fixtureModuleKey, "other/service") + entry.Registry.Owner = "other/service" return entry }, wantError: true, @@ -96,10 +71,10 @@ func TestOperationPlanner_MutableArtifactBoundary(t *testing.T) { desired := tt.mutate(clonePlannerTestEntry(current)) mutable := map[string]struct{}{} if tt.mutable { - mutable[fixtureProvenance(desired).Module] = struct{}{} + mutable[entryModule(desired)] = struct{}{} } - ops, err := (operationPlanner{}).plan(fixtureState(regapi.State{current}), fixtureState([]regapi.Entry{desired}), operationPlanOptions{ + ops, err := (operationPlanner{}).plan(regapi.State{current}, []regapi.Entry{desired}, operationPlanOptions{ controlledModules: map[string]struct{}{"acme/service": {}}, mutableModules: mutable, }) @@ -127,40 +102,31 @@ func TestOperationPlanner_DependencyRootTransitionsRemainEffective(t *testing.T) } { t.Run(test.name, func(t *testing.T) { current := plannerTestEntry(regapi.NamespaceDependency, "acme/deployment", "v1.0.0", "sha256:a", "root") - current.DependencyRoot = test.from + current.Registry.Root = test.from desired := clonePlannerTestEntry(current) - desired.DependencyRoot = test.to + desired.Registry.Root = test.to - currentState := fixtureState(regapi.State{current}) - ops, err := (operationPlanner{}).plan(currentState, fixtureState([]regapi.Entry{desired}), operationPlanOptions{ + ops, err := (operationPlanner{}).plan(regapi.State{current}, []regapi.Entry{desired}, operationPlanOptions{ controlledModules: map[string]struct{}{"acme/deployment": {}}, mutableModules: map[string]struct{}{}, }) require.NoError(t, err) - require.Len(t, ops, 1, "a root transition produces exactly one operation") + require.Len(t, ops, 1) assert.Equal(t, regapi.EntryUpdate, ops[0].Kind) - require.NotNil(t, ops[0].Provenance) - assert.Equal(t, test.to, ops[0].Provenance.Root) - assert.True(t, entriesEqual(currentState.Entries[0], ops[0].Entry), "a root transition changes no author content") + assert.Equal(t, test.to, ops[0].Entry.Registry.Root) }) } } func TestOperationPlanner_NilMutableSetUsesNormalDiff(t *testing.T) { current := plannerTestEntry("service", "acme/service", "v1.0.0", "sha256:old", "same") - changed := clonePlannerTestEntry(current) - changed.Data = payload.New(map[string]any{"value": "changed"}) + desired := clonePlannerTestEntry(current) + desired.Data = payload.New(map[string]any{"value": "new"}) - ops, err := (operationPlanner{}).plan(fixtureState(regapi.State{current}), fixtureState([]regapi.Entry{changed}), operationPlanOptions{}) + ops, err := (operationPlanner{}).plan(regapi.State{current}, []regapi.Entry{desired}, operationPlanOptions{}) require.NoError(t, err) require.Len(t, ops, 1) assert.Equal(t, regapi.EntryUpdate, ops[0].Kind) - - rebuilt := clonePlannerTestEntry(current) - rebuilt.Meta.Set(fixtureModuleDigestKey, "sha256:new") - ops, err = (operationPlanner{}).plan(fixtureState(regapi.State{current}), fixtureState([]regapi.Entry{rebuilt}), operationPlanOptions{}) - require.NoError(t, err) - assert.Empty(t, ops, "a resident identity change alone is not an author content change") } func TestOperationPlanner_ControlledDeletionBoundary(t *testing.T) { @@ -169,7 +135,7 @@ func TestOperationPlanner_ControlledDeletionBoundary(t *testing.T) { unrelated.ID = regapi.NewID("other", "service") host := regapi.Entry{ID: regapi.NewID("app", "host"), Kind: "service", Data: payload.New("host")} - ops, err := (operationPlanner{}).plan(fixtureState(regapi.State{controlled, unrelated, host}), regapi.ProvenancedState{}, operationPlanOptions{ + ops, err := (operationPlanner{}).plan(regapi.State{controlled, unrelated, host}, nil, operationPlanOptions{ controlledModules: map[string]struct{}{"acme/controlled": {}}, }) require.NoError(t, err) @@ -188,8 +154,8 @@ func TestOperationPlanner_LiveDependentRetainsControlledEntry(t *testing.T) { } ops, err := (operationPlanner{resolver: regtop.NewResolver()}).plan( - fixtureState(regapi.State{target, dependent}), - fixtureState([]regapi.Entry{dependent}), + regapi.State{target, dependent}, + []regapi.Entry{dependent}, operationPlanOptions{controlledModules: map[string]struct{}{"acme/library": {}}}, ) require.NoError(t, err) @@ -210,10 +176,10 @@ func TestOperationPlanner_KindReplacementRecreatesLiveDependentClosure(t *testin desiredProvider := clonePlannerTestEntry(provider) desiredProvider.Kind = "test.provider.v2" - currentState := fixtureState(regapi.State{provider, dependent, outer, unrelated}) - desiredState := fixtureState([]regapi.Entry{desiredProvider, dependent, outer, unrelated}) + current := regapi.State{provider, dependent, outer, unrelated} + desired := []regapi.Entry{desiredProvider, dependent, outer, unrelated} resolver := regtop.NewResolver() - ops, err := (operationPlanner{resolver: resolver}).plan(currentState, desiredState, operationPlanOptions{ + ops, err := (operationPlanner{resolver: resolver}).plan(current, desired, operationPlanOptions{ controlledModules: map[string]struct{}{ "acme/provider": {}, "acme/dependent": {}, "acme/outer": {}, "acme/unrelated": {}, }, @@ -236,15 +202,14 @@ func TestOperationPlanner_KindReplacementRecreatesLiveDependentClosure(t *testin assert.Nil(t, counts[idKey(unrelated.ID)]) builder := regtop.NewStateBuilder(zap.NewNop(), resolver) - sorted, err := builder.SortChangeSet(currentState.Entries, ops) + sorted, err := builder.SortChangeSet(current, ops) require.NoError(t, err) - state := regtop.NewStateMap(currentState.Entries) + state := regtop.NewStateMap(current) for _, op := range sorted { state, err = builder.ApplyOperation(state, op) require.NoError(t, err) } - assertPlannerStatesEqual(t, desiredState, applyPlannerOperations(currentState, ops)) - require.Len(t, regtop.StateMapToSlice(state), len(desiredState.Entries)) + assertPlannerStatesEqual(t, desired, regtop.StateMapToSlice(state)) } func TestOperationPlanner_KindReplacementDoesNotRecreateRewiredDependent(t *testing.T) { @@ -259,8 +224,8 @@ func TestOperationPlanner_KindReplacementDoesNotRecreateRewiredDependent(t *test desiredDependent := clonePlannerTestEntry(dependent) desiredDependent.Meta.Set(regapi.TagDependsOn, []string{}) ops, err := (operationPlanner{resolver: regtop.NewResolver()}).plan( - fixtureState(regapi.State{provider, dependent}), - fixtureState([]regapi.Entry{desiredProvider, desiredDependent}), + regapi.State{provider, dependent}, + []regapi.Entry{desiredProvider, desiredDependent}, operationPlanOptions{controlledModules: map[string]struct{}{"acme/provider": {}, "acme/dependent": {}}}, ) require.NoError(t, err) @@ -282,15 +247,15 @@ func TestOperationPlanner_KindReplacementReportsUnplannedDependent(t *testing.T) planner := operationPlanner{resolver: regtop.NewResolver()} _, err := planner.plan( - fixtureState(regapi.State{provider, dependent}), - fixtureState([]regapi.Entry{desiredProvider}), + regapi.State{provider, dependent}, + []regapi.Entry{desiredProvider}, operationPlanOptions{controlledModules: map[string]struct{}{"acme/provider": {}}}, ) require.ErrorContains(t, err, "live dependent test:dependent absent from desired state") _, err = planner.plan( - fixtureState(regapi.State{provider, dependent}), - fixtureState([]regapi.Entry{desiredProvider, dependent}), + regapi.State{provider, dependent}, + []regapi.Entry{desiredProvider, dependent}, operationPlanOptions{ controlledModules: map[string]struct{}{"acme/provider": {}, "acme/dependent": {}}, originalKey: idKey(dependent.ID), @@ -335,14 +300,12 @@ func TestOperationPlanner_PlanConvergesAndIsIdempotent(t *testing.T) { planner := operationPlanner{} opts := operationPlanOptions{controlledModules: controlled, mutableModules: controlled} - currentState := fixtureState(current) - desiredState := fixtureState(desired) - ops, err := planner.plan(currentState, desiredState, opts) + ops, err := planner.plan(current, desired, opts) require.NoError(t, err) - got := applyPlannerOperations(currentState, ops) - assertPlannerStatesEqual(t, desiredState, got) + got := applyPlannerOperations(current, ops) + assertPlannerStatesEqual(t, desired, got) - next, err := planner.plan(got, desiredState, opts) + next, err := planner.plan(got, desired, opts) require.NoError(t, err) assert.Empty(t, next) }) @@ -351,15 +314,14 @@ func TestOperationPlanner_PlanConvergesAndIsIdempotent(t *testing.T) { } func plannerTestEntry(kind regapi.Kind, module, version, digest, value string) regapi.Entry { + _ = version + _ = digest return regapi.Entry{ - ID: regapi.NewID("test", "entry"), - Kind: kind, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: module, - fixtureModuleVersionKey: version, - fixtureModuleDigestKey: digest, - }), - Data: payload.New(map[string]any{"value": value}), + ID: regapi.NewID("test", "entry"), + Kind: kind, + Registry: regapi.EntryMetadata{Owner: module}, + Meta: attrs.NewBag(), + Data: payload.New(map[string]any{"value": value}), } } @@ -377,47 +339,33 @@ func clonePlannerTestEntry(entry regapi.Entry) regapi.Entry { return entry } -// applyPlannerOperations applies a plan the way the registry does: the entry -// and the record the operation carries move together. -func applyPlannerOperations(current regapi.ProvenancedState, ops []regapi.Operation) regapi.ProvenancedState { - entries := make(map[string]regapi.Entry, len(current.Entries)+len(ops)) - records := make(map[string]regapi.EntryProvenance, len(current.Entries)+len(ops)) - for _, entry := range current.Entries { +func applyPlannerOperations(current regapi.State, ops []regapi.Operation) regapi.State { + entries := make(map[string]regapi.Entry, len(current)+len(ops)) + for _, entry := range current { entries[idKey(entry.ID)] = entry - records[idKey(entry.ID)] = current.Provenance[entry.ID] } for _, op := range ops { - key := idKey(op.Entry.ID) switch op.Kind { case regapi.EntryCreate, regapi.EntryUpdate: - entries[key] = op.Entry - if op.Provenance != nil { - records[key] = *op.Provenance - } + entries[idKey(op.Entry.ID)] = op.Entry case regapi.EntryDelete: - delete(entries, key) - delete(records, key) + delete(entries, idKey(op.Entry.ID)) } } - result := regapi.ProvenancedState{ - Entries: make(regapi.State, 0, len(entries)), - Provenance: make(regapi.ProvenanceMap, len(entries)), - } - for key, entry := range entries { - result.Entries = append(result.Entries, entry) - result.Provenance[entry.ID] = records[key] + result := make(regapi.State, 0, len(entries)) + for _, entry := range entries { + result = append(result, entry) } return result } -func assertPlannerStatesEqual(t *testing.T, want, got regapi.ProvenancedState) { +func assertPlannerStatesEqual(t *testing.T, want []regapi.Entry, got regapi.State) { t.Helper() - require.Len(t, got.Entries, len(want.Entries)) - gotByID := entriesByID(got.Entries) - for _, entry := range want.Entries { + require.Len(t, got, len(want)) + gotByID := entriesByID(got) + for _, entry := range want { actual, ok := gotByID[idKey(entry.ID)] require.True(t, ok, "missing entry %s", entry.ID) assert.Equal(t, entry, actual, "entry %s differs", entry.ID) - assert.Equal(t, want.Provenance[entry.ID], got.Provenance[entry.ID], "provenance of %s differs", entry.ID) } } diff --git a/boot/deps/hub/provenance_fixture_test.go b/boot/deps/hub/provenance_fixture_test.go deleted file mode 100644 index e01de74aa..000000000 --- a/boot/deps/hub/provenance_fixture_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package hub - -import ( - "testing" - - "github.com/wippyai/runtime/api/attrs" - regapi "github.com/wippyai/runtime/api/registry" -) - -// Fixtures declare who owns an entry with the keys below and with -// Entry.DependencyRoot. They exist only in fixtures: fixtureState moves each -// declaration into the provenance record the registry owns and removes it from -// the entry, so a test can never hand the handler an entry that carries its own -// ownership. -const ( - fixtureModuleKey = "module" - fixtureModuleVersionKey = "module_version" - fixtureModuleDigestKey = "module_digest" -) - -// fixtureState builds the provenanced state a directive receives. The resulting -// provenance is total over the entries, as the registry guarantees. -func fixtureState(entries regapi.State) regapi.ProvenancedState { - state := regapi.ProvenancedState{ - Entries: make(regapi.State, 0, len(entries)), - Provenance: make(regapi.ProvenanceMap, len(entries)), - } - for _, entry := range entries { - clean, record := fixtureEntryProvenance(entry) - state.Entries = append(state.Entries, clean) - state.Provenance[clean.ID] = record - } - return state -} - -// fixtureOperation strips an operation's declaration into op.Provenance, the -// way a producer of module operations populates it. -func fixtureOperation(op regapi.Operation) regapi.Operation { - clean, record := fixtureEntryProvenance(op.Entry) - op.Entry = clean - if record != (regapi.EntryProvenance{}) { - op.Provenance = &record - } - return op -} - -// fixtureProvenance is the record a fixture entry declares, without the entry. -func fixtureProvenance(entry regapi.Entry) regapi.EntryProvenance { - _, record := fixtureEntryProvenance(entry) - return record -} - -func fixtureEntryProvenance(entry regapi.Entry) (regapi.Entry, regapi.EntryProvenance) { - record := regapi.EntryProvenance{Root: entry.DependencyRoot} - entry.DependencyRoot = false - if entry.Meta == nil { - return entry, record - } - meta := attrs.NewBagFrom(entry.Meta) - record.Module = meta.GetString(fixtureModuleKey, "") - record.Version = meta.GetString(fixtureModuleVersionKey, "") - record.Digest = meta.GetString(fixtureModuleDigestKey, "") - delete(meta, fixtureModuleKey) - delete(meta, fixtureModuleVersionKey) - delete(meta, fixtureModuleDigestKey) - entry.Meta = meta - return entry, record -} - -// fixtureOwned declares an entry as produced by a module artifact. -func fixtureOwned(entry regapi.Entry, module, version, digest string) regapi.Entry { - meta := attrs.NewBagFrom(entry.Meta) - meta.Set(fixtureModuleKey, module) - if version != "" { - meta.Set(fixtureModuleVersionKey, version) - } - if digest != "" { - meta.Set(fixtureModuleDigestKey, digest) - } - entry.Meta = meta - return entry -} - -// requireScopedDelete asserts the plan deletes one entry, whatever provenance -// the operation carries. -func requireScopedDelete(t *testing.T, ops []regapi.ScopedOperation, id regapi.ID) regapi.Operation { - t.Helper() - for _, scoped := range ops { - if scoped.Operation.Kind == regapi.EntryDelete && idsEqual(scoped.Operation.Entry.ID, id) { - return scoped.Operation - } - } - t.Fatalf("no delete operation for %s", id.String()) - return regapi.Operation{} -} diff --git a/boot/deps/hub/reference_boot_cycle_test.go b/boot/deps/hub/reference_boot_cycle_test.go index e7db73611..a60012da5 100644 --- a/boot/deps/hub/reference_boot_cycle_test.go +++ b/boot/deps/hub/reference_boot_cycle_test.go @@ -68,13 +68,13 @@ modules: root := regapi.Entry{ ID: regapi.NewID("app.deps", "app"), Kind: regapi.NamespaceDependency, - DependencyRoot: true, - Data: payload.New(map[string]any{"component": "acme/app", "version": "v1.0.0"}), + Registry: regapi.EntryMetadata{Root: true}, + Data: payload.New(map[string]any{"component": "acme/app", "version": "v1.0.0"}), } - service := fixtureOwned(regapi.Entry{ + service := ownedEntry(regapi.Entry{ ID: regapi.NewID("acme.app", "service"), Kind: "service", Data: payload.New(map[string]any{"version": "1"}), - }, "acme/app", "v1.0.0", digest) + }, "acme/app") baseline := regapi.State{root, service} reference := regapi.Entry{ ID: regapi.NewID("acme.pkg", "__dependency.acme.app"), @@ -82,7 +82,6 @@ modules: Data: payload.New(map[string]any{"component": "acme/app", "version": ">=1.0.0"}), } - provenancedBaseline := fixtureState(baseline) newRegistry := func(history regapi.History) *registryimpl.Reg { resolver := topology.NewResolver() handler, err := NewDependencyHandler(DependencyHandlerOptions{ @@ -100,7 +99,7 @@ modules: history, err := historysqlite.NewSQLite(dbPath, zap.NewNop()) require.NoError(t, err) reg := newRegistry(history) - require.NoError(t, reg.LoadState(ctx, provenancedBaseline, version.FromParent(nil, 0))) + require.NoError(t, reg.LoadState(ctx, baseline, version.FromParent(nil, 0))) _, err = reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: reference}}) require.NoError(t, err) head, err := history.Head() @@ -117,7 +116,7 @@ modules: history, err = historysqlite.NewSQLite(dbPath, zap.NewNop()) require.NoError(t, err) reg = newRegistry(history) - require.NoError(t, reg.LoadState(ctx, provenancedBaseline, head)) + require.NoError(t, reg.LoadState(ctx, baseline, head)) entries, err := reg.GetAllEntries() require.NoError(t, err) found := false @@ -155,7 +154,7 @@ modules: require.NoError(t, err) defer func() { _ = history.Close() }() reg = newRegistry(history) - require.NoError(t, reg.LoadState(ctx, provenancedBaseline, head)) + require.NoError(t, reg.LoadState(ctx, baseline, head)) // Cycle 5: undo to the referenced version and redo to the promoted one. // Each transition replays its own stored graph within the unchanged diff --git a/boot/deps/hub/reference_fold_hardening_test.go b/boot/deps/hub/reference_fold_hardening_test.go index cc8e073ed..6a2b6e26d 100644 --- a/boot/deps/hub/reference_fold_hardening_test.go +++ b/boot/deps/hub/reference_fold_hardening_test.go @@ -6,6 +6,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -23,15 +24,17 @@ import ( func referenceFoldHandler(t *testing.T) *DependencyHandler { t.Helper() vendorDir := t.TempDir() - artifact := buildWappBytes(t, []wapp.Entry{{ - ID: wapp.NewID("acme.a", "entry"), Kind: regapi.EntryKind, Data: map[string]any{"module": "a"}, - }}) hub := &fakeHub{ getManifest: func(_ context.Context, org, module, _ string) (*ModuleManifest, error) { return &ModuleManifest{Org: org, Name: module, Version: "v1.0.0", URL: "memory://" + module}, nil }, - downloadFile: func(_ context.Context, _ string, dest string) error { + downloadFile: func(_ context.Context, url, dest string) error { require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755)) + module := strings.TrimPrefix(url, "memory://") + artifact := buildWappBytes(t, []wapp.Entry{{ + ID: wapp.NewID(strings.ReplaceAll("acme/"+module, "/", "."), "entry"), + Kind: regapi.EntryKind, Data: map[string]any{"module": module}, + }}) return os.WriteFile(dest, artifact, 0o600) }, } @@ -41,7 +44,7 @@ func referenceFoldHandler(t *testing.T) *DependencyHandler { } func TestExpand_RecordsFoldedReferenceForInstalledComponent(t *testing.T) { - ctx := newTestContext() + ctx := withCurrentResolution(newTestContext(), regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) handler := referenceFoldHandler(t) root := hardeningRoot("app.deps:a", "acme/a", "v1.0.0") @@ -50,7 +53,7 @@ func TestExpand_RecordsFoldedReferenceForInstalledComponent(t *testing.T) { result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: reference}, - fixtureState(regapi.State{root, moduleEntry}), + regapi.State{root, moduleEntry}, ) require.NoError(t, err) require.NotNil(t, result.Resolution) @@ -73,7 +76,7 @@ func TestExpand_FreshDuplicateInstallKeepsConflict(t *testing.T) { // second install attempt keeps the established planning conflict. _, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: reference}, - fixtureState(regapi.State{root}), + regapi.State{root}, ) require.Error(t, err) assert.Contains(t, err.Error(), "already installed") @@ -89,13 +92,13 @@ func TestExpand_UnsatisfiableReferenceConstraintFailsAtDeclaration(t *testing.T) _, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: reference}, - fixtureState(regapi.State{root, moduleEntry}), + regapi.State{root, moduleEntry}, ) require.Error(t, err) } func TestExpand_DeletingControllerPromotesReference(t *testing.T) { - ctx := newTestContext() + ctx := withCurrentResolution(newTestContext(), regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) handler := referenceFoldHandler(t) root := hardeningRoot("app.deps:a", "acme/a", "v1.0.0") @@ -104,7 +107,7 @@ func TestExpand_DeletingControllerPromotesReference(t *testing.T) { result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: root.ID}}, - fixtureState(regapi.State{root, reference, moduleEntry}), + regapi.State{root, reference, moduleEntry}, ) require.NoError(t, err) require.NotNil(t, result.Resolution) @@ -129,7 +132,7 @@ func TestExpand_DeletingReferenceKeepsRootAndModule(t *testing.T) { result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: reference.ID}}, - fixtureState(regapi.State{root, reference, moduleEntry}), + regapi.State{root, reference, moduleEntry}, ) require.NoError(t, err) require.NotNil(t, result.Resolution) @@ -213,9 +216,10 @@ func TestReconcile_ReplaysRecordedReferences(t *testing.T) { reference := hardeningRoot("acme.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") moduleEntry := hardeningModuleEntry("acme.a:entry", "acme/a", "v1.0.0") resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{reference}) + ctx = withCurrentResolution(ctx, resolution.Modules...) state := regapi.State{root, reference, moduleEntry} - result, err := handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + result, err := handler.ReconcileResolution(ctx, state, state, resolution) require.NoError(t, err) require.Equal(t, resolution.Digest, result.Resolution.Digest) } @@ -231,7 +235,7 @@ func TestReconcile_MissingReferenceEntryIsDrift(t *testing.T) { resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{reference}) state := regapi.State{root, moduleEntry} - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + _, err = handler.ReconcileResolution(ctx, state, state, resolution) require.ErrorContains(t, err, "root set") } @@ -248,13 +252,14 @@ func TestReconcile_UnrecordedDeclarationIsDrift(t *testing.T) { // Bind the stored graph to the current deployment baseline: within an // unchanged baseline an unrecorded declaration is drift, never an upgrade. transcoder := payload.GetTranscoder(ctx) - baseline, err := handler.deploymentBaselineDigest(ctx, fixtureState(state), transcoder) + baseline, err := handler.deploymentBaselineDigest(ctx, state, transcoder) require.NoError(t, err) resolution := hardeningReferencedResolution([]regapi.Entry{root}, nil) resolution.BaselineDigest = baseline resolution = resolution.Canonical() + ctx = withCurrentResolution(ctx, resolution.Modules...) - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + _, err = handler.ReconcileResolution(ctx, state, state, resolution) require.ErrorContains(t, err, "root set") } @@ -270,9 +275,10 @@ func TestReconcile_LegacyGraphUpgradesRegardlessOfDeclarationOrder(t *testing.T) late := hardeningRoot("zzz.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") legacy := hardeningReferencedResolution([]regapi.Entry{root}, nil) require.Empty(t, legacy.BaselineDigest) + ctx = withCurrentResolution(ctx, legacy.Modules...) state := regapi.State{root, late, moduleEntry} - result, err := handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), legacy) + result, err := handler.ReconcileResolution(ctx, state, state, legacy) require.NoError(t, err) require.NotNil(t, result.Resolution) require.NotEmpty(t, result.Resolution.BaselineDigest, "legacy upgrade must bind the baseline") @@ -292,7 +298,7 @@ func TestReconcile_ReferenceConstraintDriftIsRejected(t *testing.T) { resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{recorded}) state := regapi.State{root, drifted, moduleEntry} - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + _, err = handler.ReconcileResolution(ctx, state, state, resolution) require.ErrorContains(t, err, "stored dependency reference") } @@ -312,9 +318,10 @@ func TestReconcile_WildcardReferenceNormalizesAcrossReplay(t *testing.T) { resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{bare}) require.Len(t, resolution.References, 1) require.Equal(t, "*", resolution.References[0].Version) + ctx = withCurrentResolution(ctx, resolution.Modules...) state := regapi.State{root, bare, moduleEntry} - result, err := handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + result, err := handler.ReconcileResolution(ctx, state, state, resolution) require.NoError(t, err) require.Equal(t, resolution.Digest, result.Resolution.Digest) } @@ -334,7 +341,7 @@ func TestReconcile_StoredSelectionMustSatisfyReferenceConstraint(t *testing.T) { require.False(t, resolution.Valid()) state := regapi.State{root, tight, moduleEntry} - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + _, err = handler.ReconcileResolution(ctx, state, state, resolution) require.ErrorContains(t, err, "stored dependency resolution is invalid") // Exact-pin spellings carry hub semantics the model deliberately leaves @@ -346,7 +353,7 @@ func TestReconcile_StoredSelectionMustSatisfyReferenceConstraint(t *testing.T) { require.True(t, pinned.Valid()) pinnedState := regapi.State{pinnedRoot, pinnedRef, moduleEntry} - _, err = handler.ReconcileResolution(ctx, fixtureState(pinnedState), fixtureState(pinnedState), pinned) + _, err = handler.ReconcileResolution(ctx, pinnedState, pinnedState, pinned) require.ErrorContains(t, err, "does not satisfy") } @@ -364,7 +371,7 @@ func TestExpandChanges_BatchCannotBypassFreshDuplicateGate(t *testing.T) { _, err := handler.ExpandChanges(ctx, regapi.ChangeSet{ {Kind: regapi.EntryCreate, Entry: reference}, {Kind: regapi.EntryCreate, Entry: other}, - }, fixtureState(regapi.State{root})) + }, regapi.State{root}) require.Error(t, err) assert.Contains(t, err.Error(), "already installed") @@ -372,7 +379,7 @@ func TestExpandChanges_BatchCannotBypassFreshDuplicateGate(t *testing.T) { _, err = handler.ExpandChanges(ctx, regapi.ChangeSet{ {Kind: regapi.EntryCreate, Entry: other}, {Kind: regapi.EntryCreate, Entry: reference}, - }, fixtureState(regapi.State{root})) + }, regapi.State{root}) require.Error(t, err) assert.Contains(t, err.Error(), "already installed") @@ -383,7 +390,8 @@ func TestExpandChanges_BatchCannotBypassFreshDuplicateGate(t *testing.T) { {{Kind: regapi.EntryCreate, Entry: reference}, {Kind: regapi.EntryCreate, Entry: other}}, {{Kind: regapi.EntryCreate, Entry: other}, {Kind: regapi.EntryCreate, Entry: reference}}, } { - result, err := handler.ExpandChanges(ctx, batch, fixtureState(regapi.State{root, moduleEntry})) + installedCtx := withCurrentResolution(ctx, regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) + result, err := handler.ExpandChanges(installedCtx, batch, regapi.State{root, moduleEntry}) require.NoError(t, err) require.NotNil(t, result.Resolution) rootIDs := make([]string, 0, len(result.Resolution.Roots)) @@ -408,9 +416,10 @@ func TestReconcile_LegacyReferenceUpgradeWorksOffline(t *testing.T) { reference := hardeningRoot("acme.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") legacy := hardeningReferencedResolution([]regapi.Entry{root}, nil) require.Empty(t, legacy.BaselineDigest) + ctx = withCurrentResolution(ctx, legacy.Modules...) state := regapi.State{root, reference, moduleEntry} - result, err := handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), legacy) + result, err := handler.ReconcileResolution(ctx, state, state, legacy) require.NoError(t, err) require.NotNil(t, result.Resolution) require.NotEmpty(t, result.Resolution.BaselineDigest, "offline upgrade must bind the baseline") @@ -434,7 +443,7 @@ func TestReconcile_LegacyUpgradeUnsatisfiedReferenceRequiresResolution(t *testin legacy := hardeningReferencedResolution([]regapi.Entry{root}, nil) state := regapi.State{root, tight, moduleEntry} - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), legacy) + _, err = handler.ReconcileResolution(ctx, state, state, legacy) require.Error(t, err) } @@ -486,19 +495,20 @@ func TestReconcile_ParameterDisagreementBootsThenConflictsOnNextOperation(t *tes state := regapi.State{root, disagreeing, moduleDefinition, moduleEntry} transcoder := payload.GetTranscoder(ctx) - baseline, err := handler.deploymentBaselineDigest(ctx, fixtureState(state), transcoder) + baseline, err := handler.deploymentBaselineDigest(ctx, state, transcoder) require.NoError(t, err) resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{disagreeing}) resolution.BaselineDigest = baseline resolution = resolution.Canonical() + ctx = withCurrentResolution(ctx, resolution.Modules...) - _, err = handler.ReconcileResolution(ctx, fixtureState(state), fixtureState(state), resolution) + _, err = handler.ReconcileResolution(ctx, state, state, resolution) require.NoError(t, err, "committed parameter disagreement must not wedge boot") unrelated := hardeningRoot("app.deps:b", "acme/b", ">=1.0.0") _, err = handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryCreate, Entry: unrelated}, - fixtureState(state), + state, ) require.Error(t, err) require.Contains(t, err.Error(), "acme/a") @@ -517,12 +527,13 @@ func TestExpandChanges_DeleteControllerAndCreateReferenceInOneBatch(t *testing.T reference := hardeningRoot("acme.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") newcomer := hardeningRoot("other.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") moduleEntry := hardeningModuleEntry("acme.a:entry", "acme/a", "v1.0.0") + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) for _, batch := range []regapi.ChangeSet{ {{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: root.ID}}, {Kind: regapi.EntryCreate, Entry: newcomer}}, {{Kind: regapi.EntryCreate, Entry: newcomer}, {Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: root.ID}}}, } { - result, err := handler.ExpandChanges(ctx, batch, fixtureState(regapi.State{root, reference, moduleEntry})) + result, err := handler.ExpandChanges(ctx, batch, regapi.State{root, reference, moduleEntry}) require.NoError(t, err) require.NotNil(t, result.Resolution) require.Len(t, result.Resolution.Roots, 1) @@ -547,11 +558,12 @@ func TestExpandChanges_SameIDDeleteRecreateIsReplacement(t *testing.T) { recreated := hardeningRoot("app.deps:a", "acme/a", ">=1.0.0") reference := hardeningRoot("acme.pkg:__dependency.acme.a", "acme/a", ">=1.0.0") moduleEntry := hardeningModuleEntry("acme.a:entry", "acme/a", "v1.0.0") + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) result, err := handler.ExpandChanges(ctx, regapi.ChangeSet{ {Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: root.ID}}, {Kind: regapi.EntryCreate, Entry: recreated}, - }, fixtureState(regapi.State{root, reference, moduleEntry})) + }, regapi.State{root, reference, moduleEntry}) require.NoError(t, err) require.NotNil(t, result.Resolution) require.Len(t, result.Resolution.Roots, 1) diff --git a/boot/deps/hub/reference_param_drift_test.go b/boot/deps/hub/reference_param_drift_test.go index ed57bfd7c..2a1a0e3d6 100644 --- a/boot/deps/hub/reference_param_drift_test.go +++ b/boot/deps/hub/reference_param_drift_test.go @@ -39,17 +39,18 @@ func TestReconcile_ParameterDriftOnControllerStillBoots(t *testing.T) { recordedState := regapi.State{root, reference, moduleDefinition, moduleEntry} transcoder := payload.GetTranscoder(ctx) - baseline, err := handler.deploymentBaselineDigest(ctx, fixtureState(recordedState), transcoder) + baseline, err := handler.deploymentBaselineDigest(ctx, recordedState, transcoder) require.NoError(t, err) resolution := hardeningReferencedResolution([]regapi.Entry{root}, []regapi.Entry{reference}) resolution.BaselineDigest = baseline resolution = resolution.Canonical() + ctx = withCurrentResolution(ctx, resolution.Modules...) // ...then the controller's parameters change on disk. driftedRoot := paramRoot("app.deps:a", "acme/a", "v1.0.0", map[string]any{"db": "other:db"}) driftedState := regapi.State{driftedRoot, reference, moduleDefinition, moduleEntry} - result, err := handler.ReconcileResolution(ctx, fixtureState(driftedState), fixtureState(driftedState), resolution) + result, err := handler.ReconcileResolution(ctx, driftedState, driftedState, resolution) require.NoError(t, err, "parameter drift must not wedge reconciliation") require.NotNil(t, result.Resolution) } diff --git a/boot/deps/hub/replacement_offline_startup_test.go b/boot/deps/hub/replacement_offline_startup_test.go index 4c6213241..066a15e51 100644 --- a/boot/deps/hub/replacement_offline_startup_test.go +++ b/boot/deps/hub/replacement_offline_startup_test.go @@ -112,17 +112,18 @@ func (w *offlineWorkspace) writeLock(t *testing.T, body string) { func (w *offlineWorkspace) rootDependency(name, component, constraint string) regapi.Entry { return regapi.Entry{ - ID: regapi.NewID("app.deps", name), - Kind: regapi.NamespaceDependency, - Data: payload.New(map[string]any{"component": component, "version": constraint}), - DependencyRoot: true, + ID: regapi.NewID("app.deps", name), + Kind: regapi.NamespaceDependency, + Data: payload.New(map[string]any{"component": component, "version": constraint}), } } -// installedModule declares an entry as already materialized from a published -// module. fixtureState moves that declaration into registry-owned provenance. +// installedModule marks an entry as already materialized from a published +// module, which is the evidence verified-offline startup resolves from. func (w *offlineWorkspace) installedModule(id regapi.ID, module, moduleVersion, digest string) regapi.Entry { - return fixtureOwned(regapi.Entry{ID: id, Kind: "service"}, module, moduleVersion, digest) + _ = moduleVersion + _ = digest + return ownedEntry(regapi.Entry{ID: id, Kind: "service"}, module) } func (w *offlineWorkspace) newHandler(t *testing.T) *DependencyHandler { @@ -165,7 +166,7 @@ func (w *offlineWorkspace) loadState(t *testing.T, access regapi.DependencyAcces } w.registry = w.newRegistry(t, w.history) ctx := regapi.WithDependencyAccess(newTestContext(), access) - return w.registry.LoadState(ctx, fixtureState(w.baseline), version.New(0)) + return w.registry.LoadState(ctx, w.baseline, version.New(0)) } func (w *offlineWorkspace) requireEntry(t *testing.T, namespace, name string) regapi.Entry { @@ -176,16 +177,6 @@ func (w *offlineWorkspace) requireEntry(t *testing.T, namespace, name string) re return entry } -func (w *offlineWorkspace) requireProvenance(t *testing.T, id regapi.ID) regapi.EntryProvenance { - t.Helper() - - _, state, err := w.registry.SnapshotState() - require.NoError(t, err) - record, ok := state.Provenance[id] - require.True(t, ok, "missing provenance for %s", id.String()) - return record -} - func (w *offlineWorkspace) requireHubUntouched(t *testing.T) { t.Helper() require.Empty(t, w.hubCalls, "verified-offline startup must not reach the Hub") @@ -313,7 +304,7 @@ func TestVerifiedOfflineStartupUsesDeclaredSourceVersionForRange(t *testing.T) { entry := workspace.requireEntry(t, "local.mod", "svc") require.Equal(t, "local", entry.Data.Data().(map[string]any)["generation"]) - require.Equal(t, "9.1.0", workspace.requireProvenance(t, entry.ID).Version, + require.Equal(t, "9.1.0", snapshotModuleVersion(t, workspace.registry, "local/mod"), "the version the source declares settles a live range offline") workspace.requireHubUntouched(t) } @@ -344,7 +335,7 @@ replacements: entry := workspace.requireEntry(t, "local.mod", "svc") require.Equal(t, "local", entry.Data.Data().(map[string]any)["generation"], "the replacement supplies the content of a module the lock also pins") - require.Equal(t, "1.0.0", workspace.requireProvenance(t, entry.ID).Version, + require.Equal(t, "1.0.0", snapshotModuleVersion(t, workspace.registry, "local/mod"), "the recorded label stays with it") workspace.requireHubUntouched(t) } @@ -368,8 +359,8 @@ func TestVerifiedOfflineStartupResolvesTransitivelyReplacedDependency(t *testing require.NoError(t, workspace.loadState(t, regapi.DependencyAccessVerifiedOffline)) workspace.requireEntry(t, "local.app", "svc") - entry := workspace.requireEntry(t, "local.lib", "svc") - require.Equal(t, "2.5.0", workspace.requireProvenance(t, entry.ID).Version, + workspace.requireEntry(t, "local.lib", "svc") + require.Equal(t, "2.5.0", snapshotModuleVersion(t, workspace.registry, "local/lib"), "a replaced module's own source version labels it") workspace.requireHubUntouched(t) } @@ -436,6 +427,59 @@ func TestVerifiedOfflineStartupKeepsEvidenceRefusalWithoutReplacements(t *testin require.Empty(t, workspace.hubCalls, "an unverified module must not fall back to the Hub") } +func TestVerifiedOfflineReconciliationUsesPersistedDynamicSelection(t *testing.T) { + workspace := newOfflineWorkspace(t) + dynamicDigest := workspace.vendorModule(t, "acme", "dynamic", "1.2.3", []wapp.Entry{{ + ID: wapp.NewID("acme.dynamic", "service"), Kind: "service", + }}) + oldDynamicDigest := workspace.vendorModule(t, "acme", "dynamic", "1.1.0", []wapp.Entry{{ + ID: wapp.NewID("acme.dynamic", "service"), Kind: "service", + }}) + baselineDigest := workspace.vendorModule(t, "acme", "baseline", "2.0.0", []wapp.Entry{{ + ID: wapp.NewID("acme.baseline", "service"), Kind: "service", + }}) + workspace.writeLock(t, `modules: + - name: acme/dynamic + version: 1.1.0 + hash: `+oldDynamicDigest+` + - name: acme/baseline + version: 2.0.0 + hash: `+baselineDigest+` +`) + + handler := workspace.newHandler(t) + resolution := ®api.DependencyResolution{Modules: []regapi.ResolvedModule{{ + Name: "acme/dynamic", Version: "1.2.3", VersionID: "1.2.3", + Source: moduleSourceHub, Digest: dynamicDigest, + }}} + ctx := newTestContext() + ctx = regapi.WithDependencyAccess(ctx, regapi.DependencyAccessVerifiedOffline) + dynamicRoot := workspace.rootDependency("dynamic", "acme/dynamic", "1.2.3") + baselineRoot := workspace.rootDependency("baseline", "acme/baseline", "2.0.0") + + resolved, err := handler.refreshResolvedModules( + ctx, + regapi.State{dynamicRoot, baselineRoot, workspace.installedModule( + regapi.NewID("acme.dynamic", "service"), "acme/dynamic", "1.2.3", dynamicDigest, + )}, + payload.GetTranscoder(ctx), + resolution, + []desiredDependency{ + {entry: dynamicRoot, definition: DependencyDefinition{Component: "acme/dynamic", Version: "1.2.3"}}, + {entry: baselineRoot, definition: DependencyDefinition{Component: "acme/baseline", Version: "2.0.0"}}, + }, + ) + require.NoError(t, err) + require.Len(t, resolved, 2) + baselineVersion, ok := selectedModuleVersion(resolved, "acme/baseline") + require.True(t, ok) + require.Equal(t, "2.0.0", baselineVersion) + dynamicVersion, ok := selectedModuleVersion(resolved, "acme/dynamic") + require.True(t, ok) + require.Equal(t, "1.2.3", dynamicVersion) + workspace.requireHubUntouched(t) +} + func TestVerifiedOfflineStartupReportsMissingReplacementPath(t *testing.T) { workspace := singleReplacementWorkspace(t, "*") require.NoError(t, os.RemoveAll(filepath.Join(workspace.root, "local-mod"))) @@ -477,7 +521,7 @@ func TestVerifiedOfflineStartupResolvesFromLockAfterReplacementRemoved(t *testin require.NoError(t, workspace.loadState(t, regapi.DependencyAccessVerifiedOffline)) replaced := workspace.requireEntry(t, "local.mod", "svc") require.Equal(t, "local", replaced.Data.Data().(map[string]any)["generation"]) - require.Contains(t, workspace.requireProvenance(t, replaced.ID).Digest, "sha256-tree-v1:", + require.Contains(t, snapshotModuleDigest(t, workspace.registry, "local/mod"), "sha256-tree-v1:", "the tree's own identity pins a replaced module") require.NoError(t, history.Close()) @@ -489,19 +533,8 @@ func TestVerifiedOfflineStartupResolvesFromLockAfterReplacementRemoved(t *testin workspace.history = history require.NoError(t, workspace.loadState(t, regapi.DependencyAccessVerifiedOffline)) - _, state, err := workspace.registry.SnapshotState() - require.NoError(t, err) - owned := 0 - for _, entry := range state.Entries { - record := state.Provenance[entry.ID] - if record.Module != "local/mod" { - continue - } - owned++ - require.Equal(t, digest, record.Digest, - "the lock's artifact backs the module once the replacement is gone") - } - require.NotZero(t, owned, "the locked module remains resident") + require.Equal(t, digest, snapshotModuleDigest(t, workspace.registry, "local/mod"), + "the lock's artifact backs the module once the replacement is gone") workspace.requireHubUntouched(t) } @@ -521,8 +554,8 @@ func TestOnlineStartupResolvesReplacementThroughHub(t *testing.T) { require.NoError(t, workspace.loadState(t, regapi.DependencyAccessOnline)) - entry := workspace.requireEntry(t, "local.mod", "svc") - require.Equal(t, "0.2.0", workspace.requireProvenance(t, entry.ID).Version, + workspace.requireEntry(t, "local.mod", "svc") + require.Equal(t, "0.2.0", snapshotModuleVersion(t, workspace.registry, "local/mod"), "online resolution still lets the Hub label an unversioned replacement") require.Equal(t, 1, workspace.hubCalls["list local/mod"]) } diff --git a/boot/deps/hub/replacement_restart_e2e_test.go b/boot/deps/hub/replacement_restart_e2e_test.go index e6b71a438..66f86c217 100644 --- a/boot/deps/hub/replacement_restart_e2e_test.go +++ b/boot/deps/hub/replacement_restart_e2e_test.go @@ -85,14 +85,18 @@ replacements: initialHistory := history t.Cleanup(func() { _ = initialHistory.Close() }) registry := newRegistry(history, newHandler()) + ctx = regapi.WithRegistry(ctx, registry) root := regapi.Entry{ ID: regapi.NewID("app.deps", "local"), Kind: regapi.NamespaceDependency, Data: payload.New(map[string]any{"component": "local/mod", "version": "v0.1.0"}), } fresh := newRegistry(memory.New(), newHandler()) - startupCtx := regapi.WithDependencyAccess(newTestContext(), regapi.DependencyAccessUnspecified) - require.NoError(t, fresh.LoadState(startupCtx, fixtureState(regapi.State{root}), version.New(0))) + startupCtx := regapi.WithRegistry( + regapi.WithDependencyAccess(newTestContext(), regapi.DependencyAccessUnspecified), + fresh, + ) + require.NoError(t, fresh.LoadState(startupCtx, regapi.State{root}, version.New(0))) _, err = fresh.GetEntry(regapi.NewID("local.mod", "svc")) require.NoError(t, err) require.Zero(t, hubCalls) @@ -104,9 +108,8 @@ replacements: entryID := regapi.NewID("local.mod", "svc") _, err = registry.GetEntry(entryID) require.NoError(t, err) - initial, ok := registry.EntryProvenance(entryID) - require.True(t, ok) - require.NotEmpty(t, initial.Digest, "a replacement records its source tree identity") + initialDigest := snapshotModuleDigest(t, registry, "local/mod") + require.NotEmpty(t, initialDigest) // A frontend rebuild mutates only static content. Simulate a cold restart // with the persisted registry intact and an unreachable Hub. @@ -117,15 +120,16 @@ replacements: require.NoError(t, err) t.Cleanup(func() { _ = history.Close() }) restarted := newRegistry(history, newHandler()) - restartCtx := regapi.WithDependencyAccess(newTestContext(), regapi.DependencyAccessVerifiedOffline) - require.NoError(t, restarted.LoadState(restartCtx, regapi.ProvenancedState{}, version)) + restartCtx := regapi.WithRegistry( + regapi.WithDependencyAccess(newTestContext(), regapi.DependencyAccessVerifiedOffline), + restarted, + ) + require.NoError(t, restarted.LoadState(restartCtx, nil, version)) require.Zero(t, hubCalls) reloadedEntry, err := restarted.GetEntry(entryID) require.NoError(t, err) - reloaded, ok := restarted.EntryProvenance(entryID) - require.True(t, ok) - require.NotEqual(t, initial.Digest, reloaded.Digest, - "a rebuilt tree advances the resident record while the stored checkpoint stands") + require.Equal(t, initialDigest, snapshotModuleDigest(t, restarted, "local/mod"), + "restart must not rewrite the immutable history checkpoint") require.Equal(t, "one", reloadedEntry.Data.Data().(map[string]any)["generation"]) } diff --git a/boot/deps/hub/resolution_hardening_test.go b/boot/deps/hub/resolution_hardening_test.go index bf05517ef..56fb8ffff 100644 --- a/boot/deps/hub/resolution_hardening_test.go +++ b/boot/deps/hub/resolution_hardening_test.go @@ -96,14 +96,10 @@ replacements: root := hardeningRoot("app.deps:local", "local/mod", "v0.1.0") oldEntry := regapi.Entry{ - ID: regapi.NewID("local.mod", "svc"), - Kind: regapi.EntryKind, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "local/mod", - fixtureModuleVersionKey: "v0.1.0", - fixtureModuleDigestKey: beforeDigest, - }), - Data: payload.New(map[string]any{"generation": "one"}), + ID: regapi.NewID("local.mod", "svc"), + Kind: regapi.EntryKind, + Registry: regapi.EntryMetadata{Owner: "local/mod"}, + Data: payload.New(map[string]any{"generation": "one"}), } resolution := dependencyResolution([]desiredDependency{{ entry: root, @@ -115,56 +111,51 @@ replacements: Org: "local", Name: "mod", Version: "v0.1.0", Source: moduleSourceReplacementTreeV1, Digest: beforeDigest, SizeBytes: beforeSize, }}) + ctx = withCurrentResolution(ctx, resolution.Modules...) require.NoError(t, os.WriteFile(staticBundle, []byte("window.build = 'two';\n"), 0o600)) afterDigest, _, err := digestReplacementTree(replacementPath) require.NoError(t, err) require.NotEqual(t, beforeDigest, afterDigest) - result, err := handler.ReconcileResolution(ctx, fixtureState(regapi.State{root, oldEntry}), fixtureState(regapi.State{root, oldEntry}), resolution) + result, err := handler.ReconcileResolution(ctx, regapi.State{root, oldEntry}, regapi.State{root, oldEntry}, resolution) require.NoError(t, err) require.Len(t, result.Resolution.Modules, 1) require.Equal(t, beforeDigest, result.Resolution.Modules[0].Digest, "a history version keeps its original resolution checkpoint") - var updatedOp *regapi.Operation - for i := range result.Additional { - op := result.Additional[i].Operation - if op.Kind == regapi.EntryUpdate && op.Entry.ID == oldEntry.ID { - updatedOp = &op + for _, scoped := range result.Additional { + require.NotEqual(t, oldEntry.ID, scoped.Operation.Entry.ID, + "a replacement tree rebuild changes neither authored entry data nor ownership") + } + var source *sourceEffect + for _, effect := range result.Effects { + if candidate, ok := effect.(*sourceEffect); ok { + source = candidate break } } - require.NotNil(t, updatedOp) - updated := &updatedOp.Entry - require.NotNil(t, updatedOp.Provenance) - require.Equal(t, afterDigest, updatedOp.Provenance.Digest, - "a rebuilt replacement tree advances the resident record while the checkpoint stands") - require.Equal(t, "one", updated.Data.Data().(map[string]any)["generation"]) + require.NotNil(t, source) + require.Equal(t, afterDigest, source.desired["local/mod"].Digest, + "the replacement source transition carries the rebuilt tree identity") } func hardeningModuleEntry(id, module, version string) regapi.Entry { + _ = version return regapi.Entry{ - ID: regapi.ParseID(id), - Kind: regapi.EntryKind, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: module, - fixtureModuleVersionKey: version, - fixtureModuleDigestKey: hardeningDigest, - }), - Data: payload.New(map[string]any{"module": module}), + ID: regapi.ParseID(id), + Kind: regapi.EntryKind, + Registry: regapi.EntryMetadata{Owner: module}, + Data: payload.New(map[string]any{"module": module}), } } func hardeningModuleDefinition(namespace, module, version string) regapi.Entry { + _ = version return regapi.Entry{ - ID: regapi.NewID(namespace, "definition"), - Kind: regapi.NamespaceDefinition, - Meta: attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: module, - fixtureModuleVersionKey: version, - fixtureModuleDigestKey: hardeningDigest, - }), + ID: regapi.NewID(namespace, "definition"), + Kind: regapi.NamespaceDefinition, + Registry: regapi.EntryMetadata{Owner: module}, } } @@ -224,13 +215,16 @@ func TestDependencyHandler_ExpandChangesDeletesEarlierRootModulesAndReturnsResol result, err := handler.ExpandChanges(ctx, regapi.ChangeSet{ {Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: rootA.ID}}, {Kind: regapi.EntryUpdate, Entry: rootB}, // deliberately unchanged and last - }, fixtureState(regapi.State{rootA, rootB, moduleA, moduleB})) + }, regapi.State{rootA, rootB, moduleA, moduleB}) require.NoError(t, err) require.NotNil(t, result.Resolution, "the final no-op must not inherit the pre-batch graph") require.Len(t, result.Resolution.Roots, 1) require.Equal(t, rootB.ID.String(), result.Resolution.Roots[0].ID) require.Equal(t, 1, manifestCalls) - requireScopedDelete(t, result.Additional, moduleA.ID) + require.Contains(t, result.Additional, regapi.ScopedOperation{ + Operation: regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: moduleA.ID}}, + Scope: regapi.ScopeBaseline, + }) } func TestDependencyHandler_ExpandChangesRetargetsEarlierRootWithoutLeavingOldModule(t *testing.T) { @@ -259,32 +253,35 @@ func TestDependencyHandler_ExpandChangesRetargetsEarlierRootWithoutLeavingOldMod result, err := handler.ExpandChanges(ctx, regapi.ChangeSet{ {Kind: regapi.EntryUpdate, Entry: rootC}, {Kind: regapi.EntryUpdate, Entry: rootB}, - }, fixtureState(regapi.State{rootA, rootB, moduleA, moduleB})) + }, regapi.State{rootA, rootB, moduleA, moduleB}) require.NoError(t, err) require.Equal(t, "acme/c", result.Resolution.Roots[0].Component) - requireScopedDelete(t, result.Additional, moduleA.ID) + require.Contains(t, result.Additional, regapi.ScopedOperation{ + Operation: regapi.Operation{Kind: regapi.EntryDelete, Entry: regapi.Entry{ID: moduleA.ID}}, + Scope: regapi.ScopeBaseline, + }) } -func TestDependencyHandler_ExpandChangesRetaggingRootStillRemovesOwnedModule(t *testing.T) { +func TestDependencyHandler_ExpandChangesAuthorMetaDoesNotChangeModuleOwnership(t *testing.T) { ctx := newTestContext() - handler, err := NewDependencyHandler(DependencyHandlerOptions{ - Hub: &fakeHub{}, Logger: zap.NewNop(), VendorDir: t.TempDir(), - }) - require.NoError(t, err) + handler := referenceFoldHandler(t) root := hardeningRoot("app.deps:a", "acme/a", "v1.0.0") owned := hardeningModuleEntry("acme.a:entry", "acme/a", "v1.0.0") + ctx = withCurrentResolution(ctx, regapi.ResolvedModule{Name: "acme/a", Version: "v1.0.0"}) retagged := root - retagged.Meta = attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "host/owner", - }) + retagged.Meta = attrs.NewBagFrom(map[string]any{"label": "host"}) - result, err := handler.ExpandChanges(ctx, regapi.ChangeSet{ - fixtureOperation(regapi.Operation{Kind: regapi.EntryUpdate, Entry: retagged}), - }, fixtureState(regapi.State{root, owned})) + result, err := handler.ExpandChanges(ctx, regapi.ChangeSet{{ + Kind: regapi.EntryUpdate, Entry: retagged, + }}, regapi.State{root, owned}) require.NoError(t, err) require.True(t, result.Applied) - requireScopedDelete(t, result.Additional, owned.ID) + require.Len(t, result.Additional, 1) + op := result.Additional[0].Operation + require.Equal(t, regapi.EntryUpdate, op.Kind) + require.Equal(t, owned.ID, op.Entry.ID) + require.Equal(t, "acme/a", op.Entry.Registry.Owner) } func TestDependencyHandler_ExpandUnchangedRootStillReturnsLegacyCheckpointGraph(t *testing.T) { @@ -303,7 +300,7 @@ func TestDependencyHandler_ExpandUnchangedRootStillReturnsLegacyCheckpointGraph( } handler, err := NewDependencyHandler(DependencyHandlerOptions{Hub: hub, Logger: zap.NewNop(), VendorDir: t.TempDir()}) require.NoError(t, err) - result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryUpdate, Entry: root}, fixtureState(regapi.State{root, module})) + result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryUpdate, Entry: root}, regapi.State{root, module}) require.NoError(t, err) require.NotNil(t, result.Resolution) require.Len(t, result.Resolution.Modules, 1) @@ -316,17 +313,17 @@ func TestDependencyHandler_ReconcileRejectsRootSetDriftAndDuplicates(t *testing. rootA := hardeningRoot("app.deps:a", "acme/a", "v1.0.0") rootB := hardeningRoot("app.deps:b", "acme/b", "v1.0.0") - _, err = handler.ReconcileResolution(ctx, fixtureState(regapi.State{rootA, rootB}), fixtureState(regapi.State{rootA, rootB}), hardeningResolution(rootA)) + _, err = handler.ReconcileResolution(ctx, regapi.State{rootA, rootB}, regapi.State{rootA, rootB}, hardeningResolution(rootA)) require.ErrorContains(t, err, "root set") duplicate := hardeningResolution(rootA, rootB) duplicate.Roots[1] = duplicate.Roots[0] duplicate = duplicate.Canonical() - _, err = handler.ReconcileResolution(ctx, fixtureState(regapi.State{rootA, rootB}), fixtureState(regapi.State{rootA, rootB}), duplicate) + _, err = handler.ReconcileResolution(ctx, regapi.State{rootA, rootB}, regapi.State{rootA, rootB}, duplicate) require.ErrorContains(t, err, "stored dependency resolution is invalid") rootBDuplicateComponent := hardeningRoot("app.deps:b", "acme/a", "v1.0.0") - _, err = handler.ReconcileResolution(ctx, fixtureState(regapi.State{rootA, rootBDuplicateComponent}), fixtureState(regapi.State{rootA, rootBDuplicateComponent}), hardeningResolution(rootA, rootBDuplicateComponent)) + _, err = handler.ReconcileResolution(ctx, regapi.State{rootA, rootBDuplicateComponent}, regapi.State{rootA, rootBDuplicateComponent}, hardeningResolution(rootA, rootBDuplicateComponent)) require.ErrorContains(t, err, "stored dependency resolution is invalid") } @@ -337,7 +334,8 @@ func TestDependencyHandler_ReconcileAcceptsStoredLabelSelectionOffline(t *testin handler, err := NewDependencyHandler(DependencyHandlerOptions{Hub: &fakeHub{}, Logger: zap.NewNop(), VendorDir: t.TempDir()}) require.NoError(t, err) resolution := hardeningResolution(root) - result, err := handler.ReconcileResolution(ctx, fixtureState(regapi.State{root, module}), fixtureState(regapi.State{root, module}), resolution) + ctx = withCurrentResolution(ctx, resolution.Modules...) + result, err := handler.ReconcileResolution(ctx, regapi.State{root, module}, regapi.State{root, module}, resolution) require.NoError(t, err) require.Equal(t, resolution.Digest, result.Resolution.Digest) } @@ -366,9 +364,9 @@ func TestDependencyHandler_ReconcileReloadsOnlyModuleWithChangedRootParameters(t root := func(value string) regapi.Entry { return regapi.Entry{ - ID: regapi.NewID("app.deps", "feature"), - Kind: regapi.NamespaceDependency, - DependencyRoot: true, + ID: regapi.NewID("app.deps", "feature"), + Kind: regapi.NamespaceDependency, + Registry: regapi.EntryMetadata{Root: true}, Data: payload.New(map[string]any{ "component": "acme/feature", "version": "v1.0.0", @@ -378,24 +376,19 @@ func TestDependencyHandler_ReconcileReloadsOnlyModuleWithChangedRootParameters(t }), } } - moduleMeta := attrs.NewBagFrom(map[string]any{ - fixtureModuleKey: "acme/feature", - fixtureModuleVersionKey: "v1.0.0", - fixtureModuleDigestKey: digest, - }) requirement := regapi.Entry{ - ID: regapi.NewID("acme.feature", "scope"), - Kind: regapi.NamespaceRequirement, - Meta: moduleMeta, + ID: regapi.NewID("acme.feature", "scope"), + Kind: regapi.NamespaceRequirement, + Registry: regapi.EntryMetadata{Owner: "acme/feature"}, Data: payload.New(map[string]any{ "targets": []any{map[string]any{"entry": "policy", "path": ".groups +="}}, }), } policy := regapi.Entry{ - ID: regapi.NewID("acme.feature", "policy"), - Kind: "security.policy", - Meta: moduleMeta, - Data: payload.New(map[string]any{"groups": []any{"scope:old"}}), + ID: regapi.NewID("acme.feature", "policy"), + Kind: "security.policy", + Registry: regapi.EntryMetadata{Owner: "acme/feature"}, + Data: payload.New(map[string]any{"groups": []any{"scope:old"}}), } beforeRoot, targetRoot := root("scope:old"), root("scope:new") current := regapi.State{beforeRoot, requirement, policy} @@ -411,7 +404,7 @@ func TestDependencyHandler_ReconcileReloadsOnlyModuleWithChangedRootParameters(t VendorDir: vendorDir, }) require.NoError(t, err) - result, err := handler.ReconcileResolution(ctx, fixtureState(current), fixtureState(target), resolution) + result, err := handler.ReconcileResolution(ctx, current, target, resolution) require.NoError(t, err) var updated *regapi.Entry @@ -433,14 +426,14 @@ func TestDependencyHandler_ReconcileRejectsUnsafeStoredArtifactIdentity(t *testi resolution := hardeningResolution(root) handler, err := NewDependencyHandler(DependencyHandlerOptions{Hub: &fakeHub{}, Logger: zap.NewNop(), VendorDir: t.TempDir()}) require.NoError(t, err) - _, err = handler.ReconcileResolution(ctx, fixtureState(regapi.State{root}), fixtureState(regapi.State{root}), resolution) + _, err = handler.ReconcileResolution(ctx, regapi.State{root}, regapi.State{root}, resolution) require.ErrorContains(t, err, "invalid module name") safeRoot := hardeningRoot("app.deps:safe", "acme/safe", "v1.0.0") badDigest := hardeningResolution(safeRoot) badDigest.Modules[0].Digest = "sha256:deadbeef" badDigest = badDigest.Canonical() - _, err = handler.ReconcileResolution(ctx, fixtureState(regapi.State{safeRoot}), fixtureState(regapi.State{safeRoot}), badDigest) + _, err = handler.ReconcileResolution(ctx, regapi.State{safeRoot}, regapi.State{safeRoot}, badDigest) require.ErrorContains(t, err, "invalid sha256 digest") require.Error(t, validateModuleArtifactIdentity(graph.Name{Organization: "acme", Module: "safe"}, "1.0.0/../../escape", "")) diff --git a/boot/deps/hub/resolve.go b/boot/deps/hub/resolve.go index b16453ef1..d1960f3d0 100644 --- a/boot/deps/hub/resolve.go +++ b/boot/deps/hub/resolve.go @@ -639,7 +639,7 @@ func (r *resolver) fetchManifest(ctx context.Context, org, name, version string) } expected := r.lockedDigests[org+"/"+name+"@"+version] - if expected == "" || manifest.Digest == expected { + if expected == "" || artifactDigestsEqual(manifest.Digest, expected) { return manifest, nil } @@ -648,7 +648,7 @@ func (r *resolver) fetchManifest(ctx context.Context, org, name, version string) if ferr != nil { return nil, ferr } - if fresh != nil && fresh.Digest == expected { + if fresh != nil && artifactDigestsEqual(fresh.Digest, expected) { return fresh, nil } if fresh != nil { diff --git a/boot/deps/hub/resolve_test.go b/boot/deps/hub/resolve_test.go index e5c33fea1..373648241 100644 --- a/boot/deps/hub/resolve_test.go +++ b/boot/deps/hub/resolve_test.go @@ -480,6 +480,23 @@ func TestResolve_LockedDigestMatch(t *testing.T) { assert.Equal(t, "sha256:1.0.0", result.Modules[0].Digest) } +func TestResolve_LockedDigestMatchAcceptsBareHubDigest(t *testing.T) { + p := newMutableProvider() + p.addModule("acme", "http", "1.0.0") + p.override("acme", "http", "1.0.0", "abcdef1234") + + result, err := Resolve(context.Background(), p, []DependencySpec{ + {Org: "acme", Name: "http", Constraint: "1.0.0"}, + }, &ResolveOptions{ + LockedDigests: map[string]string{"acme/http@1.0.0": "sha256:abcdef1234"}, + }) + + require.NoError(t, err) + assert.Empty(t, result.Errors) + require.Len(t, result.Modules, 1) + assert.Equal(t, "abcdef1234", result.Modules[0].Digest) +} + func TestResolve_LockedDigestMismatchBareProvider(t *testing.T) { p := newFakeProvider() p.addModule("acme", "http", "1.0.0") diff --git a/boot/deps/hub/restore_sources.go b/boot/deps/hub/restore_sources.go new file mode 100644 index 000000000..55c9aceb8 --- /dev/null +++ b/boot/deps/hub/restore_sources.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MPL-2.0 + +package hub + +import ( + "context" + "fmt" + "path/filepath" + + moduleapi "github.com/wippyai/runtime/api/modules" +) + +// prepareRestoreSources materializes the stored graph and publishes the same +// source registry used by live dependency transitions. +func (h *DependencyHandler) prepareRestoreSources(ctx context.Context, modules []ResolvedModule) error { + sources := make(moduleapi.Sources, len(modules)) + for index, module := range modules { + path, err := h.ensureModuleAvailable(ctx, module) + if err != nil { + return fmt.Errorf("materialize recorded module %s: %w", modKey(module), err) + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve recorded module %s: %w", modKey(module), err) + } + name := module.Org + "/" + module.Name + source := moduleapi.Source{ + LoadPath: absolutePath, + Owner: name, + Version: module.Version, + Digest: module.Digest, + Sequence: uint64(index) + 1, + DeploymentRoot: h.isDeploymentRoot(name), + Replacement: module.Source == moduleSourceReplacementTreeV1, + } + if source.Replacement { + source.ResourceRoot = absolutePath + } + sources[name] = source + } + registry := moduleapi.GetSourceRegistry(ctx) + if registry == nil { + return nil + } + registry.Set(sources) + return nil +} + +func (h *DependencyHandler) materializeRestoreModules(ctx context.Context, modules []ResolvedModule) error { + for _, module := range modules { + if _, err := h.ensureModuleAvailable(ctx, module); err != nil { + return fmt.Errorf("materialize recorded module %s: %w", modKey(module), err) + } + } + return nil +} diff --git a/boot/deps/hub/root_application_update_test.go b/boot/deps/hub/root_application_update_test.go index c662c4567..f05139784 100644 --- a/boot/deps/hub/root_application_update_test.go +++ b/boot/deps/hub/root_application_update_test.go @@ -127,55 +127,40 @@ modules: require.NoError(t, err) appRoot := regapi.Entry{ - ID: regapi.NewID("deployment.packages", "app"), Kind: regapi.NamespaceDependency, DependencyRoot: true, + ID: regapi.NewID("deployment.packages", "app"), Kind: regapi.NamespaceDependency, Registry: regapi.EntryMetadata{Root: true}, Data: payload.New(map[string]any{"component": "acme/app", "version": "v1.0.0"}), } - workerRoot := fixtureOwned(regapi.Entry{ - ID: workerRootID, Kind: regapi.NamespaceDependency, DependencyRoot: true, + workerRoot := ownedEntry(regapi.Entry{ + ID: workerRootID, Kind: regapi.NamespaceDependency, Registry: regapi.EntryMetadata{Root: true}, Data: payload.New(map[string]any{ "component": "acme/worker", "version": "v1.0.0", "parameters": []any{map[string]any{"name": "acme.worker:router", "value": "app:api"}}, }), - }, "acme/app", "v1.0.0", "sha256:old-app") + }, "acme/app") snapshot := regapi.State{ appRoot, workerRoot, - fixtureOwned(regapi.Entry{ID: regapi.NewID("acme.app", "definition"), Kind: regapi.NamespaceDefinition}, - "acme/app", "v1.0.0", "sha256:old-app"), - fixtureOwned(regapi.Entry{ID: regapi.NewID("acme.app", "service"), Kind: "service"}, - "acme/app", "v1.0.0", "sha256:old-app"), - fixtureOwned(regapi.Entry{ID: regapi.NewID("acme.worker", "definition"), Kind: regapi.NamespaceDefinition}, - "acme/worker", "v1.0.0", digests[selection{"acme/worker", "v1.0.0"}]), - fixtureOwned(regapi.Entry{ID: regapi.NewID("acme.worker", "service"), Kind: "service"}, - "acme/worker", "v1.0.0", digests[selection{"acme/worker", "v1.0.0"}]), + ownedEntry(regapi.Entry{ID: regapi.NewID("acme.app", "definition"), Kind: regapi.NamespaceDefinition}, "acme/app"), + ownedEntry(regapi.Entry{ID: regapi.NewID("acme.app", "service"), Kind: "service"}, "acme/app"), + ownedEntry(regapi.Entry{ID: regapi.NewID("acme.worker", "definition"), Kind: regapi.NamespaceDefinition}, "acme/worker"), + ownedEntry(regapi.Entry{ID: regapi.NewID("acme.worker", "service"), Kind: "service"}, "acme/worker"), } updatedRoot := appRoot updatedRoot.Data = payload.New(map[string]any{"component": "acme/app", "version": "v2.0.0"}) - result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryUpdate, Entry: updatedRoot}, fixtureState(snapshot)) + result, err := handler.Expand(ctx, regapi.Operation{Kind: regapi.EntryUpdate, Entry: updatedRoot}, snapshot) require.NoError(t, err) - // The nested declaration is byte-identical across the two artifacts and its - // root selection is unchanged, so the update produces no operation for it. - // Losing the selection would surface here as a demotion. - for _, scoped := range result.Additional { - if scoped.Operation.Entry.ID != workerRootID { - continue - } - require.NotNil(t, scoped.Operation.Provenance) - require.True(t, scoped.Operation.Provenance.Root, - "dependencies declared by the selected root application must remain deployment roots") - } - var appService *regapi.Operation + var nestedUpdate *regapi.Operation for i := range result.Additional { - if result.Additional[i].Operation.Entry.ID == regapi.NewID("acme.app", "service") { + if result.Additional[i].Operation.Entry.ID == workerRootID { op := result.Additional[i].Operation - appService = &op + nestedUpdate = &op break } } - require.NotNil(t, appService, "the changed application entry is updated") - require.NotNil(t, appService.Provenance) - require.Equal(t, "v2.0.0", appService.Provenance.Version, "the operation carries the new resident identity") + require.NotNil(t, nestedUpdate, "root package update must replace its nested dependency declaration") + require.True(t, nestedUpdate.Entry.Registry.Root, + "dependencies declared by the selected root application must remain deployment roots") // Exercise the real root self-update shape. The deployment root is selected // by wippy.lock and therefore has no synthetic registry dependency at v0. @@ -197,26 +182,21 @@ modules: v1AppEntries, err := loadEntriesFromWappBytesForTest(artifacts[selection{"acme/app", "v1.0.0"}]) require.NoError(t, err) for i := range v1AppEntries { - v1AppEntries[i] = fixtureOwned(v1AppEntries[i], "acme/app", "v1.0.0", digests[selection{"acme/app", "v1.0.0"}]) + v1AppEntries[i] = ownedEntry(v1AppEntries[i], "acme/app") if v1AppEntries[i].Kind == regapi.NamespaceDependency { - v1AppEntries[i].DependencyRoot = true + v1AppEntries[i].Registry.Root = true } } workerEntries, err := loadEntriesFromWappBytesForTest(artifacts[selection{"acme/worker", "v1.0.0"}]) require.NoError(t, err) for i := range workerEntries { - workerEntries[i] = fixtureOwned(workerEntries[i], "acme/worker", "v1.0.0", digests[selection{"acme/worker", "v1.0.0"}]) + workerEntries[i] = ownedEntry(workerEntries[i], "acme/worker") } baseline := make(regapi.State, 0, len(v1AppEntries)+len(workerEntries)) baseline = append(baseline, v1AppEntries...) baseline = append(baseline, workerEntries...) v0 := version.FromParent(nil, regapi.RootVersion) - require.NoError(t, reg.LoadState(ctx, fixtureState(baseline), v0)) - residentVersion := func(id regapi.ID) string { - record, ok := reg.EntryProvenance(id) - require.True(t, ok, "every entry of the live state has a provenance record") - return record.Version - } + require.NoError(t, reg.LoadState(ctx, baseline, v0)) v0Resolution, err := history.GetDependencyResolution(v0) require.NoError(t, err) require.Equal(t, "v1.0.0", resolutionModuleVersion(v0Resolution, "acme/app"), @@ -232,12 +212,9 @@ modules: workerUpdate.Meta = nil _, err = reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryUpdate, Entry: workerUpdate}}) require.NoError(t, err) - _, err = reg.GetEntry(workerRootID) + installedWorker, err = reg.GetEntry(workerRootID) require.NoError(t, err) - workerRecord, ok := reg.EntryProvenance(workerRootID) - require.True(t, ok) - require.Equal(t, "acme/app", workerRecord.Module, - "an update without provenance preserves the record the registry holds") + require.Equal(t, "acme/app", entryModule(installedWorker)) overlayRoot := regapi.Entry{ ID: regapi.NewID("deployment.packages", "application"), Kind: regapi.NamespaceDependency, @@ -245,28 +222,25 @@ modules: } v1, err := reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: overlayRoot}}) require.NoError(t, err) - service, err := reg.GetEntry(regapi.NewID("acme.app", "service")) + _, err = reg.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v2.0.0", residentVersion(service.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, reg, "acme/app")) _, err = reg.GetEntry(adminPolicyID) require.Error(t, err, "v2 fixture deliberately removes the v1 policy") require.NoError(t, reg.ApplyVersion(ctx, v0)) - service, err = reg.GetEntry(regapi.NewID("acme.app", "service")) + _, err = reg.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v1", service.Data.Data().(map[string]any)["version"]) - require.Equal(t, "v1.0.0", residentVersion(service.ID), - "undo restores the resident identity of the version it reveals") + require.Equal(t, "v1.0.0", snapshotModuleVersion(t, reg, "acme/app")) _, err = reg.GetEntry(adminPolicyID) require.NoError(t, err, "undo must reveal every entry from the locked root package") _, err = reg.GetEntry(overlayRoot.ID) require.Error(t, err) require.NoError(t, reg.ApplyVersion(ctx, v1)) - service, err = reg.GetEntry(regapi.NewID("acme.app", "service")) + _, err = reg.GetEntry(regapi.NewID("acme.app", "service")) require.NoError(t, err) - require.Equal(t, "v2", service.Data.Data().(map[string]any)["version"]) - require.Equal(t, "v2.0.0", residentVersion(service.ID)) + require.Equal(t, "v2.0.0", snapshotModuleVersion(t, reg, "acme/app")) _, err = reg.GetEntry(adminPolicyID) require.Error(t, err) } diff --git a/boot/deps/hub/source_effect.go b/boot/deps/hub/source_effect.go index db56fe7ea..df9f007e7 100644 --- a/boot/deps/hub/source_effect.go +++ b/boot/deps/hub/source_effect.go @@ -115,7 +115,7 @@ func (h *DependencyHandler) buildSourceEffect( Owner: owner, Version: mod.Version, Digest: mod.Digest, - DeploymentRoot: h.lock != nil && h.lock.IsRootModule(module), + DeploymentRoot: h.isDeploymentRoot(module), Replacement: replacementSource, } } diff --git a/boot/handler.go b/boot/handler.go index aea663d93..052ba7c16 100644 --- a/boot/handler.go +++ b/boot/handler.go @@ -135,10 +135,6 @@ func wrapListener(kinds registry.Kind, listener registry.EntryListener) eventbus return nil } - if opProv, ok := evt.Aux.(registry.OpProvenance); ok { - ctx = registry.WithOpProvenance(ctx, opProv) - } - bus := event.GetBus(ctx) if err := dispatchEntry(ctx, evt.Kind, entry, listener); err != nil { bus.Send(ctx, event.Event{ diff --git a/cmd/internal/entries/loader.go b/cmd/internal/entries/loader.go index 115d2e9a8..513fb749f 100644 --- a/cmd/internal/entries/loader.go +++ b/cmd/internal/entries/loader.go @@ -27,6 +27,7 @@ import ( "github.com/wippyai/runtime/boot/deps/wappextract" "github.com/wippyai/runtime/cmd/internal/hubclient" embedpkg "github.com/wippyai/runtime/service/fs/embed" + registrymigration "github.com/wippyai/runtime/system/registry/migration" regtop "github.com/wippyai/runtime/system/registry/topology" "github.com/wippyai/wapp" "go.uber.org/zap" @@ -40,9 +41,29 @@ func LoadFromLockFile(ctx context.Context, logger *zap.Logger) error { lockPath, err := lock.Find(".", lockFilePath) if err != nil { - logger.Info("no lock file found, starting with empty registry") - ConfigureSourceLoader(ctx, nil, logger) - return nil + if !os.IsNotExist(err) { + return NewLoadLockFileError(err) + } + sources := moduleapi.GetSourceRegistry(ctx) + if sources == nil { + logger.Info("no deployment sources found, starting with empty registry") + return nil + } + modulePaths := moduleLoadPathsFromSources(sources.Snapshot()) + if len(modulePaths) == 0 { + logger.Info("no deployment sources found, starting with empty registry") + return nil + } + configureSourceLoader(sources, logger) + loaded, loadErr := LoadEntriesFromModuleLoadPaths(ctx, modulePaths, logger) + if loadErr != nil { + return NewLoadEntriesFromPathsError(loadErr) + } + if err := registerWappWithEmbedRegistry(ctx, modulePaths, logger); err != nil { + return err + } + logger.Info("loaded entries from registry deployment", zap.Int("count", len(loaded))) + return LoadEntriesToRegistry(ctx, loaded, logger) } logger.Info("loading entries from lock file", zap.String("path", lockPath)) @@ -68,7 +89,7 @@ func LoadFromLockFile(ctx context.Context, logger *zap.Logger) error { } logger.Debug("load paths from lock file", zap.Strings("paths", flatPaths)) - entries, prov, err := LoadEntriesFromModuleLoadPaths(ctx, modulePaths, logger) + entries, err := LoadEntriesFromModuleLoadPaths(ctx, modulePaths, logger) if err != nil { return NewLoadEntriesFromPathsError(err) } @@ -80,7 +101,7 @@ func LoadFromLockFile(ctx context.Context, logger *zap.Logger) error { return err } - if err := LoadEntriesToRegistry(ctx, entries, prov, logger); err != nil { + if err := LoadEntriesToRegistry(ctx, entries, logger); err != nil { return err // Already has context from registry } @@ -104,6 +125,23 @@ func EnsureModulesInstalled(ctx context.Context, lockPath string, logger *zap.Lo return ensureModulesInstalledFromLock(ctx, lockObj, logger) } +// EnsureModulesInstalledFromLock materializes the exact module selection held +// by lockObj. An optional client pins artifact retrieval to a selected registry. +func EnsureModulesInstalledFromLock( + ctx context.Context, + lockObj *lock.Lock, + logger *zap.Logger, + hubClient *hub.Client, +) error { + if lockObj == nil { + return NewLoadLockFileError(fmt.Errorf("lock file is required")) + } + if err := lock.Validate(lockObj); err != nil { + return NewInvalidLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) + } + return ensureModulesInstalledFromLockWithClient(ctx, lockObj, logger, hubClient) +} + func warnTrackedLockReplacements(lockObj *lock.Lock, logger *zap.Logger) { if lockObj == nil || logger == nil || len(lockObj.GetTrackedReplacements()) == 0 { return @@ -129,6 +167,15 @@ func verifyModuleArtifact(path, lockedDigest, servedDigest string, size uint64) } func ensureModulesInstalledFromLock(ctx context.Context, lockObj *lock.Lock, logger *zap.Logger) error { + return ensureModulesInstalledFromLockWithClient(ctx, lockObj, logger, nil) +} + +func ensureModulesInstalledFromLockWithClient( + ctx context.Context, + lockObj *lock.Lock, + logger *zap.Logger, + hubClient *hub.Client, +) error { modules := lockObj.GetModules() if len(modules) == 0 { return nil @@ -184,10 +231,12 @@ func ensureModulesInstalledFromLock(ctx context.Context, lockObj *lock.Lock, log logger.Info("auto-installing missing modules", zap.Int("count", len(missingModules))) - // Create hub client - hubClient, err := createHubClient() - if err != nil { - return fmt.Errorf("failed to create hub client: %w", err) + if hubClient == nil { + var err error + hubClient, err = createHubClient() + if err != nil { + return fmt.Errorf("failed to create hub client: %w", err) + } } for _, mod := range missingModules { @@ -318,22 +367,23 @@ func LoadEntriesFromPaths(ctx context.Context, paths []string, logger *zap.Logge entries = append(entries, loadedEntries...) } - if err := NormalizeEntries(ctx, &entries, nil); err != nil { + if err := NormalizeEntries(ctx, &entries); err != nil { return nil, err } return entries, nil } -// LoadEntriesFromModuleLoadPaths loads entries from lock module paths together -// with the provenance recorded from the load context. +// LoadEntriesFromModuleLoadPaths loads entries from the selected deployment +// sources. The loader assigns registry-owned metadata from each load path; +// package contents cannot claim their own owner or deployment-root status. func LoadEntriesFromModuleLoadPaths( ctx context.Context, modulePaths []lock.ModuleLoadPath, logger *zap.Logger, -) ([]regapi.Entry, regapi.ProvenanceMap, error) { +) ([]regapi.Entry, error) { ConfigureSourceLoader(ctx, modulePaths, logger) - return loadEntriesWithModuleMeta(ctx, modulePaths, logger) + return loadEntriesFromModulePaths(ctx, modulePaths, logger) } // ConfigureSourceLoader registers the current deployment inputs and the @@ -343,42 +393,50 @@ func ConfigureSourceLoader(ctx context.Context, modulePaths []lock.ModuleLoadPat if registry == nil { return } - registry.SetLoader(func(loadCtx context.Context, sources moduleapi.Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { - ids := make([]string, 0, len(sources)) - for id := range sources { - ids = append(ids, id) + configureSourceLoader(registry, logger) +} + +func configureSourceLoader(registry *moduleapi.SourceRegistry, logger *zap.Logger) { + registry.SetLoader(func(loadCtx context.Context, sources moduleapi.Sources) ([]regapi.Entry, error) { + return loadEntriesFromModulePaths(loadCtx, moduleLoadPathsFromSources(sources), logger) + }) +} + +func moduleLoadPathsFromSources(sources moduleapi.Sources) []lock.ModuleLoadPath { + ids := make([]string, 0, len(sources)) + for id := range sources { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + leftSequence := sources[ids[i]].Sequence + rightSequence := sources[ids[j]].Sequence + if leftSequence != rightSequence { + return leftSequence < rightSequence } - sort.Slice(ids, func(i, j int) bool { - leftSequence := sources[ids[i]].Sequence - rightSequence := sources[ids[j]].Sequence - if leftSequence != rightSequence { - return leftSequence < rightSequence - } - return ids[i] < ids[j] - }) - paths := make([]lock.ModuleLoadPath, 0, len(ids)) - for _, id := range ids { - source := sources[id] - sourceRoot := source.ResourceRoot - if sourceRoot == "" { - sourceRoot = source.LoadPath - } - module := id - if source.Owner == moduleapi.ApplicationSourceID { - module = "" - } - paths = append(paths, lock.ModuleLoadPath{ - Path: source.LoadPath, - Module: module, - Version: source.Version, - Digest: source.Digest, - SourceRoot: sourceRoot, - Root: source.DeploymentRoot, - Replacement: source.Replacement, - }) - } - return loadEntriesWithModuleMeta(loadCtx, paths, logger) + return ids[i] < ids[j] }) + paths := make([]lock.ModuleLoadPath, 0, len(ids)) + for _, id := range ids { + source := sources[id] + sourceRoot := source.ResourceRoot + if sourceRoot == "" { + sourceRoot = source.LoadPath + } + module := id + if source.Owner == moduleapi.ApplicationSourceID { + module = "" + } + paths = append(paths, lock.ModuleLoadPath{ + Path: source.LoadPath, + Module: module, + Version: source.Version, + Digest: source.Digest, + SourceRoot: sourceRoot, + Root: source.DeploymentRoot, + Replacement: source.Replacement, + }) + } + return paths } func registerSources(ctx context.Context, modulePaths []lock.ModuleLoadPath) *moduleapi.SourceRegistry { @@ -441,25 +499,18 @@ func registerSources(ctx context.Context, modulePaths []lock.ModuleLoadPath) *mo return registry } -// loadEntriesWithModuleMeta loads entries from annotated paths and records -// each entry's provenance from the load context: owning module, version, -// artifact digest, and deployment-root selection. Entry payloads are loaded -// verbatim. -func loadEntriesWithModuleMeta(ctx context.Context, modulePaths []lock.ModuleLoadPath, logger *zap.Logger) ([]regapi.Entry, regapi.ProvenanceMap, error) { +// loadEntriesFromModulePaths loads entries from annotated deployment paths. +func loadEntriesFromModulePaths(ctx context.Context, modulePaths []lock.ModuleLoadPath, logger *zap.Logger) ([]regapi.Entry, error) { dtt := payload.GetTranscoder(ctx) if dtt == nil { - return nil, nil, ErrTranscoderNotFound + return nil, ErrTranscoderNotFound } ldr := boot.GetLoader(ctx) if ldr == nil { - return nil, nil, ErrLoaderNotFound + return nil, ErrLoaderNotFound } - prov := make(regapi.ProvenanceMap) - var entryProv []regapi.EntryProvenance - var fromReplacement []bool - replacementOwners := make(map[string]struct{}) for _, mp := range modulePaths { if mp.Replacement && mp.Module != "" { @@ -467,99 +518,68 @@ func loadEntriesWithModuleMeta(ctx context.Context, modulePaths []lock.ModuleLoa } } - var entries []regapi.Entry + type loadedPath struct { + entries []regapi.Entry + replacement bool + } + loadedPaths := make([]loadedPath, 0, len(modulePaths)) for _, mp := range modulePaths { - moduleDigest := mp.Digest - if mp.Replacement { - // Replacements are authoritative. If the configured source cannot be - // identified, fail instead of silently restoring an embedded generation. - root := mp.SourceRoot - if root == "" { - root = mp.Path - } - var digestErr error - moduleDigest, _, digestErr = hub.ReplacementTreeIdentity(root) - if digestErr != nil { - return nil, nil, fmt.Errorf("identify replacement module %s: %w", mp.Module, digestErr) + if !mp.Replacement { + if _, replaced := replacementOwners[mp.Module]; replaced { + continue } } loaded, err := loadEntriesFromModulePath(ctx, mp, ldr, dtt, logger) if err != nil { - return nil, nil, err - } - if !mp.Replacement && mp.Module != "" { - if _, replaced := replacementOwners[mp.Module]; replaced { - // A replacement is authoritative for its module: the vendored - // copy of that module contributes nothing. - continue - } + return nil, err } if shouldApplyModuleConfigFilters(mp) { loaded, err = applyModuleConfigFilters(ctx, mp, loaded, logger) if err != nil { - return nil, nil, err + return nil, err } } for i := range loaded { - p := regapi.EntryProvenance{ - Module: mp.Module, - Version: mp.Version, - Digest: moduleDigest, - Root: mp.Root && loaded[i].Kind == regapi.NamespaceDependency, + loaded[i].Registry = regapi.EntryMetadata{Owner: mp.Module} + if mp.Root && loaded[i].Kind == regapi.NamespaceDependency { + loaded[i].Registry.Root = true } - if mp.Module == "" { - p.Version = "" - p.Digest = "" - } - entryProv = append(entryProv, p) } - entries = append(entries, loaded...) - for range loaded { - fromReplacement = append(fromReplacement, mp.Replacement) - } + loadedPaths = append(loadedPaths, loadedPath{replacement: mp.Replacement, entries: loaded}) } - if len(replacementOwners) > 0 { - // A replacement is authoritative for the entry IDs it provides: any - // copy of those IDs loaded from a non-replacement source yields. - replacementIDs := make(map[regapi.ID]struct{}) - for i, entry := range entries { - if fromReplacement[i] { - replacementIDs[entry.ID.Canonical()] = struct{}{} - } + replacementEntries := make(map[regapi.ID]struct{}) + for _, source := range loadedPaths { + if !source.replacement { + continue } - filtered := entries[:0] - filteredProv := entryProv[:0] - for i, entry := range entries { - if !fromReplacement[i] { - if _, owned := replacementIDs[entry.ID.Canonical()]; owned { + for _, entry := range source.entries { + replacementEntries[entry.ID.Canonical()] = struct{}{} + } + } + + entries := make([]regapi.Entry, 0) + for _, source := range loadedPaths { + for _, entry := range source.entries { + if !source.replacement { + if _, replaced := replacementEntries[entry.ID.Canonical()]; replaced { continue } } - filtered = append(filtered, entry) - filteredProv = append(filteredProv, entryProv[i]) + entries = append(entries, entry) } - entries = filtered - entryProv = filteredProv - } - - // The map is built from the surviving entries, so a filtered copy can - // never leave its record behind. Later survivors win, matching entry - // last-definition semantics. - for i, entry := range entries { - prov[entry.ID.Canonical()] = entryProv[i] } - if err := NormalizeEntries(ctx, &entries, prov); err != nil { - return nil, nil, err + if err := NormalizeEntries(ctx, &entries); err != nil { + return nil, err } - return entries, prov, nil + return entries, nil } func shouldApplyModuleConfigFilters(mp lock.ModuleLoadPath) bool { @@ -619,11 +639,11 @@ func applyModuleConfigFilters( // 2. disable: removes excluded entries before link mutation // 3. link: resolves requirements from dependencies/defaults // 4. post-link override: allows explicit final-value overrides -func NormalizeEntries(ctx context.Context, entries *[]regapi.Entry, prov regapi.ProvenanceMap) error { +func NormalizeEntries(ctx context.Context, entries *[]regapi.Entry) error { pipeline := build.New( stages.Override(), stages.Disable(), - stages.Link(prov), + stages.Link(), stages.Override(), ) @@ -645,6 +665,9 @@ func loadEntriesFromModulePath(ctx context.Context, mp lock.ModuleLoadPath, ldr stat, err := os.Stat(path) if os.IsNotExist(err) { + if mp.Replacement { + return nil, NewLoadFromPathError(path, fmt.Errorf("replacement source for module %s is unavailable", mp.Module)) + } logger.Warn("path not found, skipping", zap.String("path", path)) return nil, nil } @@ -674,6 +697,7 @@ func loadEntriesFromModulePath(ctx context.Context, mp lock.ModuleLoadPath, ldr return nil, nil } +// loadEntriesFromWapp loads entries from a .wapp file. func loadEntriesFromWapp(path string, dtt payload.Transcoder) ([]regapi.Entry, error) { file, err := os.Open(path) if err != nil { @@ -690,7 +714,7 @@ func loadEntriesFromWapp(path string, dtt payload.Transcoder) ([]regapi.Entry, e } // LoadEntriesToRegistry loads entries into the registry using LoadState to restore from history. -func LoadEntriesToRegistry(ctx context.Context, entries []regapi.Entry, prov regapi.ProvenanceMap, logger *zap.Logger) error { +func LoadEntriesToRegistry(ctx context.Context, entries []regapi.Entry, logger *zap.Logger) error { if err := waitForListenerReadiness(ctx, logger); err != nil { return err } @@ -751,15 +775,11 @@ func LoadEntriesToRegistry(ctx context.Context, entries []regapi.Entry, prov reg logger.Info("initializing registry with baseline state at v0") } - if prov == nil { - prov = make(regapi.ProvenanceMap, len(baselineState)) - } - for _, entry := range baselineState { - if _, ok := prov[entry.ID.Canonical()]; !ok { - prov[entry.ID.Canonical()] = regapi.EntryProvenance{} - } + if err := registrymigration.Apply(ctx, hist, baselineState); err != nil { + return fmt.Errorf("migrate registry history: %w", err) } - if err := reg.LoadState(ctx, regapi.ProvenancedState{Entries: baselineState, Provenance: prov}, head); err != nil { + + if err := reg.LoadState(ctx, baselineState, head); err != nil { return err // Already wrapped by registry with proper context } diff --git a/cmd/internal/entries/loader_test.go b/cmd/internal/entries/loader_test.go index a57519ff2..326d38050 100644 --- a/cmd/internal/entries/loader_test.go +++ b/cmd/internal/entries/loader_test.go @@ -25,7 +25,6 @@ import ( regapi "github.com/wippyai/runtime/api/registry" bootpkg "github.com/wippyai/runtime/boot" "github.com/wippyai/runtime/boot/components/core" - "github.com/wippyai/runtime/boot/deps/hub" "github.com/wippyai/runtime/boot/deps/lock" transcoder "github.com/wippyai/runtime/system/payload" yamlpayload "github.com/wippyai/runtime/system/payload/yaml" @@ -233,7 +232,7 @@ entries: t.Fatal(err) } - loaded, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ + loaded, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ Path: moduleDir, Module: "acme/app", Version: "1.0.0", Digest: "sha256:app", SourceRoot: moduleDir, Root: true, }}, zap.NewNop()) if err != nil { @@ -242,25 +241,15 @@ entries: if len(loaded) != 1 { t.Fatalf("entries = %d, want 1", len(loaded)) } - if loaded[0].DependencyRoot { - t.Fatal("entry payload must not carry the deployment-root flag") - } - if loaded[0].Meta.GetString("module", "") != "" { - t.Fatal("entry payload must not carry ownership metadata") - } - p := prov[loaded[0].ID.Canonical()] - if !p.Root { + if !loaded[0].Registry.Root { t.Fatal("selected lock root dependency lost root provenance") } - if p.Module != "acme/app" { - t.Fatalf("package ownership = %q, want acme/app", p.Module) - } - if p.Digest != "sha256:app" { - t.Fatalf("package digest = %q, want sha256:app", p.Digest) + if got := loaded[0].Registry.Owner; got != "acme/app" { + t.Fatalf("package owner = %q, want acme/app", got) } } -func TestLoadEntriesFromReplacementCarriesCanonicalTreeIdentity(t *testing.T) { +func TestLoadEntriesFromReplacementAssignsSourceOwner(t *testing.T) { ctx := setupTestContext(t) moduleRoot := t.TempDir() sourceDir := filepath.Join(moduleRoot, "src") @@ -273,27 +262,23 @@ entries: data: local `), 0o600)) - wantDigest, _, err := hub.ReplacementTreeIdentity(moduleRoot) - require.NoError(t, err) - loaded, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ + loaded, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ Path: sourceDir, Module: "acme/replacement", Version: "1.2.3", SourceRoot: moduleRoot, Replacement: true, }}, zap.NewNop()) require.NoError(t, err) require.Len(t, loaded, 1) - p := prov[loaded[0].ID.Canonical()] - assert.Equal(t, "acme/replacement", p.Module) - assert.Equal(t, "1.2.3", p.Version) - assert.Equal(t, wantDigest, p.Digest) + assert.Equal(t, "acme/replacement", loaded[0].Registry.Owner) + assert.Empty(t, loaded[0].Meta.GetString("module", "")) } func TestLoadEntriesFromMissingReplacementFailsClosed(t *testing.T) { ctx := setupTestContext(t) missing := filepath.Join(t.TempDir(), "missing") - _, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ + _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ Path: missing, Module: "acme/replacement", SourceRoot: missing, Replacement: true, }}, zap.NewNop()) - require.ErrorContains(t, err, "identify replacement module acme/replacement") + require.ErrorContains(t, err, "replacement source for module acme/replacement is unavailable") } func TestLoadEntriesFromReplacementOwnsEntriesOverApplicationSnapshot(t *testing.T) { @@ -307,10 +292,6 @@ namespace: ownership.test entries: - name: value kind: test.value - meta: - module: acme/replacement - module_version: 0.9.0 - module_digest: sha256-tree-v1:stale data: stale - name: app_value kind: test.value @@ -321,16 +302,10 @@ namespace: ownership.test entries: - name: value kind: test.value - meta: - module: acme/replacement - module_version: 0.9.0 - module_digest: sha256-tree-v1:stale data: local `), 0o600)) - wantDigest, _, err := hub.ReplacementTreeIdentity(replacementRoot) - require.NoError(t, err) - loaded, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + loaded, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: appDir, Root: true}, {Path: replacementSource, Module: "acme/replacement", SourceRoot: replacementRoot, Replacement: true}, }, zap.NewNop()) @@ -342,13 +317,8 @@ entries: } owned := byID[regapi.NewID("ownership.test", "value")] assert.Equal(t, "local", owned.Data.Data()) - // Author meta passes through verbatim, stale keys included; ownership truth - // lives in the provenance map. - assert.Equal(t, "acme/replacement", owned.Meta.GetString("module", "")) - assert.Equal(t, "sha256-tree-v1:stale", owned.Meta.GetString("module_digest", "")) - ownedProv := prov[owned.ID.Canonical()] - assert.Equal(t, "acme/replacement", ownedProv.Module) - assert.Equal(t, wantDigest, ownedProv.Digest) + assert.Equal(t, "acme/replacement", owned.Registry.Owner) + assert.Empty(t, owned.Meta.GetString("module", "")) assert.Equal(t, "application", byID[regapi.NewID("ownership.test", "app_value")].Data.Data()) } @@ -373,15 +343,13 @@ entries: writeEntry(moduleV1, "source.lifecycle", "module", "v1") writeEntry(moduleV2, "source.lifecycle", "module", "v2") - _, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{Path: appDir, Root: true}}, zap.NewNop()) + _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{Path: appDir, Root: true}}, zap.NewNop()) require.NoError(t, err) - var lastProv regapi.ProvenanceMap loadByID := func() map[regapi.ID]regapi.Entry { t.Helper() loaded, loadErr := moduleapi.GetSourceRegistry(ctx).Load(ctx) require.NoError(t, loadErr) - lastProv = loaded.Provenance byID := make(map[regapi.ID]regapi.Entry, len(loaded.Entries)) for _, entry := range loaded.Entries { byID[entry.ID] = entry @@ -401,10 +369,7 @@ entries: byID = loadByID() moduleEntry := byID[regapi.NewID("source.lifecycle", "module")] assert.Equal(t, "v1", moduleEntry.Data.Data()) - moduleProv := lastProv[moduleEntry.ID.Canonical()] - assert.Equal(t, "acme/source", moduleProv.Module) - assert.Equal(t, "1.0.0", moduleProv.Version) - assert.Equal(t, "sha256:v1", moduleProv.Digest) + assert.Equal(t, "acme/source", moduleEntry.Registry.Owner) v2 := moduleapi.Source{LoadPath: moduleV2, ResourceRoot: moduleV2, Owner: "acme/source", Version: "2.0.0", Digest: "sha256:v2", Sequence: 2} previous, err = registry.Transition(moduleapi.Sources{"acme/source": v2}, nil, "acme/source") @@ -413,9 +378,7 @@ entries: byID = loadByID() moduleEntry = byID[regapi.NewID("source.lifecycle", "module")] assert.Equal(t, "v2", moduleEntry.Data.Data()) - moduleProv = lastProv[moduleEntry.ID.Canonical()] - assert.Equal(t, "2.0.0", moduleProv.Version) - assert.Equal(t, "sha256:v2", moduleProv.Digest) + assert.Equal(t, "acme/source", moduleEntry.Registry.Owner) _, err = registry.Transition(previous, nil, "acme/source") require.NoError(t, err) @@ -447,7 +410,7 @@ entries: data: second `), 0o600)) - initial, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{Path: first}, {Path: second}}, zap.NewNop()) + initial, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{Path: first}, {Path: second}}, zap.NewNop()) require.NoError(t, err) loaded, err := moduleapi.GetSourceRegistry(ctx).Load(ctx) require.NoError(t, err) @@ -490,7 +453,7 @@ entries: {Path: first, Module: "zeta/first"}, {Path: second, Module: "alpha/second"}, } - initial, _, err := LoadEntriesFromModuleLoadPaths(ctx, paths, zap.NewNop()) + initial, err := LoadEntriesFromModuleLoadPaths(ctx, paths, zap.NewNop()) require.NoError(t, err) loaded, err := moduleapi.GetSourceRegistry(ctx).Load(ctx) require.NoError(t, err) @@ -534,7 +497,7 @@ entries: t.Fatal(err) } - loaded, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ + loaded, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{{ Path: appDir, Root: true, }}, zap.NewNop()) @@ -544,15 +507,11 @@ entries: if len(loaded) != 1 { t.Fatalf("entries = %d, want 1", len(loaded)) } - p := prov[loaded[0].ID.Canonical()] - if !p.Root { + if !loaded[0].Registry.Root { t.Fatal("application source dependency was not marked as a deployment root") } - if p.Module != "" { - t.Fatalf("application source ownership = %q, want empty", p.Module) - } - if loaded[0].DependencyRoot { - t.Fatal("entry payload must not carry the deployment-root flag") + if got := loaded[0].Registry.Owner; got != "" { + t.Fatalf("application source owner = %q, want empty", got) } } @@ -629,7 +588,7 @@ entries: } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - loaded, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + loaded, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: appDir}, {Path: tt.path, Module: "example/accounts", Version: "1.0.0"}, }, logger) @@ -1385,7 +1344,7 @@ entries: t.Fatalf("write module views.yaml: %v", err) } - entries, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: moduleDir, Module: "wippy/views", Version: "0.4.15"}, }, logger) if err != nil { @@ -1406,8 +1365,8 @@ entries: if !ok { t.Fatalf("non-excluded module entry missing") } - if got := prov[publicAPI.ID.Canonical()].Module; got != "wippy/views" { - t.Fatalf("module provenance = %q, want wippy/views", got) + if got := publicAPI.Registry.Owner; got != "wippy/views" { + t.Fatalf("module owner = %q, want wippy/views", got) } } @@ -1447,7 +1406,7 @@ entries: t.Fatalf("write module _index.yaml: %v", err) } - entries, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: moduleDir, Module: "wippy/dataflow"}, }, logger) if err != nil { @@ -1465,8 +1424,8 @@ entries: if !ok { t.Fatalf("non-excluded module entry missing") } - if got := prov[real.ID.Canonical()].Module; got != "wippy/dataflow" { - t.Fatalf("module provenance = %q, want wippy/dataflow", got) + if got := real.Registry.Owner; got != "wippy/dataflow" { + t.Fatalf("module owner = %q, want wippy/dataflow", got) } } @@ -1528,7 +1487,7 @@ entries: t.Fatalf("write host app _index.yaml: %v", err) } - entries, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: appDir}, {Path: moduleDir, Module: "wippy/facade"}, }, logger) @@ -1548,8 +1507,8 @@ entries: if gatewayCount != 1 { t.Fatalf("app:gateway count = %d, want 1 (collision not filtered)", gatewayCount) } - if got := gateway.Meta.GetString("module", ""); got != "" { - t.Fatalf("app:gateway tagged with module=%q, want host (untagged) entry to win", got) + if got := gateway.Registry.Owner; got != "" { + t.Fatalf("app:gateway owner = %q, want host entry", got) } } @@ -1614,7 +1573,7 @@ entries: t.Fatalf("write host app _index.yaml: %v", err) } - entries, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: appDir}, {Path: srcDir, Module: "kickside/component", SourceRoot: moduleRoot}, }, logger) @@ -1635,8 +1594,8 @@ entries: if gatewayCount != 1 { t.Fatalf("app:gateway count = %d, want 1 (src-layout collision not filtered)", gatewayCount) } - if got := gateway.Meta.GetString("module", ""); got != "" { - t.Fatalf("app:gateway tagged with module=%q, want host (untagged) entry to win", got) + if got := gateway.Registry.Owner; got != "" { + t.Fatalf("app:gateway owner = %q, want host entry", got) } if _, ok := found["kickside.component:real_handler"]; !ok { t.Fatalf("non-excluded module entry missing") @@ -1694,7 +1653,7 @@ entries: t.Fatalf("write excluded source: %v", err) } - entries, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: srcDir, Module: "kickside/events", SourceRoot: moduleRoot}, }, logger) if err != nil { @@ -1748,7 +1707,7 @@ entries: t.Fatalf("write module _index.yaml: %v", err) } - _, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: srcDir, Module: "kickside/broken", SourceRoot: moduleRoot}, }, logger) if err == nil { @@ -1795,7 +1754,7 @@ entries: t.Fatalf("write module _index.yaml: %v", err) } - entries, _, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ + entries, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ {Path: srcDir, Module: "kickside/contract", SourceRoot: moduleRoot}, }, logger) if err != nil { @@ -1849,7 +1808,7 @@ func TestNormalizeEntries_PreLinkOverrideAffectsRequirementDefaults(t *testing.T }, } - if err := NormalizeEntries(ctx, &items, nil); err != nil { + if err := NormalizeEntries(ctx, &items); err != nil { t.Fatalf("NormalizeEntries failed: %v", err) } @@ -1920,7 +1879,7 @@ func TestNormalizeEntries_PostLinkOverrideWinsFinalValue(t *testing.T) { }, } - if err := NormalizeEntries(ctx, &items, nil); err != nil { + if err := NormalizeEntries(ctx, &items); err != nil { t.Fatalf("NormalizeEntries failed: %v", err) } @@ -2100,37 +2059,3 @@ func TestWaitForListenerReadiness_ContextCancelled(t *testing.T) { t.Fatal("expected cancellation error, got nil") } } - -// A replacement loaded before a colliding non-replacement copy must keep both -// its entry and its provenance record; the losing copy leaves nothing behind. -func TestLoadEntriesReplacementFirstKeepsItsProvenance(t *testing.T) { - ctx := setupTestContext(t) - appDir := t.TempDir() - replacementRoot := t.TempDir() - replacementSource := filepath.Join(replacementRoot, "src") - require.NoError(t, os.MkdirAll(replacementSource, 0o755)) - manifest := `version: "1.0" -namespace: order.test -entries: - - name: value - kind: test.value - data: %s -` - require.NoError(t, os.WriteFile(filepath.Join(replacementSource, "_index.yaml"), []byte(fmt.Sprintf(manifest, "local")), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(appDir, "_index.yaml"), []byte(fmt.Sprintf(manifest, "stale")), 0o600)) - - wantDigest, _, err := hub.ReplacementTreeIdentity(replacementRoot) - require.NoError(t, err) - - // Replacement FIRST, non-replacement copy after. - loaded, prov, err := LoadEntriesFromModuleLoadPaths(ctx, []lock.ModuleLoadPath{ - {Path: replacementSource, Module: "acme/replacement", SourceRoot: replacementRoot, Replacement: true}, - {Path: appDir, Root: true}, - }, zap.NewNop()) - require.NoError(t, err) - require.Len(t, loaded, 1) - assert.Equal(t, "local", loaded[0].Data.Data()) - record := prov[loaded[0].ID.Canonical()] - assert.Equal(t, "acme/replacement", record.Module) - assert.Equal(t, wantDigest, record.Digest) -} diff --git a/cmd/wippy/cmd/install.go b/cmd/wippy/cmd/install.go index 29bdab9b1..6a360a21c 100644 --- a/cmd/wippy/cmd/install.go +++ b/cmd/wippy/cmd/install.go @@ -9,7 +9,6 @@ import ( "path/filepath" "github.com/spf13/cobra" - regapi "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/boot/deps/artifact" bootauth "github.com/wippyai/runtime/boot/deps/auth" "github.com/wippyai/runtime/boot/deps/graph" @@ -412,7 +411,7 @@ func installedArtifactInputs( return packs, nil, nil } - loaded, loadedProv, err := entries.LoadEntriesFromModuleLoadPaths(ctx, replacementPaths, logger) + loaded, err := entries.LoadEntriesFromModuleLoadPaths(ctx, replacementPaths, logger) if err != nil { return nil, nil, fmt.Errorf("load replacement artifacts: %w", err) } @@ -424,7 +423,7 @@ func installedArtifactInputs( } roots[modulePath.Module] = root } - resources, err := artifact.DirectoryResources(ctx, regapi.ProvenancedState{Entries: loaded, Provenance: loadedProv}, roots, versions) + resources, err := artifact.DirectoryResources(ctx, loaded, roots, versions) if err != nil { return nil, nil, err } diff --git a/cmd/wippy/cmd/load_entries.go b/cmd/wippy/cmd/load_entries.go index 25cd3dc6a..6ca6f9bb1 100644 --- a/cmd/wippy/cmd/load_entries.go +++ b/cmd/wippy/cmd/load_entries.go @@ -11,9 +11,9 @@ import ( "go.uber.org/zap" ) -func loadEntriesFromLockPaths(ctx context.Context, lockObj *lock.Lock, logger *zap.Logger) ([]regapi.Entry, regapi.ProvenanceMap, error) { +func loadEntriesFromLockPaths(ctx context.Context, lockObj *lock.Lock, logger *zap.Logger) ([]regapi.Entry, error) { if lockObj == nil { - return nil, nil, nil + return nil, nil } modulePaths := lockObj.GetModuleLoadPaths() return entries.LoadEntriesFromModuleLoadPaths(ctx, modulePaths, logger) diff --git a/cmd/wippy/cmd/load_entries_test.go b/cmd/wippy/cmd/load_entries_test.go index d16b1ec3b..5bac16a66 100644 --- a/cmd/wippy/cmd/load_entries_test.go +++ b/cmd/wippy/cmd/load_entries_test.go @@ -8,8 +8,6 @@ import ( "path/filepath" "testing" - regapi "github.com/wippyai/runtime/api/registry" - contextapi "github.com/wippyai/runtime/api/context" logapi "github.com/wippyai/runtime/api/logs" "github.com/wippyai/runtime/api/payload" @@ -21,7 +19,7 @@ import ( ) func TestLoadEntriesFromLockPaths_NilLock(t *testing.T) { - loaded, _, err := loadEntriesFromLockPaths(context.Background(), nil, zap.NewNop()) + loaded, err := loadEntriesFromLockPaths(context.Background(), nil, zap.NewNop()) if err != nil { t.Fatalf("loadEntriesFromLockPaths returned error: %v", err) } @@ -95,29 +93,26 @@ entries: t.Fatalf("write lock: %v", err) } - loaded, prov, err := loadEntriesFromLockPaths(ctx, lockObj, logger) + loaded, err := loadEntriesFromLockPaths(ctx, lockObj, logger) if err != nil { t.Fatalf("loadEntriesFromLockPaths failed: %v", err) } router := "" - var entryProv regapi.EntryProvenance + owner := "" for _, entry := range loaded { if entry.ID.String() != "identity.account:login.endpoint" { continue } router = entry.Meta.GetString("router", "") - entryProv = prov[entry.ID.Canonical()] + owner = entry.Registry.Owner } if router != "app:api.public" { t.Fatalf("router = %q, want app:api.public", router) } - if entryProv.Module != "example/accounts" { - t.Fatalf("module provenance = %q, want example/accounts", entryProv.Module) - } - if entryProv.Version != "v1.0.0" { - t.Fatalf("module_version provenance = %q, want v1.0.0", entryProv.Version) + if owner != "example/accounts" { + t.Fatalf("owner = %q, want example/accounts", owner) } } diff --git a/cmd/wippy/cmd/lock_helpers.go b/cmd/wippy/cmd/lock_helpers.go index 34da7acd3..7eeb54ed7 100644 --- a/cmd/wippy/cmd/lock_helpers.go +++ b/cmd/wippy/cmd/lock_helpers.go @@ -54,7 +54,7 @@ func ensureModulesAndLoadEntries( } } - allEntries, _, err := loadEntriesFromLockPaths(ctx, lockObj, logger) + allEntries, err := loadEntriesFromLockPaths(ctx, lockObj, logger) if err != nil { return nil, NewLoadEntriesError(fmt.Sprintf("lock paths (%s)", lockPath), err) } diff --git a/cmd/wippy/cmd/pack.go b/cmd/wippy/cmd/pack.go index 116921270..2cc13ebd6 100644 --- a/cmd/wippy/cmd/pack.go +++ b/cmd/wippy/cmd/pack.go @@ -445,7 +445,7 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea p.Send(progressMsg{stage: stageLoadEntries, percent: 0.2, status: fmt.Sprintf("Loading entries from %d paths...", len(paths))}) p.Send(logMsg{level: "info", message: fmt.Sprintf("Loading from %d paths", len(paths))}) - loadedEntries, loadedProv, err := loadEntriesFromLockPaths(app.Ctx, lockObj, logger) + loadedEntries, err := loadEntriesFromLockPaths(app.Ctx, lockObj, logger) if err != nil { return NewLoadEntriesError(fmt.Sprintf("lock paths (%s)", lockPath), err) } @@ -478,7 +478,7 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea pipelineStages = append(pipelineStages, stages.Disable(excludeNS, excludeEntries)) } - pipelineStages = append(pipelineStages, stages.Disable(), stages.Link(loadedProv), stages.Override()) + pipelineStages = append(pipelineStages, stages.Disable(), stages.Link(), stages.Override()) // Bytecode compilation (before EmbedFS so bytecode FS can be collected) if len(bytecodePatterns) > 0 { @@ -512,7 +512,7 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea percent: 0.55, status: status, }) - pipelineStages = append(pipelineStages, stages.EmbedFS("", loadedProv, embedConfig.stagePatterns()...)) + pipelineStages = append(pipelineStages, stages.EmbedFS("", embedConfig.stagePatterns()...)) } pipeline := build.New(pipelineStages...) @@ -622,11 +622,7 @@ func performPack(cmd *cobra.Command, args []string, app *appinit.Context, p *tea if err := addPackRuntimeMetadata(metadata, folderPath); err != nil { return NewPackConfigError(err) } - // The provenance frame is runtime-owned: it lands after user metadata so - // no --meta flag can overwrite it. - if len(loadedProv) > 0 { - metadata["provenance"] = encodePackProvenance(loadedProv) - } + metadata[packRegistryMetadataKey] = packRegistryMetadata(loadedEntries) p.Send(progressMsg{stage: stageWrite, percent: 0.8, status: "Writing pack file..."}) @@ -932,7 +928,7 @@ func parseMetadataFlags(metaFlags []string, metadata attrs.Bag, logger *zap.Logg return NewEmptyMetadataKeyError(flag) } - if strings.HasPrefix(key, "wippy.") || strings.HasPrefix(key, "system.") { + if key == packRegistryMetadataKey || strings.HasPrefix(key, "wippy.") || strings.HasPrefix(key, "system.") { return NewReservedMetadataNamespaceError(key) } @@ -978,7 +974,7 @@ func runListMode(app *appinit.Context, lockPath, _ string) error { _ = lockObj.GetLoadPaths() - allEntries, _, err := loadEntriesFromLockPaths(app.Ctx, lockObj, app.Logger) + allEntries, err := loadEntriesFromLockPaths(app.Ctx, lockObj, app.Logger) if err != nil { return NewLoadEntriesError(fmt.Sprintf("lock paths (%s)", lockPath), err) } diff --git a/cmd/wippy/cmd/pack_provenance.go b/cmd/wippy/cmd/pack_provenance.go deleted file mode 100644 index 906c4f043..000000000 --- a/cmd/wippy/cmd/pack_provenance.go +++ /dev/null @@ -1,144 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package cmd - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "os" - "sort" - - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/wapp" -) - -// encodePackProvenance serializes entry provenance into the pack metadata -// frame, keyed by canonical entry ID. The entries themselves stay verbatim; -// the pack carries the runtime's knowledge out of band. -func encodePackProvenance(prov registry.ProvenanceMap) map[string]any { - out := make(map[string]any, len(prov)) - for id, p := range prov { - rec := map[string]any{} - if p.Module != "" { - rec["module"] = p.Module - } - if p.Version != "" { - rec["version"] = p.Version - } - if p.Digest != "" { - rec["digest"] = p.Digest - } - if p.Root { - rec["root"] = true - } - out[id.String()] = rec - } - return out -} - -// packProvenanceFromMetadata reads the provenance a pack recorded in its -// metadata frame. A pack without the frame predates it and loads as -// host-authored entries; a pack WITH the frame is parsed strictly — a -// malformed record or an entry the frame does not cover is an error, never a -// silent host coercion. -func packProvenanceFromMetadata(reader *wapp.Reader, loadedEntries []registry.Entry) (registry.ProvenanceMap, error) { - out := make(registry.ProvenanceMap, len(loadedEntries)) - metadata, err := reader.GetMetadata() - if err != nil { - return nil, fmt.Errorf("read pack metadata: %w", err) - } - rawVal, framePresent := metadata["provenance"] - if !framePresent { - return out, nil - } - raw, ok := rawVal.(map[string]any) - if !ok { - return nil, fmt.Errorf("provenance frame has type %T, want a map", rawVal) - } - byID := make(map[string]registry.EntryProvenance, len(raw)) - for key, val := range raw { - rec, recOK := val.(map[string]any) - if !recOK { - return nil, fmt.Errorf("provenance record %q has type %T, want a map", key, val) - } - pr := registry.EntryProvenance{} - var fieldErr error - pr.Module, fieldErr = provString(rec, "module", fieldErr) - pr.Version, fieldErr = provString(rec, "version", fieldErr) - pr.Digest, fieldErr = provString(rec, "digest", fieldErr) - pr.Root, fieldErr = provBool(rec, "root", fieldErr) - if fieldErr != nil { - return nil, fmt.Errorf("provenance record %q: %w", key, fieldErr) - } - if pr.Module == "" && (pr.Version != "" || pr.Digest != "") { - return nil, fmt.Errorf("provenance record %q carries an artifact identity with no module", key) - } - byID[key] = pr - } - for _, entry := range loadedEntries { - id := entry.ID.Canonical() - pr, covered := byID[id.String()] - if !covered { - return nil, fmt.Errorf("provenance frame does not cover entry %s", id.String()) - } - out[id] = pr - delete(byID, id.String()) - } - if len(byID) != 0 { - extra := make([]string, 0, len(byID)) - for id := range byID { - extra = append(extra, id) - } - sort.Strings(extra) - return nil, fmt.Errorf("provenance frame contains record for unknown entry %s", extra[0]) - } - return out, nil -} - -// packFileDigest is the artifact identity of one pack file. -func packFileDigest(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil -} - -// provString reads one optional string field, failing on a wrong type. -func provString(rec map[string]any, field string, err error) (string, error) { - if err != nil { - return "", err - } - val, ok := rec[field] - if !ok { - return "", nil - } - str, isString := val.(string) - if !isString { - return "", fmt.Errorf("field %q has type %T, want string", field, val) - } - return str, nil -} - -// provBool reads one optional bool field, failing on a wrong type. -func provBool(rec map[string]any, field string, err error) (bool, error) { - if err != nil { - return false, err - } - val, ok := rec[field] - if !ok { - return false, nil - } - b, isBool := val.(bool) - if !isBool { - return false, fmt.Errorf("field %q has type %T, want bool", field, val) - } - return b, nil -} diff --git a/cmd/wippy/cmd/pack_provenance_test.go b/cmd/wippy/cmd/pack_provenance_test.go deleted file mode 100644 index e85bd8c9b..000000000 --- a/cmd/wippy/cmd/pack_provenance_test.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package cmd - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/cmd/internal/entries" - "github.com/wippyai/wapp" -) - -func TestPackProvenanceRejectsUnknownEntry(t *testing.T) { - entry := wapp.Entry{ID: wapp.NewID("app", "known"), Kind: "test"} - var packed bytes.Buffer - err := wapp.NewWriter().PackEntries(wapp.Metadata{ - "provenance": map[string]any{ - "app:known": map[string]any{}, - "app:extra": map[string]any{}, - }, - }, []wapp.Entry{entry}, &packed) - require.NoError(t, err) - - reader, err := entries.NewPackReader(bytes.NewReader(packed.Bytes()), nil) - require.NoError(t, err) - loaded, err := reader.GetEntries() - require.NoError(t, err) - - _, err = packProvenanceFromMetadata(reader.Reader(), loaded) - require.ErrorContains(t, err, "unknown entry app:extra") -} diff --git a/cmd/wippy/cmd/publish.go b/cmd/wippy/cmd/publish.go index bcf9b65c8..6967280c7 100644 --- a/cmd/wippy/cmd/publish.go +++ b/cmd/wippy/cmd/publish.go @@ -468,11 +468,11 @@ func packModule(ctx context.Context, app *appinit.Context, cfg *config.ModuleCon pipelineStages := []boot.Stage{ stages.Override(), stages.DisableWithOptions(disableOpts), - stages.Link(nil), + stages.Link(), stages.Override(), } if len(embedPatterns) > 0 { - pipelineStages = append(pipelineStages, stages.EmbedFS(srcDir, nil, embedPatterns...)) + pipelineStages = append(pipelineStages, stages.EmbedFS(srcDir, embedPatterns...)) } pipeline := build.New(pipelineStages...) diff --git a/cmd/wippy/cmd/registry.go b/cmd/wippy/cmd/registry.go index 92a666f77..7104e9de4 100644 --- a/cmd/wippy/cmd/registry.go +++ b/cmd/wippy/cmd/registry.go @@ -95,6 +95,7 @@ func init() { registryListCmd.Flags().StringSlice("meta", nil, "filter by metadata (field=value)") registryListCmd.Flags().Bool("json", false, "output in JSON format") registryListCmd.Flags().Bool("yaml", false, "output in YAML format") + registryListCmd.Flags().Bool("registry-meta", false, "include registry-owned metadata in JSON or YAML output") registryListCmd.Flags().StringP("lock-file", "l", defaultLockFile, "path to lock file") // Show flags @@ -114,7 +115,11 @@ func runRegistryList(cmd *cobra.Command, _ []string) error { metaFilters, _ := cmd.Flags().GetStringSlice("meta") jsonOutput, _ := cmd.Flags().GetBool("json") yamlOutput, _ := cmd.Flags().GetBool("yaml") + includeRegistryMeta, _ := cmd.Flags().GetBool("registry-meta") lockFile, _ := cmd.Flags().GetString("lock-file") + if includeRegistryMeta && !jsonOutput && !yamlOutput { + return fmt.Errorf("--registry-meta requires --json or --yaml") + } allEntries, err := loadRegistryEntries(cmd, lockFile) if err != nil { @@ -161,10 +166,10 @@ func runRegistryList(cmd *cobra.Command, _ []string) error { }) if jsonOutput { - return outputEntriesJSON(results) + return outputEntriesJSON(results, includeRegistryMeta) } if yamlOutput { - return outputEntriesYAML(results) + return outputEntriesYAML(results, includeRegistryMeta) } // Table output @@ -290,21 +295,31 @@ func (r *sliceEntryReader) GetEntry(id regapi.ID) (regapi.Entry, error) { return regapi.Entry{}, fmt.Errorf("entry not found: %s", id.String()) } -func outputEntriesJSON(entries []regapi.Entry) error { - type entryInfo struct { - Meta attrs.Bag `json:"meta,omitempty"` - ID string `json:"id"` - Kind string `json:"kind"` - } +type registryEntryInfo struct { + Registry *regapi.EntryMetadata `json:"registry,omitempty" yaml:"registry,omitempty"` + Meta attrs.Bag `json:"meta,omitempty" yaml:"meta,omitempty"` + ID string `json:"id" yaml:"id"` + Kind string `json:"kind" yaml:"kind"` +} - infos := make([]entryInfo, len(entries)) +func registryEntryInfos(entries []regapi.Entry, includeRegistryMeta bool) []registryEntryInfo { + infos := make([]registryEntryInfo, len(entries)) for i, e := range entries { - infos[i] = entryInfo{ + infos[i] = registryEntryInfo{ ID: e.ID.String(), Kind: e.Kind, Meta: e.Meta, } + if includeRegistryMeta { + metadata := e.Registry + infos[i].Registry = &metadata + } } + return infos +} + +func outputEntriesJSON(entries []regapi.Entry, includeRegistryMeta bool) error { + infos := registryEntryInfos(entries, includeRegistryMeta) data, err := stdjson.MarshalIndent(infos, "", " ") if err != nil { @@ -314,21 +329,8 @@ func outputEntriesJSON(entries []regapi.Entry) error { return nil } -func outputEntriesYAML(entries []regapi.Entry) error { - type entryInfo struct { - Meta attrs.Bag `yaml:"meta,omitempty"` - ID string `yaml:"id"` - Kind string `yaml:"kind"` - } - - infos := make([]entryInfo, len(entries)) - for i, e := range entries { - infos[i] = entryInfo{ - ID: e.ID.String(), - Kind: e.Kind, - Meta: e.Meta, - } - } +func outputEntriesYAML(entries []regapi.Entry, includeRegistryMeta bool) error { + infos := registryEntryInfos(entries, includeRegistryMeta) data, err := yaml.Marshal(infos) if err != nil { diff --git a/cmd/wippy/cmd/registry_metadata_test.go b/cmd/wippy/cmd/registry_metadata_test.go new file mode 100644 index 000000000..3fdcaa95f --- /dev/null +++ b/cmd/wippy/cmd/registry_metadata_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + regapi "github.com/wippyai/runtime/api/registry" +) + +func TestRegistryListMetadataIsOptIn(t *testing.T) { + entry := regapi.Entry{ + ID: regapi.NewID("acme.module", "service"), + Kind: "service", + Registry: regapi.EntryMetadata{Owner: "acme/module", Root: true}, + } + + without := registryEntryInfos([]regapi.Entry{entry}, false) + require.Len(t, without, 1) + assert.Nil(t, without[0].Registry) + + with := registryEntryInfos([]regapi.Entry{entry}, true) + require.Len(t, with, 1) + require.NotNil(t, with[0].Registry) + assert.Equal(t, entry.Registry, *with[0].Registry) + assert.Empty(t, entry.Meta, "formatting must not copy registry state into author metadata") +} diff --git a/cmd/wippy/cmd/run.go b/cmd/wippy/cmd/run.go index d06906587..a81803972 100644 --- a/cmd/wippy/cmd/run.go +++ b/cmd/wippy/cmd/run.go @@ -224,6 +224,10 @@ func runWithUseCase(cmd *cobra.Command, args []string, useCase string) error { logger = logapi.GetLogger(ctx).Named("run") logger.Info("infrastructure initialized") + if err := prepareRunDependencies(ctx, cfg, registryURL, logger.Named("dependencies")); err != nil { + logger.Error("dependency preparation failed", zap.Error(err)) + return err + } // Create embed registry for fs.embed support with .wapp modules embedReg := embedpkg.NewRegistry() diff --git a/cmd/wippy/cmd/run_dependencies.go b/cmd/wippy/cmd/run_dependencies.go new file mode 100644 index 000000000..b956355da --- /dev/null +++ b/cmd/wippy/cmd/run_dependencies.go @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/wippyai/runtime/api/boot" + "github.com/wippyai/runtime/api/payload" + "github.com/wippyai/runtime/api/semver" + "github.com/wippyai/runtime/boot/deps/hub" + "github.com/wippyai/runtime/boot/deps/lock" + bootloader "github.com/wippyai/runtime/boot/loader" + "github.com/wippyai/runtime/boot/loader/interpolate" + "github.com/wippyai/runtime/cmd/internal/entries" + "github.com/wippyai/runtime/cmd/internal/hubclient" + "go.uber.org/zap" +) + +// prepareRunDependencies repairs a local application's stale lock before any +// runtime service starts. A complete lock remains an offline authority; only a +// source dependency that the lock cannot satisfy permits Hub resolution. +func prepareRunDependencies( + ctx context.Context, + cfg boot.Config, + registryURL string, + logger *zap.Logger, +) error { + lockPath, err := lock.Find(".", defaultLockFile) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return NewLoadLockFileError(err) + } + + lockObj, err := newConfiguredLock(lockPath, cfg, logger) + if err != nil { + return NewLoadLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) + } + if err := lock.Validate(lockObj); err != nil { + return NewInvalidLockFileError(fmt.Errorf("lock file %s: %w", lockPath, err)) + } + + // A published deployment is rooted by the lock itself. Its exact graph is + // materialized later by LoadFromLockFile and is never inferred from the + // caller's working directory. + if len(lockObj.GetRootModules()) != 0 { + return nil + } + ldr := boot.GetLoader(ctx) + if ldr == nil { + transcoder := payload.GetTranscoder(ctx) + if transcoder == nil { + return ErrTranscoderNotFound + } + interpolator := interpolate.NewEntryInterpolator(transcoder, + interpolate.WithInterpolator(interpolate.LoadFile), + ) + ldr = bootloader.NewLoader(transcoder, logger.Named("loader"), interpolator) + } + + dirs := lockObj.GetDirectories() + loaded, err := loadDependencyScanEntries(ctx, ldr, dirs.Src, lockObj, logger) + if err != nil { + return NewLoadEntriesFromSourceError(err) + } + transcoder := payload.GetTranscoder(ctx) + if transcoder == nil { + return ErrTranscoderNotFound + } + roots := extractRootDependencies(loaded, transcoder) + if lockSatisfiesSource(lockObj, roots) { + return nil + } + + client, err := hubclient.New(hubclient.Options{RegistryURL: registryURL}) + if err != nil { + return NewCreateHubClientError(err) + } + resolved, err := resolveRunDependencies(ctx, client, lockObj, roots) + if err != nil { + return err + } + + modules := make([]lock.Module, 0, len(resolved.Modules)) + selected := make(map[string]struct{}, len(resolved.Modules)) + for _, module := range resolved.Modules { + name := module.Org + "/" + module.Name + selected[name] = struct{}{} + modules = append(modules, lock.Module{ + Name: name, + Version: module.Version, + Hash: module.Digest, + }) + } + // Replacements are intentionally absent from Hub resolution. Retain their + // selected rows so source ownership and deployment-root identity do not + // disappear while repairing unrelated dependencies. + for _, module := range lockObj.GetModules() { + if _, ok := selected[module.Name]; ok { + continue + } + if _, replaced := lockObj.GetReplacement(module.Name); replaced { + modules = append(modules, module) + } + } + lockObj.ReplaceModules(modules) + + // Download and verify the candidate graph before publishing it. Unpacked + // directories are refreshed only after the lock is committed, by the normal + // lock loader, so a failed preparation cannot replace the active sources. + options := lockObj.GetOptions() + lockObj.SetOptions(lock.Options{}) + err = entries.EnsureModulesInstalledFromLock(ctx, lockObj, logger.Named("modules"), client) + lockObj.SetOptions(options) + if err != nil { + return NewEnsureModulesInstalledError(err) + } + if err := lockObj.Write(); err != nil { + return NewWriteLockFileError(fmt.Errorf("lock file %s: %w", lockObj.Path(), err)) + } + + logger.Info("completed stale dependency lock", zap.Int("modules", len(modules))) + return nil +} + +func lockSatisfiesSource(lockObj *lock.Lock, roots []dependencyRequest) bool { + if lockObj == nil { + return false + } + for _, root := range roots { + name := root.Org + "/" + root.Module + module, ok := lockObj.GetModule(name) + if _, replaced := lockObj.GetReplacement(name); replaced && !ok { + constraint := strings.TrimSpace(root.Constraint) + if constraint == "" || constraint == "*" || strings.HasPrefix(constraint, "@") { + continue + } + } + if !ok || !lockedVersionSatisfies(module.Version, root.Constraint) { + return false + } + if _, replaced := lockObj.GetReplacement(name); !replaced && module.Hash == "" { + return false + } + } + return true +} + +func lockedVersionSatisfies(version, constraint string) bool { + constraint = strings.TrimSpace(constraint) + if version == "" { + return false + } + if constraint == "" || constraint == "*" || strings.HasPrefix(constraint, "@") { + return true + } + v, err := semver.ParseVersion(version) + if err != nil { + return false + } + c, err := semver.ParseConstraint(constraint) + return err == nil && c.Match(v) +} + +func resolveRunDependencies( + ctx context.Context, + provider hub.HubClient, + lockObj *lock.Lock, + roots []dependencyRequest, +) (*hub.ResolveDependenciesResult, error) { + definitions := make([]hub.DependencyDefinition, 0, len(roots)) + for _, root := range roots { + definitions = append(definitions, hub.DependencyDefinition{ + Component: root.Org + "/" + root.Module, + Version: root.Constraint, + }) + } + handler, err := hub.NewDependencyHandler(hub.DependencyHandlerOptions{ + Hub: provider, + LockPath: lockObj.Path(), + WorkspaceReplacements: lockObj.GetReplacements(), + }) + if err != nil { + return nil, NewBuildDependencyGraphError(err) + } + modules, err := handler.ResolveWorkspaceDependencies(ctx, definitions) + if err != nil { + return nil, err + } + return &hub.ResolveDependenciesResult{Modules: modules}, nil +} diff --git a/cmd/wippy/cmd/run_dependencies_test.go b/cmd/wippy/cmd/run_dependencies_test.go new file mode 100644 index 000000000..77ae1b446 --- /dev/null +++ b/cmd/wippy/cmd/run_dependencies_test.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/boot/deps/hub" + "github.com/wippyai/runtime/boot/deps/lock" +) + +func TestLockSatisfiesSource(t *testing.T) { + lockObj, err := lock.New(filepath.Join(t.TempDir(), "wippy.lock")) + require.NoError(t, err) + lockObj.SetModule(lock.Module{Name: "acme/core", Version: "1.4.0", Hash: "sha256:core"}) + lockObj.SetReplacement(lock.Replacement{From: "acme/local", To: t.TempDir()}) + + require.True(t, lockSatisfiesSource(lockObj, []dependencyRequest{ + {Org: "acme", Module: "core", Constraint: "^1.2.0"}, + {Org: "acme", Module: "local", Constraint: "*"}, + })) + require.False(t, lockSatisfiesSource(lockObj, []dependencyRequest{ + {Org: "acme", Module: "views", Constraint: ">=2.0.0"}, + })) + require.False(t, lockSatisfiesSource(lockObj, []dependencyRequest{ + {Org: "acme", Module: "core", Constraint: ">=2.0.0"}, + })) + + lockObj.SetModule(lock.Module{Name: "acme/core", Version: "1.4.0"}) + require.False(t, lockSatisfiesSource(lockObj, []dependencyRequest{ + {Org: "acme", Module: "core", Constraint: "^1.2.0"}, + })) +} + +func TestResolveRunDependenciesKeepsCompatibleLockAndCompletesIt(t *testing.T) { + // Existing locks and the current Hub both carry the bare SHA-256 form. + // Keeping it exact here prevents one-sided normalization from rejecting + // identical artifacts during lock completion. + const coreDigest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const viewsDigest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + provider := runManifestProvider{manifests: map[string]hub.ModuleManifest{ + "acme/core@1.4.0": { + Org: "acme", Name: "core", Version: "1.4.0", Digest: coreDigest, + Dependencies: []hub.ManifestDep{{Org: "acme", Name: "views", Constraint: "^2.0.0"}}, + }, + "acme/views@2.1.0": { + Org: "acme", Name: "views", Version: "2.1.0", Digest: viewsDigest, + }, + }, versions: map[string][]hub.VersionInfo{ + "acme/core": {{Version: "1.5.0"}, {Version: "1.4.0"}}, + "acme/views": {{Version: "2.1.0"}}, + }} + + lockObj, err := lock.New(filepath.Join(t.TempDir(), "wippy.lock")) + require.NoError(t, err) + lockObj.SetModule(lock.Module{Name: "acme/core", Version: "1.4.0", Hash: coreDigest}) + require.NoError(t, lockObj.Write()) + + result, err := resolveRunDependencies(context.Background(), provider, lockObj, []dependencyRequest{ + {Org: "acme", Module: "core", Constraint: "^1.0.0"}, + }) + require.NoError(t, err) + require.Equal(t, []hub.ResolvedModule{ + {Org: "acme", Name: "core", Version: "1.4.0", Digest: coreDigest}, + {Org: "acme", Name: "views", Version: "2.1.0", Digest: viewsDigest}, + }, result.Modules) +} + +func TestResolveRunDependenciesSelectsVersionForUnversionedReplacement(t *testing.T) { + ctx := setupLoaderContext(t) + replacement := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(replacement, "_index.yaml"), []byte(`version: "1.0" +namespace: acme.local +entries: [] +`), 0o600)) + + lockObj, err := lock.New(filepath.Join(t.TempDir(), "wippy.lock")) + require.NoError(t, err) + lockObj.SetModule(lock.Module{Name: "acme/local", Version: "1.0.0"}) + lockObj.SetReplacement(lock.Replacement{From: "acme/local", To: replacement}) + require.NoError(t, lockObj.Write()) + + provider := runManifestProvider{versions: map[string][]hub.VersionInfo{ + "acme/local": {{Version: "1.2.0"}, {Version: "1.0.0"}}, + }} + result, err := resolveRunDependencies(ctx, provider, lockObj, []dependencyRequest{ + {Org: "acme", Module: "local", Constraint: ">=1.2.0"}, + }) + require.NoError(t, err) + require.Len(t, result.Modules, 1) + require.Equal(t, "1.2.0", result.Modules[0].Version) +} + +type runManifestProvider struct { + manifests map[string]hub.ModuleManifest + versions map[string][]hub.VersionInfo +} + +func (p runManifestProvider) GetManifest(_ context.Context, org, module, constraint string) (*hub.ModuleManifest, error) { + manifest, ok := p.manifests[org+"/"+module+"@"+constraint] + if !ok { + return nil, fmt.Errorf("manifest not found") + } + copy := manifest + return ©, nil +} + +func (p runManifestProvider) ListAllVersions(_ context.Context, org, module string) ([]hub.VersionInfo, error) { + versions, ok := p.versions[org+"/"+module] + if !ok { + return nil, fmt.Errorf("versions not found") + } + return append([]hub.VersionInfo(nil), versions...), nil +} + +func (p runManifestProvider) GetDownloadURL(context.Context, *hub.DownloadParams) (*hub.DownloadInfo, error) { + return nil, fmt.Errorf("download not expected") +} + +func (p runManifestProvider) DownloadToFile(context.Context, string, string) error { + return fmt.Errorf("download not expected") +} diff --git a/cmd/wippy/cmd/run_pack.go b/cmd/wippy/cmd/run_pack.go index e2f30de08..ce7bdc3da 100644 --- a/cmd/wippy/cmd/run_pack.go +++ b/cmd/wippy/cmd/run_pack.go @@ -43,6 +43,8 @@ var hubIdentPattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // use case instead; future run-like commands target their own use case. const defaultUseCase = "run" +const packRegistryMetadataKey = "registry" + type packCommand struct { name string entryID string @@ -106,11 +108,7 @@ func collectPackCommands(ctx context.Context, mainModule string) ([]packCommand, filtered := allEntries[:0] for _, entry := range allEntries { - owner, ownerErr := entryModuleOwner(reg, entry.ID) - if ownerErr != nil { - return nil, fmt.Errorf("resolve command ownership for %s: %w", entry.ID.String(), ownerErr) - } - if !packCommandAllowed(owner, mainModule) { + if !packCommandAllowed(entry, mainModule) { continue } filtered = append(filtered, entry) @@ -138,24 +136,8 @@ func findPackCommandForModule(ctx context.Context, commandName, useCase, mainMod return selectEntrypoint(commands, commandName, useCase) } -// entryModuleOwner reads ownership from registry provenance; author metadata -// named "module" is ordinary payload and never consulted. A registry that -// cannot answer, or an entry without a record, fails closed. -func entryModuleOwner(reg registry.Registry, id registry.ID) (string, error) { - reader, ok := reg.(interface { - EntryProvenance(registry.ID) (registry.EntryProvenance, bool) - }) - if !ok { - return "", fmt.Errorf("registry does not serve provenance") - } - p, found := reader.EntryProvenance(id) - if !found { - return "", registry.NewMissingProvenanceError(id) - } - return p.Module, nil -} - -func packCommandAllowed(module, mainModule string) bool { +func packCommandAllowed(entry registry.Entry, mainModule string) bool { + module := entry.Registry.Owner if mainModule == "" { return module == "" } @@ -604,7 +586,7 @@ func runFromPackFile(cmd *cobra.Command, packFile string, args []string, useCase return fmt.Errorf("failed to load main module identity from pack metadata: %w", err) } - packEntries, packProv, err := loadPackEntries([]string{packFile}, mainModule, embedReg) + packEntries, err := loadPackEntries([]string{packFile}, mainModule, embedReg) if err != nil { runLogger.Error("failed to load entries from pack", zap.Error(err)) return NewLoadEntriesError(packFile, err) @@ -617,7 +599,7 @@ func runFromPackFile(cmd *cobra.Command, packFile string, args []string, useCase } entries.ConfigureSourceLoader(ctx, sourcePaths, runLogger) - return runPackEntries(ctx, loader, runLogger, packEntries, packProv, args, useCase, mainModule) + return runPackEntries(ctx, loader, runLogger, packEntries, args, useCase, mainModule) } // runFromPackFiles executes runtime from multiple already resolved .wapp files. @@ -657,7 +639,7 @@ func runFromPackFiles(cmd *cobra.Command, packFiles []string, args []string, use } } - packEntries, packProv, err := loadPackEntries(packFiles, mainModule, embedReg) + packEntries, err := loadPackEntries(packFiles, mainModule, embedReg) if err != nil { runLogger.Error("failed to load entries from packs", zap.Error(err)) return NewLoadEntriesError("pack files", err) @@ -670,7 +652,7 @@ func runFromPackFiles(cmd *cobra.Command, packFiles []string, args []string, use } entries.ConfigureSourceLoader(ctx, sourcePaths, runLogger) - return runPackEntries(ctx, loader, runLogger, packEntries, packProv, args, useCase, mainModule) + return runPackEntries(ctx, loader, runLogger, packEntries, args, useCase, mainModule) } func packSourcePaths(packFiles []string, rootModule string) ([]lock.ModuleLoadPath, error) { @@ -697,7 +679,6 @@ func runPackEntries( loader *bootpkg.Loader, logger *zap.Logger, packEntries []registry.Entry, - packProv registry.ProvenanceMap, args []string, useCase string, mainModule string, @@ -713,7 +694,7 @@ func runPackEntries( return NewStartComponentsError(err) } - if err := applyPackEntries(appCtx, packEntries, packProv, logger); err != nil { + if err := applyPackEntries(appCtx, packEntries, logger); err != nil { return err } @@ -776,12 +757,12 @@ func moduleIdentityFromPackFile(packFile string) (moduleName string, moduleVersi // applyPackEntries restores packed entries as baseline state after applying the // canonical entry normalization pipeline. -func applyPackEntries(ctx context.Context, packEntries []registry.Entry, prov registry.ProvenanceMap, logger *zap.Logger) error { - if err := entries.NormalizeEntries(ctx, &packEntries, prov); err != nil { +func applyPackEntries(ctx context.Context, packEntries []registry.Entry, logger *zap.Logger) error { + if err := entries.NormalizeEntries(ctx, &packEntries); err != nil { return err } - return entries.LoadEntriesToRegistry(ctx, packEntries, prov, logger) + return entries.LoadEntriesToRegistry(ctx, packEntries, logger) } type packReaderRegistry interface { @@ -792,81 +773,48 @@ type modulePackReaderRegistry interface { RegisterPack(packPath, module, version string, reader *wapp.Reader, file *os.File) error } -func loadPackEntries(packFiles []string, rootModule string, embedReg packReaderRegistry) ([]registry.Entry, registry.ProvenanceMap, error) { +func loadPackEntries(packFiles []string, rootModule string, embedReg packReaderRegistry) ([]registry.Entry, error) { packEntries := make([]registry.Entry, 0) - prov := make(registry.ProvenanceMap) for _, packFile := range packFiles { if !hasWappExtension(packFile) { - return nil, nil, fmt.Errorf("unsupported pack format %q", packFile) + return nil, fmt.Errorf("unsupported pack format %q", packFile) } file, err := os.Open(packFile) if err != nil { - return nil, nil, fmt.Errorf("open pack %s: %w", packFile, err) + return nil, fmt.Errorf("open pack %s: %w", packFile, err) } packReader, err := entries.NewPackReader(file, nil) if err != nil { file.Close() - return nil, nil, fmt.Errorf("read pack %s: %w", packFile, err) + return nil, fmt.Errorf("read pack %s: %w", packFile, err) } moduleName, moduleVersion := moduleIdentityFromPackMetadata(packReader.Reader()) - packDigest := "" - if moduleName != "" { - var digestErr error - packDigest, digestErr = packFileDigest(packFile) - if digestErr != nil { - file.Close() - return nil, nil, fmt.Errorf("digest pack %s: %w", packFile, digestErr) - } - } - if err := registerPackResources(embedReg, packFile, moduleName, moduleVersion, packReader.Reader(), file); err != nil { - file.Close() - return nil, nil, fmt.Errorf("register embed resources for %s: %w", packFile, err) - } - loadedEntries, err := packReader.GetEntries() if err != nil { - return nil, nil, fmt.Errorf("read entries from %s: %w", packFile, err) + file.Close() + return nil, fmt.Errorf("read entries from %s: %w", packFile, err) } if moduleName != "" { - for i := range loadedEntries { - id := loadedEntries[i].ID.Canonical() - prov[id] = registry.EntryProvenance{ - Module: moduleName, - Version: moduleVersion, - Digest: packDigest, - Root: moduleName == rootModule && loadedEntries[i].Kind == registry.NamespaceDependency, - } - } - } else { - packProv, provErr := packProvenanceFromMetadata(packReader.Reader(), loadedEntries) - if provErr != nil { - return nil, nil, fmt.Errorf("read provenance from %s: %w", packFile, provErr) - } - for id, pr := range packProv { - prov[id] = pr - } - if err := registerMonolithicPackResourceAliases(embedReg, packFile, packReader.Reader(), loadedEntries, packProv); err != nil { - return nil, nil, fmt.Errorf("register embed resource aliases for %s: %w", packFile, err) - } + assignPackEntryMetadata(loadedEntries, moduleName, moduleName == rootModule) + } else if err := applyPackedEntryMetadata(packReader.Reader(), loadedEntries); err != nil { + file.Close() + return nil, fmt.Errorf("read registry metadata from %s: %w", packFile, err) } - packEntries = append(packEntries, loadedEntries...) - } - - // The map is total: entries from packs that predate the provenance frame - // carry the explicit host record. - for _, entry := range packEntries { - if _, ok := prov[entry.ID.Canonical()]; !ok { - prov[entry.ID.Canonical()] = registry.EntryProvenance{} + if err := registerPackResources(embedReg, packFile, moduleName, moduleVersion, packReader.Reader(), file); err != nil { + file.Close() + return nil, fmt.Errorf("register embed resources for %s: %w", packFile, err) } + + packEntries = append(packEntries, loadedEntries...) } - return packEntries, prov, nil + return packEntries, nil } func registerPackResources(embedReg packReaderRegistry, packFile, moduleName, moduleVersion string, reader *wapp.Reader, file *os.File) error { @@ -878,40 +826,6 @@ func registerPackResources(embedReg packReaderRegistry, packFile, moduleName, mo return embedReg.Register(packFile, reader, file) } -func registerMonolithicPackResourceAliases(embedReg packReaderRegistry, packFile string, reader *wapp.Reader, loadedEntries []registry.Entry, prov registry.ProvenanceMap) error { - moduleReg, ok := embedReg.(modulePackReaderRegistry) - if !ok { - return nil - } - - seen := make(map[string]struct{}) - for _, entry := range loadedEntries { - p, ok := prov[entry.ID.Canonical()] - if !ok || p.Module == "" { - continue - } - - key := p.Module + "\x00" + p.Version - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - - if err := moduleReg.RegisterPack(monolithicPackAliasPath(packFile, p.Module, p.Version), p.Module, p.Version, reader, nil); err != nil { - return err - } - } - - return nil -} - -func monolithicPackAliasPath(packFile, moduleName, moduleVersion string) string { - if moduleVersion == "" { - return packFile + "#" + moduleName - } - return packFile + "#" + moduleName + "@" + moduleVersion -} - func moduleIdentityFromPackMetadata(reader *wapp.Reader) (moduleName string, moduleVersion string) { if reader == nil { return "", "" @@ -942,3 +856,109 @@ func moduleIdentityFromPackMetadata(reader *wapp.Reader) (moduleName string, mod return org + "/" + name, version } + +func assignPackEntryMetadata(items []registry.Entry, moduleName string, root bool) { + if moduleName == "" { + return + } + + for i := range items { + items[i].Registry = registry.EntryMetadata{Owner: moduleName} + if root && items[i].Kind == registry.NamespaceDependency { + items[i].Registry.Root = true + } + } +} + +// packRegistryMetadata records the registry-owned entry state of a snapshot +// pack. Module packs derive ownership from their signed package identity; a +// snapshot contains several selected modules and therefore retains the state +// established from its lock before it was packed. +func packRegistryMetadata(items []registry.Entry) map[string]any { + entries := make(map[string]any, len(items)) + for _, entry := range items { + entries[entry.ID.String()] = map[string]any{ + "owner": entry.Registry.Owner, + "root": entry.Registry.Root, + } + } + return map[string]any{"entries": entries} +} + +// applyPackedEntryMetadata restores the registry-owned state written by +// wippy pack. Pack metadata belongs to the container; entry author metadata +// is never consulted for ownership or deployment-root selection. +func applyPackedEntryMetadata(reader *wapp.Reader, items []registry.Entry) error { + metadata, err := reader.GetMetadata() + if err != nil { + return fmt.Errorf("read pack metadata: %w", err) + } + raw, found := metadata[packRegistryMetadataKey] + if !found { + return fmt.Errorf("pack has no registry metadata") + } + state, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("registry metadata has type %T, want a map", raw) + } + rawEntries, found := state["entries"] + if !found { + return fmt.Errorf("registry metadata has no entries") + } + records, ok := rawEntries.(map[string]any) + if !ok { + return fmt.Errorf("registry entries have type %T, want a map", rawEntries) + } + + for i := range items { + id := items[i].ID.String() + rawRecord, found := records[id] + if !found { + return fmt.Errorf("registry metadata does not cover entry %s", id) + } + record, ok := rawRecord.(map[string]any) + if !ok { + return fmt.Errorf("registry metadata for entry %s has type %T, want a map", id, rawRecord) + } + owner, err := packedEntryOwner(record, id) + if err != nil { + return err + } + root, err := packedEntryRoot(record, id) + if err != nil { + return err + } + items[i].Registry = registry.EntryMetadata{Owner: owner, Root: root} + delete(records, id) + } + if len(records) != 0 { + for id := range records { + return fmt.Errorf("registry metadata contains unknown entry %s", id) + } + } + return nil +} + +func packedEntryOwner(record map[string]any, id string) (string, error) { + raw, found := record["owner"] + if !found { + return "", nil + } + owner, ok := raw.(string) + if !ok { + return "", fmt.Errorf("registry metadata for entry %s has owner type %T, want string", id, raw) + } + return owner, nil +} + +func packedEntryRoot(record map[string]any, id string) (bool, error) { + raw, found := record["root"] + if !found { + return false, nil + } + root, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("registry metadata for entry %s has root type %T, want bool", id, raw) + } + return root, nil +} diff --git a/cmd/wippy/cmd/run_pack_shutdown_test.go b/cmd/wippy/cmd/run_pack_shutdown_test.go index cefb82fdd..3e5b47c1c 100644 --- a/cmd/wippy/cmd/run_pack_shutdown_test.go +++ b/cmd/wippy/cmd/run_pack_shutdown_test.go @@ -74,7 +74,7 @@ func TestRunPackEntries_GracefulShutdownStopsRunningService(t *testing.T) { done := make(chan error, 1) go func() { - done <- runPackEntries(ctx, loader, baseLogger, nil, nil, nil, defaultUseCase, "") + done <- runPackEntries(ctx, loader, baseLogger, nil, nil, defaultUseCase, "") }() waitForLogMessage(t, logs, "supervisor started") diff --git a/cmd/wippy/cmd/run_pack_test.go b/cmd/wippy/cmd/run_pack_test.go index b57b6f81d..964e09f2a 100644 --- a/cmd/wippy/cmd/run_pack_test.go +++ b/cmd/wippy/cmd/run_pack_test.go @@ -68,7 +68,7 @@ func TestRunPackEntries_InvalidRequirementFailsNormalizationPipeline(t *testing. }, } - err = runPackEntries(ctx, loader, zap.NewNop(), packEntries, nil, []string{"missing"}, defaultUseCase, "") + err = runPackEntries(ctx, loader, zap.NewNop(), packEntries, []string{"missing"}, defaultUseCase, "") if err == nil { t.Fatal("expected normalization pipeline error") } @@ -101,7 +101,7 @@ func TestRunPackEntries_NoEntrypointRunsAsServer(t *testing.T) { done := make(chan error, 1) go func() { - done <- runPackEntries(ctx, loader, zap.NewNop(), nil, nil, nil, defaultUseCase, "") + done <- runPackEntries(ctx, loader, zap.NewNop(), nil, nil, defaultUseCase, "") }() select { @@ -139,7 +139,7 @@ func TestRunPackEntries_TestModeWithoutTestEntrypointErrors(t *testing.T) { _ = embedReg.Close() }) - err = runPackEntries(ctx, loader, zap.NewNop(), nil, nil, nil, "test", "") + err = runPackEntries(ctx, loader, zap.NewNop(), nil, nil, "test", "") if err == nil { t.Fatal("expected an error when there is no test entrypoint") } @@ -165,7 +165,7 @@ func TestRunPackEntries_RunModeUnknownCommandErrors(t *testing.T) { _ = embedReg.Close() }) - err = runPackEntries(ctx, loader, zap.NewNop(), nil, nil, []string{"nope"}, defaultUseCase, "") + err = runPackEntries(ctx, loader, zap.NewNop(), nil, []string{"nope"}, defaultUseCase, "") if err == nil { t.Fatal("expected an error for an unknown command") } @@ -548,29 +548,25 @@ func TestCollectPackCommandsFiltersDependencyModules(t *testing.T) { entries := []regapi.Entry{ { - ID: regapi.NewID("wippy.test", "runner"), - Kind: "process.lua", - Data: payload.New(map[string]any{"source": "return {}"}), + ID: regapi.NewID("wippy.test", "runner"), + Kind: "process.lua", + Data: payload.New(map[string]any{"source": "return {}"}), + Registry: regapi.EntryMetadata{Owner: "wippy/test"}, Meta: map[string]any{ - "module": "wippy/test", "command": map[string]any{"name": "test"}, }, }, { - ID: regapi.NewID("app", "serve"), - Kind: "process.lua", - Data: payload.New(map[string]any{"source": "return {}"}), + ID: regapi.NewID("app", "serve"), + Kind: "process.lua", + Data: payload.New(map[string]any{"source": "return {}"}), + Registry: regapi.EntryMetadata{Owner: "kickside/kickside"}, Meta: map[string]any{ - "module": "kickside/kickside", "command": map[string]any{"name": "serve", "main": true}, }, }, } - prov := regapi.ProvenanceMap{ - regapi.NewID("wippy.test", "runner"): {Module: "wippy/test"}, - regapi.NewID("app", "serve"): {Module: "kickside/kickside"}, - } - if err := applyPackEntries(appCtx, entries, prov, zap.NewNop()); err != nil { + if err := applyPackEntries(appCtx, entries, zap.NewNop()); err != nil { t.Fatalf("apply entries: %v", err) } @@ -594,25 +590,48 @@ func TestCollectPackCommandsFiltersDependencyModules(t *testing.T) { func TestPackCommandAllowed(t *testing.T) { tests := []struct { name string - module string + owner string mainModule string want bool }{ - {name: "host command allowed without pack identity", module: "", want: true}, - {name: "dependency command excluded without pack identity", module: "wippy/test", want: false}, - {name: "main module command allowed", module: "kickside/kickside", mainModule: "kickside/kickside", want: true}, - {name: "dependency command excluded when main module known", module: "wippy/test", mainModule: "kickside/kickside", want: false}, - {name: "moduleless command allowed when main module known", module: "", mainModule: "kickside/kickside", want: true}, + { + name: "moduleless command allowed without pack identity", + want: true, + }, + { + name: "dependency command excluded without pack identity", + owner: "wippy/test", + want: false, + }, + { + name: "main module command allowed", + owner: "kickside/kickside", + mainModule: "kickside/kickside", + want: true, + }, + { + name: "dependency command excluded when main module known", + owner: "wippy/test", + mainModule: "kickside/kickside", + want: false, + }, + { + name: "moduleless command allowed when main module known", + mainModule: "kickside/kickside", + want: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := packCommandAllowed(tc.module, tc.mainModule); got != tc.want { + entry := regapi.Entry{Registry: regapi.EntryMetadata{Owner: tc.owner}} + if got := packCommandAllowed(entry, tc.mainModule); got != tc.want { t.Fatalf("packCommandAllowed() = %v, want %v", got, tc.want) } }) } } + func TestBootstrapPackRuntimeWithDefaults_Harness(t *testing.T) { t.Run("applies runtime defaults when config key is missing", func(t *testing.T) { tmpDir := t.TempDir() @@ -696,7 +715,7 @@ func TestLoadPackEntries_RawLoadSkipsLinkPipeline(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, _, err := loadPackEntries([]string{packPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{packPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -745,7 +764,7 @@ func TestLoadPackEntries_RejectsUnsupportedExtension(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - _, _, err := loadPackEntries([]string{"./not-a-pack.yaml"}, "", embedReg) + _, err := loadPackEntries([]string{"./not-a-pack.yaml"}, "", embedReg) if err == nil { t.Fatal("expected error for unsupported pack extension") } @@ -774,7 +793,7 @@ func TestLoadPackEntries_AcceptsUppercaseExtension(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, _, err := loadPackEntries([]string{upperPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{upperPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -793,7 +812,7 @@ func TestLoadPackEntries_RegisterErrorIncludesPath(t *testing.T) { }, }) - _, _, err := loadPackEntries([]string{packPath}, "", failingPackRegistry{err: errors.New("boom")}) + _, err := loadPackEntries([]string{packPath}, "", failingPackRegistry{err: errors.New("boom")}) if err == nil { t.Fatal("expected register error") } @@ -825,7 +844,7 @@ func TestLoadPackEntries_MultiPackOrder(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, _, err := loadPackEntries([]string{depPack, mainPack}, "", embedReg) + packEntries, err := loadPackEntries([]string{depPack, mainPack}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -850,7 +869,7 @@ func TestLoadPackEntries_MultiPackOrder(t *testing.T) { } } -func TestLoadPackEntries_AnnotatesModuleMetadataFromPackMetadata(t *testing.T) { +func TestLoadPackEntries_AssignsModuleMetadataFromPackMetadata(t *testing.T) { tmpDir := t.TempDir() packPath := createTestPackFileWithMetadata(t, tmpDir, "module-pack", wapp.Metadata{ "name": "users", @@ -870,7 +889,7 @@ func TestLoadPackEntries_AnnotatesModuleMetadataFromPackMetadata(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, packProv, err := loadPackEntries([]string{packPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{packPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -878,15 +897,8 @@ func TestLoadPackEntries_AnnotatesModuleMetadataFromPackMetadata(t *testing.T) { t.Fatalf("entry count = %d, want 1", len(packEntries)) } - p := packProv[packEntries[0].ID.Canonical()] - if p.Module != "userspace/users" { - t.Fatalf("module = %q, want userspace/users", p.Module) - } - if p.Version != "0.1.3" { - t.Fatalf("module_version = %q, want 0.1.3", p.Version) - } - if packEntries[0].Meta.GetString("module", "") != "" { - t.Fatal("entry payload must not carry ownership metadata") + if got := packEntries[0].Registry.Owner; got != "userspace/users" { + t.Fatalf("owner = %q, want userspace/users", got) } } @@ -907,24 +919,24 @@ func TestLoadPackEntries_UsesExplicitRootIdentityNotPackOrder(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, packProv, err := loadPackEntries([]string{rootPack, transitivePack}, "acme/app", embedReg) + packEntries, err := loadPackEntries([]string{rootPack, transitivePack}, "acme/app", embedReg) if err != nil { t.Fatal(err) } if len(packEntries) != 2 { t.Fatalf("entry count = %d, want 2", len(packEntries)) } - if !packProv[packEntries[0].ID.Canonical()].Root { + if !packEntries[0].Registry.Root { t.Fatal("explicit root module dependency was not marked as a deployment root") } - if packProv[packEntries[1].ID.Canonical()].Root { + if packEntries[1].Registry.Root { t.Fatal("last pack was incorrectly promoted to a deployment root") } - if got := packProv[packEntries[0].ID.Canonical()].Module; got != "acme/app" { - t.Fatalf("root ownership was discarded: %q", got) + if got := packEntries[0].Registry.Owner; got != "acme/app" { + t.Fatalf("root owner was discarded: %q", got) } - if got := packProv[packEntries[1].ID.Canonical()].Module; got != "acme/lib" { - t.Fatalf("transitive ownership = %q, want acme/lib", got) + if got := packEntries[1].Registry.Owner; got != "acme/lib" { + t.Fatalf("transitive owner = %q, want acme/lib", got) } } @@ -939,7 +951,7 @@ func TestLoadPackEntries_RegistersModuleOwnedEmbeddedResources(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, packProv, err := loadPackEntries([]string{packPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{packPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -947,13 +959,9 @@ func TestLoadPackEntries_RegistersModuleOwnedEmbeddedResources(t *testing.T) { t.Fatalf("entry count = %d, want 1", len(packEntries)) } - var entryProv *regapi.EntryProvenance - if p, ok := packProv[packEntries[0].ID.Canonical()]; ok { - entryProv = &p - } - fsys, err := embedReg.GetFSForEntry(packEntries[0], entryProv) + fsys, err := embedReg.GetFS(packEntries[0].ID) if err != nil { - t.Fatalf("GetFSForEntry failed: %v", err) + t.Fatalf("GetFS failed: %v", err) } data, err := fs.ReadFile(fsys, "index.html") if err != nil { @@ -964,7 +972,7 @@ func TestLoadPackEntries_RegistersModuleOwnedEmbeddedResources(t *testing.T) { } } -func TestLoadPackEntries_DoesNotOverrideExistingModuleMetadata(t *testing.T) { +func TestLoadPackEntries_PreservesAuthorMetadata(t *testing.T) { tmpDir := t.TempDir() packPath := createTestPackFileWithMetadata(t, tmpDir, "module-pack-existing-meta", wapp.Metadata{ "name": "users", @@ -985,7 +993,7 @@ func TestLoadPackEntries_DoesNotOverrideExistingModuleMetadata(t *testing.T) { embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, _, err := loadPackEntries([]string{packPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{packPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } @@ -995,28 +1003,30 @@ func TestLoadPackEntries_DoesNotOverrideExistingModuleMetadata(t *testing.T) { meta := packEntries[0].Meta if got := meta.GetString("module", ""); got != "custom/module" { - t.Fatalf("module = %q, want custom/module", got) + t.Fatalf("author module metadata = %q, want custom/module", got) } if got := meta.GetString("module_version", ""); got != "9.9.9" { - t.Fatalf("module_version = %q, want 9.9.9", got) + t.Fatalf("author module version metadata = %q, want 9.9.9", got) + } + if got := packEntries[0].Registry.Owner; got != "userspace/users" { + t.Fatalf("owner = %q, want userspace/users", got) } } -func TestLoadPackEntries_MonolithicPackRegistersModuleResourceAliases(t *testing.T) { +func TestLoadPackEntries_MonolithicPackRegistersResourcesByID(t *testing.T) { tmpDir := t.TempDir() root := filepath.Join(tmpDir, "static") writeTestFile(t, root, "index.html", []byte("module\n")) - packPath := createTestResourcePackFile(t, tmpDir, "snapshot", wapp.Metadata{ - "name": "snapshot", - }, []wapp.Entry{{ + entries := []wapp.Entry{{ ID: wapp.NewID("acme.ui", "static_fs"), Kind: "fs.embed", - Meta: wapp.Metadata{ - "module": "acme/ui", - "module_version": "1.2.3", - }, - }}, []wapp.ResourceSpec{{ + Meta: wapp.Metadata{"module": "author/controlled"}, + }} + metadata := wapp.Metadata{"name": "snapshot", packRegistryMetadataKey: map[string]any{ + "entries": map[string]any{"acme.ui:static_fs": map[string]any{"owner": "acme/ui"}}, + }} + packPath := createTestResourcePackFile(t, tmpDir, "snapshot", metadata, entries, []wapp.ResourceSpec{{ ID: wapp.NewID("acme.ui", "static_fs"), FS: os.DirFS(root), }}) @@ -1024,28 +1034,65 @@ func TestLoadPackEntries_MonolithicPackRegistersModuleResourceAliases(t *testing embedReg := embedpkg.NewRegistry() defer func() { _ = embedReg.Close() }() - packEntries, packProv, err := loadPackEntries([]string{packPath}, "", embedReg) + packEntries, err := loadPackEntries([]string{packPath}, "", embedReg) if err != nil { t.Fatalf("loadPackEntries failed: %v", err) } if len(packEntries) != 1 { t.Fatalf("entry count = %d, want 1", len(packEntries)) } - - var entryProv *regapi.EntryProvenance - if p, ok := packProv[packEntries[0].ID.Canonical()]; ok { - entryProv = &p + if got := packEntries[0].Registry.Owner; got != "acme/ui" { + t.Fatalf("owner = %q, want acme/ui", got) } - fsys, err := embedReg.GetFSForEntry(packEntries[0], entryProv) + + fsys, err := embedReg.GetFS(packEntries[0].ID) if err != nil { - t.Fatalf("GetFSForEntry failed: %v", err) + t.Fatalf("GetFS failed: %v", err) } data, err := fs.ReadFile(fsys, "index.html") if err != nil { - t.Fatalf("read aliased resource: %v", err) + t.Fatalf("read embedded resource: %v", err) } if string(data) != "module\n" { - t.Fatalf("aliased resource data = %q", string(data)) + t.Fatalf("embedded resource data = %q", string(data)) + } +} + +func TestPackRegistryMetadataCoversRegistryState(t *testing.T) { + items := []regapi.Entry{ + {ID: regapi.NewID("app", "root"), Registry: regapi.EntryMetadata{Root: true}}, + {ID: regapi.NewID("acme.ui", "static"), Registry: regapi.EntryMetadata{Owner: "acme/ui"}}, + } + metadata := packRegistryMetadata(items) + records, ok := metadata["entries"].(map[string]any) + if !ok { + t.Fatalf("entries metadata = %T, want map", metadata["entries"]) + } + root, ok := records["app:root"].(map[string]any) + if !ok || root["root"] != true { + t.Fatalf("root metadata = %#v, want root marker", records["app:root"]) + } + owned, ok := records["acme.ui:static"].(map[string]any) + if !ok || owned["owner"] != "acme/ui" { + t.Fatalf("owner metadata = %#v, want acme/ui", records["acme.ui:static"]) + } +} + +func TestLoadPackEntriesRejectsIncompleteRegistryMetadata(t *testing.T) { + tmpDir := t.TempDir() + packPath := createTestPackFileWithMetadata(t, tmpDir, "snapshot", wapp.Metadata{ + "name": "snapshot", + packRegistryMetadataKey: map[string]any{"entries": map[string]any{}}, + }, []wapp.Entry{{ + ID: wapp.NewID("app", "serve"), + Kind: "process.lua", + }}) + + embedReg := embedpkg.NewRegistry() + defer func() { _ = embedReg.Close() }() + _, err := loadPackEntries([]string{packPath}, "", embedReg) + if err == nil || !strings.Contains(err.Error(), "does not cover entry app:serve") { + t.Fatalf("loadPackEntries error = %v, want incomplete registry metadata", err) } } @@ -1265,6 +1312,13 @@ func createTestPackFile(t *testing.T, dir, name string, entries []wapp.Entry) st func createTestPackFileWithMetadata(t *testing.T, dir, name string, metadata wapp.Metadata, entries []wapp.Entry) string { t.Helper() + if _, found := metadata[packRegistryMetadataKey]; !found { + converted := make([]regapi.Entry, len(entries)) + for i := range entries { + converted[i] = regapi.Entry{ID: regapi.NewID(entries[i].ID.Namespace, entries[i].ID.Name)} + } + metadata[packRegistryMetadataKey] = packRegistryMetadata(converted) + } var buf bytes.Buffer writer := wapp.NewWriter() diff --git a/runtime/lua/modules/registry/changes.go b/runtime/lua/modules/registry/changes.go index ea1c6cbdc..b40f9bc38 100644 --- a/runtime/lua/modules/registry/changes.go +++ b/runtime/lua/modules/registry/changes.go @@ -100,34 +100,6 @@ func changesUpdate(l *lua.LState) int { return 2 } - // Root-ness is deployment state, not entry content. An echoed-back root - // value equal to the selection at the changeset's OWN snapshot passes; an - // attempted mutation is refused and pointed at the API that owns it. - // Comparing against the snapshot keeps a changeset staged from an older - // view valid regardless of concurrent activity. - if rootVal, ok := entryTable.RawGetString("root").(lua.LBool); ok { - current := false - if changes.snapshot != nil && changes.snapshot.prov != nil { - if p, found := changes.snapshot.prov[entry.ID.Canonical()]; found { - current = p.Root - } - } else if reg := regapi.GetRegistry(l.Context()); reg != nil { - if reader, readerOK := reg.(provenanceReader); readerOK { - if p, found := reader.EntryProvenance(entry.ID.Canonical()); found { - current = p.Root - } - } - } - if bool(rootVal) != current { - err := lua.NewLuaError(l, "root is deployment state; change it with registry.set_root"). - WithKind(lua.Invalid). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - } - changes.ops = append(changes.ops, regapi.Operation{ Kind: regapi.EntryUpdate, Entry: entry, diff --git a/runtime/lua/modules/registry/changes_test.go b/runtime/lua/modules/registry/changes_test.go index ad3c058cc..7ef385cba 100644 --- a/runtime/lua/modules/registry/changes_test.go +++ b/runtime/lua/modules/registry/changes_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" lua "github.com/wippyai/go-lua" - "github.com/wippyai/runtime/api/attrs" regapi "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/runtime/lua/engine/value" "go.uber.org/zap" @@ -110,84 +109,6 @@ func TestDeleteIDsAcceptsEntryListsForBulkOverlayDelete(t *testing.T) { } } -// A writer unaware of root status must not demote a deployment root. This is -// the shape keeper takes on every dependency update: read the entry, change a -// field, write it back. Absence of root on an update means unchanged. -// An update saying nothing about root carries no provenance: the registry's -// nil-provenance rule preserves the stored selection. -func TestChangesUpdateOmittingRootCarriesNoProvenance(t *testing.T) { - l := newTestState() - defer l.Close() - - stored := regapi.Entry{ - ID: regapi.ParseID("app.deps:keeper"), - Kind: "ns.dependency", - Meta: attrs.Bag{"module": "kickside/kickside"}, - } - - changes := &Changes{ - snapshot: &Snapshot{entries: []regapi.Entry{stored}, log: zap.NewNop()}, - ops: []regapi.Operation{}, - log: zap.NewNop(), - } - - ud := l.NewUserData() - ud.Value = changes - l.Push(ud) - - entryTable := l.CreateTable(0, 3) - entryTable.RawSetString("id", lua.LString("app.deps:keeper")) - entryTable.RawSetString("kind", lua.LString("ns.dependency")) - entryTable.RawSetString("meta", l.CreateTable(0, 0)) - l.Push(entryTable) - - changesUpdate(l) - - if len(changes.ops) != 1 { - t.Fatalf("expected one op, got %d", len(changes.ops)) - } - if changes.ops[0].Entry.DependencyRoot { - t.Error("entry payloads never carry the deployment-root flag") - } - if changes.ops[0].Provenance != nil { - t.Error("an update without a root statement carries no provenance") - } -} - -// Root mutations go through registry.set_root; an update table attempting one -// is refused. -func TestChangesUpdateRejectsRootMutation(t *testing.T) { - l := newTestState() - defer l.Close() - - stored := regapi.Entry{ - ID: regapi.ParseID("app.deps:keeper"), - Kind: "ns.dependency", - } - - changes := &Changes{ - snapshot: &Snapshot{entries: []regapi.Entry{stored}, log: zap.NewNop()}, - ops: []regapi.Operation{}, - log: zap.NewNop(), - } - - ud := l.NewUserData() - ud.Value = changes - l.Push(ud) - - entryTable := l.CreateTable(0, 3) - entryTable.RawSetString("id", lua.LString("app.deps:keeper")) - entryTable.RawSetString("kind", lua.LString("ns.dependency")) - entryTable.RawSetString("root", lua.LTrue) - l.Push(entryTable) - - changesUpdate(l) - - if len(changes.ops) != 0 { - t.Fatalf("a rejected root mutation must append no op, got %d", len(changes.ops)) - } -} - func TestDeleteIDsRejectsCyclicTables(t *testing.T) { l := newTestState() defer l.Close() diff --git a/runtime/lua/modules/registry/history.go b/runtime/lua/modules/registry/history.go index 7c675b06c..9bb2df183 100644 --- a/runtime/lua/modules/registry/history.go +++ b/runtime/lua/modules/registry/history.go @@ -6,6 +6,7 @@ import ( lua "github.com/wippyai/go-lua" regapi "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/runtime/lua/engine/value" + "github.com/wippyai/runtime/system/registry/topology" "go.uber.org/zap" ) @@ -120,16 +121,10 @@ func historySnapshotAt(l *lua.LState) int { return 2 } - provenanced, ok := history.reg.(regapi.ProvenancedSnapshotReader) - if !ok { - err := lua.NewLuaError(l, "registry does not serve historical snapshots"). - WithKind(lua.Unavailable). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - state, buildErr := provenanced.ProvenancedStateAtVersion(l.Context(), version) + resolver := regapi.GetResolver(l.Context()) + stateBuilder := topology.NewStateBuilder(history.log, resolver) + + state, buildErr := stateBuilder.BuildState(history.hist, version) if buildErr != nil { err := lua.WrapErrorWithLua(l, buildErr, "build snapshot state"). WithKind(lua.Internal). @@ -142,8 +137,7 @@ func historySnapshotAt(l *lua.LState) int { snap := &Snapshot{ reg: history.reg, version: version, - entries: state.Entries, - prov: state.Provenance, + entries: state, log: history.log, } diff --git a/runtime/lua/modules/registry/module.go b/runtime/lua/modules/registry/module.go index 11169a894..6f78d2440 100644 --- a/runtime/lua/modules/registry/module.go +++ b/runtime/lua/modules/registry/module.go @@ -11,6 +11,7 @@ import ( luaapi "github.com/wippyai/runtime/api/runtime/lua" "github.com/wippyai/runtime/runtime/lua/engine/value" "github.com/wippyai/runtime/runtime/security" + "github.com/wippyai/runtime/system/registry/topology" "go.uber.org/zap" ) @@ -88,7 +89,7 @@ func NewModule(opts Options) *luaapi.ModuleDef { Description: "Registry operations for entries, snapshots, and versioning", Class: []string{luaapi.ClassNondeterministic, luaapi.ClassStorage}, Build: func() (*lua.LTable, []luaapi.YieldType) { - mod := lua.CreateTable(0, 13) + mod := lua.CreateTable(0, 11) mod.RawSetString("get", lua.LGoFunc(registryGet)) mod.RawSetString("find", lua.LGoFunc(registryFind)) mod.RawSetString("parse_id", lua.LGoFunc(parseID)) @@ -100,8 +101,6 @@ func NewModule(opts Options) *luaapi.ModuleDef { mod.RawSetString("apply_version", lua.LGoFunc(registryApplyVersion)) mod.RawSetString("build_delta", makeBuildDelta(opts.Log)) mod.RawSetString("overlay", lua.LGoFunc(registryOverlay)) - mod.RawSetString("provenance", lua.LGoFunc(registryProvenance)) - mod.RawSetString("set_root", lua.LGoFunc(registrySetRoot)) mod.Immutable = true return mod, nil }, @@ -231,13 +230,6 @@ func registryGet(l *lua.LState) int { } entryTable, convErr := entryToLuaTable(l, entry) - if convErr == nil { - if reader, ok := reg.(provenanceReader); ok { - if p, found := reader.EntryProvenance(entry.ID.Canonical()); found { - entryTable.RawSetString("root", lua.LBool(p.Root)) - } - } - } if convErr != nil { err := lua.WrapErrorWithLua(l, convErr, "convert entry"). WithKind(lua.Internal). @@ -350,32 +342,13 @@ func registrySnapshot(l *lua.LState) int { return 2 } - // Version, entries and provenance come from one consistent read: a - // concurrent apply can never produce a mismatched snapshot. - consistent, ok := reg.(regapi.ProvenancedSnapshotReader) - if !ok { - err := lua.NewLuaError(l, "registry does not serve consistent snapshots"). - WithKind(lua.Unavailable). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - version, state, snapErr := consistent.SnapshotState() - if snapErr != nil { - err := lua.WrapErrorWithLua(l, snapErr, "snapshot registry state"). - WithKind(lua.Internal). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } + state := reg.Snapshot() snap := &Snapshot{ reg: reg, - version: version, + state: state.Registry, + version: state.Version, entries: state.Entries, - prov: state.Provenance, log: zap.NewNop(), } @@ -576,20 +549,10 @@ func makeSnapshotAt(log *zap.Logger) lua.LGoFunc { return 2 } - // A historical snapshot answers for the declarative layer: baseline - // plus authored history with provenance. Module worlds reconciled from - // stored resolutions materialize only through apply_version, which - // alone may stage their effects. - provenanced, ok := reg.(regapi.ProvenancedSnapshotReader) - if !ok { - err := lua.NewLuaError(l, "registry does not serve historical snapshots"). - WithKind(lua.Unavailable). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - state, stateErr := provenanced.ProvenancedStateAtVersion(ctx, foundVersion) + resolver := regapi.GetResolver(ctx) + stateBuilder := topology.NewStateBuilder(log, resolver) + + state, stateErr := stateBuilder.BuildState(hist, foundVersion) if stateErr != nil { err := lua.WrapErrorWithLua(l, stateErr, "build snapshot state"). WithKind(lua.Internal). @@ -602,8 +565,7 @@ func makeSnapshotAt(log *zap.Logger) lua.LGoFunc { snap := &Snapshot{ reg: reg, version: foundVersion, - entries: state.Entries, - prov: state.Provenance, + entries: state, log: log, } diff --git a/runtime/lua/modules/registry/module_test.go b/runtime/lua/modules/registry/module_test.go index f2bcea7b8..5c7d3ce34 100644 --- a/runtime/lua/modules/registry/module_test.go +++ b/runtime/lua/modules/registry/module_test.go @@ -42,7 +42,6 @@ func TestLoad(t *testing.T) { functions := []string{ "snapshot", "current_version", "versions", "parse_id", "history", "find", "get", "build_delta", "overlay", - "provenance", "set_root", } for _, fn := range functions { if modTbl.RawGetString(fn).Type() != lua.LTFunction { @@ -134,6 +133,36 @@ func TestSnapshotWithoutRegistry(t *testing.T) { } } +func TestRegistrySnapshotUsesAtomicState(t *testing.T) { + entry := regapi.Entry{ + ID: regapi.NewID("app", "handler"), + Kind: "function.lua", + Registry: regapi.EntryMetadata{Owner: "org/module"}, + } + reg := &mockRegistry{snapshot: regapi.Snapshot{ + Version: &mockVersion{id: 7, str: "v7"}, + Entries: regapi.State{entry}, + Registry: regapi.StateMetadata{Resolution: ®api.DependencyResolution{ + Digest: "sha256:resolution", InputDigest: "sha256:inputs", + Modules: []regapi.ResolvedModule{{Name: "org/module", Version: "1.2.3", Digest: "sha256:module"}}, + }}, + }} + ctx := regapi.WithRegistry(setupContextWithTranscoder(), reg) + + l := lua.NewState() + defer l.Close() + l.SetContext(ctx) + setupModule(l) + require.NoError(t, l.DoString(` + local snap, err = registry.snapshot() + assert(err == nil) + local state, state_err = snap:state() + assert(state_err == nil) + assert(state.entries[1].registry.owner == "org/module") + assert(state.resolution.modules[1].version == "1.2.3") + `)) +} + func TestCurrentVersionWithoutRegistry(t *testing.T) { l := lua.NewState() defer l.Close() @@ -224,64 +253,6 @@ func TestFindWithoutRegistry(t *testing.T) { } } -func TestRegistryProvenanceRequiresEntryRead(t *testing.T) { - id := regapi.NewID("app", "hidden") - reg := &mockRegistry{ - entries: map[string]regapi.Entry{id.String(): {ID: id}}, - provenance: regapi.ProvenanceMap{id: {Module: "private/module"}}, - } - ctx, release := strictOverlayContext(t) - defer release() - ctx = regapi.WithRegistry(ctx, reg) - - l := lua.NewState() - defer l.Close() - l.SetContext(ctx) - lua.OpenErrors(l) - setupModule(l) - require.NoError(t, l.DoString(` - local provenance, err = registry.provenance("app:hidden") - assert(provenance == nil and err ~= nil) - assert(err:kind() == errors.PERMISSION_DENIED) - `)) -} - -func TestRegistryProvenanceReturnsDetachedRecord(t *testing.T) { - id := regapi.NewID("app", "visible") - reg := &mockRegistry{ - entries: map[string]regapi.Entry{id.String(): {ID: id}}, - provenance: regapi.ProvenanceMap{id: { - Module: "org/module", Version: "1.2.3", Digest: "sha256:abc", Root: true, - }}, - } - ctx, release := strictOverlayContext(t, - "registry.get\x00"+id.String(), - "registry.get\x00app:missing", - ) - defer release() - ctx = regapi.WithRegistry(ctx, reg) - - l := lua.NewState() - defer l.Close() - l.SetContext(ctx) - setupModule(l) - require.NoError(t, l.DoString(` - local first, err = registry.provenance("app:visible") - assert(err == nil and first ~= nil) - assert(first.module == "org/module") - assert(first.version == "1.2.3") - assert(first.digest == "sha256:abc") - assert(first.root == true) - first.module = "forged/module" - - local second, second_err = registry.provenance("app:visible") - assert(second_err == nil and second.module == "org/module") - - local missing, missing_err = registry.provenance("app:missing") - assert(missing == nil and missing_err == nil) - `)) -} - func TestBuildDeltaWithoutTranscoder(t *testing.T) { l := lua.NewState() defer l.Close() @@ -661,19 +632,14 @@ func TestParseIDEdgeCases(t *testing.T) { // mockRegistry implements regapi.Registry for testing type mockRegistry struct { currentVersion regapi.Version + snapshot regapi.Snapshot entries map[string]regapi.Entry overlayEntries map[string]regapi.State - provenance regapi.ProvenanceMap appliedOwner string appliedChanges regapi.ChangeSet generation uint64 } -func (m *mockRegistry) EntryProvenance(id regapi.ID) (regapi.EntryProvenance, bool) { - p, ok := m.provenance[id.Canonical()] - return p, ok -} - func (m *mockRegistry) GetEntry(id regapi.ID) (regapi.Entry, error) { entry, ok := m.entries[id.String()] if !ok { @@ -690,6 +656,13 @@ func (m *mockRegistry) Current() (regapi.Version, error) { return m.currentVersion, nil } +func (m *mockRegistry) Snapshot() regapi.Snapshot { + if m.snapshot.Version != nil || m.snapshot.Entries != nil || m.snapshot.Registry.Resolution != nil { + return m.snapshot + } + return regapi.Snapshot{Version: m.currentVersion} +} + func (m *mockRegistry) Apply(_ context.Context, _ regapi.ChangeSet) (regapi.Version, error) { return nil, nil } @@ -698,7 +671,7 @@ func (m *mockRegistry) ApplyVersion(_ context.Context, _ regapi.Version) error { return nil } -func (m *mockRegistry) LoadState(_ context.Context, _ regapi.ProvenancedState, _ regapi.Version) error { +func (m *mockRegistry) LoadState(_ context.Context, _ regapi.State, _ regapi.Version) error { return nil } @@ -761,9 +734,6 @@ func TestRegistryOverlayUsesNormalSnapshotAndChanges(t *testing.T) { require.NoError(t, l.DoString(` local snap, err = registry.overlay("app.runtime:source-1") assert(err == nil and snap ~= nil) - local state, state_err = snap:state() - assert(state == nil and state_err ~= nil) - assert(state_err:kind() == errors.UNAVAILABLE) local entries, entries_err = snap:entries() assert(entries_err == nil and #entries == 1) assert(snap:version():id() == 7) diff --git a/runtime/lua/modules/registry/provenance.go b/runtime/lua/modules/registry/provenance.go deleted file mode 100644 index 4029d3342..000000000 --- a/runtime/lua/modules/registry/provenance.go +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "context" - - lua "github.com/wippyai/go-lua" - regapi "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/runtime/security" -) - -// provenanceReader answers provenance questions about the live registry. -type provenanceReader interface { - EntryProvenance(regapi.ID) (regapi.EntryProvenance, bool) -} - -func provenanceToLuaTable(l *lua.LState, p regapi.EntryProvenance) *lua.LTable { - table := l.CreateTable(0, 4) - table.RawSetString("module", lua.LString(p.Module)) - table.RawSetString("version", lua.LString(p.Version)) - table.RawSetString("digest", lua.LString(p.Digest)) - table.RawSetString("root", lua.LBool(p.Root)) - return table -} - -// registryProvenance returns the provenance record for one entry of the live -// state: {module, version, digest, root}, or nil for an unknown entry. Values -// are copies; mutating the table changes nothing. -func registryProvenance(l *lua.LState) int { - id := regapi.ParseID(l.CheckString(1)).Canonical() - ctx := l.Context() - if ctx == nil { - l.Push(lua.LNil) - l.Push(lua.NewLuaError(l, "no context"). - WithKind(lua.Internal). - WithRetryable(false)) - return 2 - } - - reg := regapi.GetRegistry(ctx) - if reg == nil { - err := lua.NewLuaError(l, "registry not available"). - WithKind(lua.Unavailable). - WithRetryable(true) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - if !security.IsAllowed(ctx, "registry.get", id.String(), nil) { - l.Push(lua.LNil) - l.Push(lua.NewLuaError(l, "not allowed to access entry: "+id.String()). - WithKind(lua.PermissionDenied). - WithRetryable(false)) - return 2 - } - - reader, ok := reg.(provenanceReader) - if !ok { - err := lua.NewLuaError(l, "registry does not serve provenance"). - WithKind(lua.Unavailable). - WithRetryable(false) - l.Push(lua.LNil) - l.Push(err) - return 2 - } - - p, found := reader.EntryProvenance(id) - if !found { - l.Push(lua.LNil) - l.Push(lua.LNil) - return 2 - } - - l.Push(provenanceToLuaTable(l, p)) - l.Push(lua.LNil) - return 2 -} - -// rootSetter is the registry-side atomic root mutation. -type rootSetter interface { - SetDependencyRoot(ctx context.Context, id regapi.ID, root bool) (regapi.Version, error) -} - -// registrySetRoot flips the deployment-root selection of one ns.dependency -// entry. Root-ness is deployment state held by the registry, not entry -// content: the mutation is authorized like any governance apply and runs -// inside the registry's apply serialization, changing only the Root flag on -// the current tuple. -func registrySetRoot(l *lua.LState) int { - idStr := l.CheckString(1) - enable := l.CheckBool(2) - - if !security.IsAllowed(l.Context(), "registry.apply", "", nil) { - err := lua.NewLuaError(l, "not allowed to apply registry changes"). - WithKind(lua.PermissionDenied). - WithRetryable(false) - l.Push(lua.LFalse) - l.Push(err) - return 2 - } - - reg := regapi.GetRegistry(l.Context()) - if reg == nil { - err := lua.NewLuaError(l, "registry not available"). - WithKind(lua.Unavailable). - WithRetryable(true) - l.Push(lua.LFalse) - l.Push(err) - return 2 - } - setter, ok := reg.(rootSetter) - if !ok { - err := lua.NewLuaError(l, "registry does not serve deployment-root mutation"). - WithKind(lua.Unavailable). - WithRetryable(false) - l.Push(lua.LFalse) - l.Push(err) - return 2 - } - - if _, setErr := setter.SetDependencyRoot(l.Context(), regapi.ParseID(idStr), enable); setErr != nil { - err := lua.WrapErrorWithLua(l, setErr, "set deployment root"). - WithKind(lua.Invalid). - WithRetryable(false) - l.Push(lua.LFalse) - l.Push(err) - return 2 - } - - l.Push(lua.LTrue) - l.Push(lua.LNil) - return 2 -} diff --git a/runtime/lua/modules/registry/snapshot.go b/runtime/lua/modules/registry/snapshot.go index fc986d69a..848108b7a 100644 --- a/runtime/lua/modules/registry/snapshot.go +++ b/runtime/lua/modules/registry/snapshot.go @@ -16,27 +16,54 @@ import ( // Snapshot represents a point-in-time view of the registry type Snapshot struct { reg regapi.Registry + state regapi.StateMetadata version regapi.Version log *zap.Logger - prov regapi.ProvenanceMap overlayOwner string entries []regapi.Entry overlayGen uint64 } -// entryTable renders one entry with root served from the snapshot's -// provenance. -func (s *Snapshot) entryTable(l *lua.LState, entry regapi.Entry) (*lua.LTable, error) { - table, err := entryToLuaTable(l, entry) - if err != nil { - return nil, err +// snapshotState returns the entry state and selected module graph captured by +// this snapshot. Entry registry metadata is represented with each entry so +// callers cannot associate ownership with a different snapshot or entry. +func snapshotState(l *lua.LState) int { + snap := checkSnapshot(l) + if snap == nil { + return 0 } - if s.prov != nil { - if p, ok := s.prov[entry.ID.Canonical()]; ok { - table.RawSetString("root", lua.LBool(p.Root)) + if !authorizeSnapshotRead(l, snap) { + return 2 + } + + entries := l.CreateTable(len(snap.entries), 0) + idx := 1 + for _, entry := range snap.entries { + if snap.overlayOwner == "" && !security.IsAllowed(l.Context(), "registry.get", entry.ID.String(), nil) { + continue } + + entryTable, err := stateEntryToLuaTable(l, entry) + if err != nil { + l.Push(lua.LNil) + l.Push(lua.WrapErrorWithLua(l, err, "convert registry state entry"). + WithKind(lua.Internal). + WithRetryable(false)) + return 2 + } + entries.RawSetInt(idx, entryTable) + idx++ + } + + result := l.CreateTable(0, 2) + result.RawSetString("entries", entries) + if snap.state.Resolution != nil { + result.RawSetString("resolution", resolutionToLuaTable(l, snap.state.Resolution)) } - return table, nil + + l.Push(result) + l.Push(lua.LNil) + return 2 } func authorizeSnapshotRead(l *lua.LState, snap *Snapshot) bool { @@ -65,65 +92,6 @@ func (s *Snapshot) GetEntry(id regapi.ID) (regapi.Entry, error) { return regapi.Entry{}, fmt.Errorf("entry not found: %s", id) } -// snapshotState returns the authorized declarative entries and their -// registry-owned provenance from this exact snapshot. The keyed provenance map -// is total over the returned entries; missing or orphaned records are an -// invariant failure, never an implicit host-ownership statement. -func snapshotState(l *lua.LState) int { - snap := checkSnapshot(l) - if snap == nil { - return 0 - } - if !authorizeSnapshotRead(l, snap) { - return 2 - } - if snap.prov == nil { - l.Push(lua.LNil) - l.Push(lua.NewLuaError(l, "snapshot does not carry registry provenance"). - WithKind(lua.Unavailable). - WithRetryable(false)) - return 2 - } - - state := regapi.ProvenancedState{Entries: snap.entries, Provenance: snap.prov} - if err := state.Validate(); err != nil { - l.Push(lua.LNil) - l.Push(lua.WrapErrorWithLua(l, err, "validate snapshot provenance"). - WithKind(lua.Internal). - WithRetryable(false)) - return 2 - } - - entriesTable := l.CreateTable(len(snap.entries), 0) - provenanceTable := l.CreateTable(0, len(snap.entries)) - idx := 1 - for _, entry := range snap.entries { - id := entry.ID.Canonical() - if snap.overlayOwner == "" && !security.IsAllowed(l.Context(), "registry.get", id.String(), nil) { - continue - } - - entryTable, err := snap.entryTable(l, entry) - if err != nil { - l.Push(lua.LNil) - l.Push(lua.WrapErrorWithLua(l, err, "convert entry"). - WithKind(lua.Internal). - WithRetryable(false)) - return 2 - } - entriesTable.RawSetInt(idx, entryTable) - provenanceTable.RawSetString(id.String(), provenanceToLuaTable(l, snap.prov[id])) - idx++ - } - - result := l.CreateTable(0, 2) - result.RawSetString("entries", entriesTable) - result.RawSetString("provenance", provenanceTable) - l.Push(result) - l.Push(lua.LNil) - return 2 -} - // snapshotEntries returns all entries in the snapshot func snapshotEntries(l *lua.LState) int { snap := checkSnapshot(l) @@ -151,7 +119,7 @@ func snapshotEntries(l *lua.LState) int { continue } - entryTable, convErr := snap.entryTable(l, entry) + entryTable, convErr := entryToLuaTable(l, entry) if convErr != nil { err := lua.WrapErrorWithLua(l, convErr, "convert entry"). WithKind(lua.Internal). @@ -201,7 +169,7 @@ func snapshotGet(l *lua.LState) int { return 2 } - entryTable, convErr := snap.entryTable(l, entry) + entryTable, convErr := entryToLuaTable(l, entry) if convErr != nil { err := lua.WrapErrorWithLua(l, convErr, "convert entry"). WithKind(lua.Internal). @@ -239,7 +207,7 @@ func snapshotNamespace(l *lua.LState) int { entriesTable := l.CreateTable(len(result), 0) for i, entry := range result { - entryTable, convErr := snap.entryTable(l, entry) + entryTable, convErr := entryToLuaTable(l, entry) if convErr != nil { err := lua.WrapErrorWithLua(l, convErr, "convert entry"). WithKind(lua.Internal). @@ -296,7 +264,7 @@ func snapshotFind(l *lua.LState) int { continue } - entryTable, convErr := snap.entryTable(l, entry) + entryTable, convErr := entryToLuaTable(l, entry) if convErr != nil { err := lua.WrapErrorWithLua(l, convErr, "convert entry"). WithKind(lua.Internal). diff --git a/runtime/lua/modules/registry/snapshot_test.go b/runtime/lua/modules/registry/snapshot_test.go index 20559a193..af27aa43c 100644 --- a/runtime/lua/modules/registry/snapshot_test.go +++ b/runtime/lua/modules/registry/snapshot_test.go @@ -88,73 +88,59 @@ func runSnapshotState(ctx context.Context, t *testing.T, snap *Snapshot, source require.NoError(t, l.DoString(source)) } -func TestSnapshotStateReturnsTotalDetachedState(t *testing.T) { - rootID := regapi.NewID("app", "root") - ownedID := regapi.NewID("pkg", "entry") +func TestSnapshotStateReturnsDetachedRegistryMetadataAndResolution(t *testing.T) { + rootID := regapi.NewID("app.deps", "module") + ownedID := regapi.NewID("app", "handler") snap := &Snapshot{ entries: regapi.State{ - {ID: rootID, Kind: regapi.NamespaceDependency}, - {ID: ownedID, Kind: "function.lua"}, - }, - prov: regapi.ProvenanceMap{ - rootID: {Root: true}, - ownedID: {Module: "org/pkg", Version: "1.2.3", Digest: "sha256:abc"}, + {ID: rootID, Kind: regapi.NamespaceDependency, Registry: regapi.EntryMetadata{Root: true}}, + {ID: ownedID, Kind: "function.lua", Registry: regapi.EntryMetadata{Owner: "org/module"}}, }, + state: regapi.StateMetadata{Resolution: ®api.DependencyResolution{ + Digest: "sha256:resolution", + InputDigest: "sha256:inputs", + Roots: []regapi.DependencyRoot{{ + ID: rootID.String(), Component: "org/module", Version: "^1.0", + }}, + Modules: []regapi.ResolvedModule{{ + Name: "org/module", Version: "1.2.3", VersionID: "version-id", Source: "hub", Digest: "sha256:module", SizeBytes: 42, Protected: true, + }}, + }}, log: zap.NewNop(), } runSnapshotState(setupContextWithTranscoder(), t, snap, ` local first, err = snap:state() assert(err == nil and #first.entries == 2) - assert(first.entries[1].root == true) - assert(first.entries[1].meta.module == nil) - assert(first.provenance["app:root"].module == "") - assert(first.provenance["app:root"].root == true) - assert(first.provenance["pkg:entry"].module == "org/pkg") - assert(first.provenance["pkg:entry"].version == "1.2.3") - assert(first.provenance["pkg:entry"].digest == "sha256:abc") - - first.entries[1].root = false - first.entries[1].meta.module = "forged/pkg" - first.provenance["pkg:entry"].module = "forged/pkg" - first.provenance["app:root"] = nil + assert(first.entries[1].registry.owner == "") + assert(first.entries[1].registry.root == true) + assert(first.entries[2].registry.owner == "org/module") + assert(first.entries[2].registry.root == false) + assert(first.entries[2].root == nil) + assert(first.resolution.digest == "sha256:resolution") + assert(first.resolution.roots[1].component == "org/module") + assert(first.resolution.modules[1].version == "1.2.3") + assert(first.resolution.modules[1].size_bytes == 42) + + first.entries[2].registry.owner = "forged/module" + first.entries[1].registry.root = false + first.resolution.modules[1].version = "999.0.0" local second, second_err = snap:state() - assert(second_err == nil and #second.entries == 2) - assert(second.entries[1].root == true) - assert(second.entries[1].meta.module == nil) - assert(second.provenance["pkg:entry"].module == "org/pkg") - assert(second.provenance["app:root"].root == true) + assert(second_err == nil) + assert(second.entries[2].registry.owner == "org/module") + assert(second.entries[1].registry.root == true) + assert(second.resolution.modules[1].version == "1.2.3") `) } -func TestSnapshotStateRejectsInvalidProvenance(t *testing.T) { - id := regapi.NewID("app", "entry") - assertInvalid := func(t *testing.T, provenance regapi.ProvenanceMap) { - t.Helper() - snap := &Snapshot{entries: regapi.State{{ID: id}}, prov: provenance, log: zap.NewNop()} - runSnapshotState(setupContextWithTranscoder(), t, snap, ` - local state, err = snap:state() - assert(state == nil and err ~= nil) - assert(err:kind() == errors.INTERNAL) - `) - } - t.Run("missing", func(t *testing.T) { - assertInvalid(t, regapi.ProvenanceMap{}) - }) - t.Run("orphaned", func(t *testing.T) { - assertInvalid(t, regapi.ProvenanceMap{id: {}, regapi.NewID("app", "orphan"): {}}) - }) -} - -func TestSnapshotStateFiltersEntriesAndProvenanceTogether(t *testing.T) { +func TestSnapshotStateFiltersEntriesWithMetadata(t *testing.T) { visible := regapi.NewID("app", "visible") hidden := regapi.NewID("app", "hidden") snap := &Snapshot{ - entries: regapi.State{{ID: visible}, {ID: hidden}}, - prov: regapi.ProvenanceMap{ - visible: {Module: "org/visible"}, - hidden: {Module: "org/hidden"}, + entries: regapi.State{ + {ID: visible, Registry: regapi.EntryMetadata{Owner: "org/visible"}}, + {ID: hidden, Registry: regapi.EntryMetadata{Owner: "org/hidden"}}, }, log: zap.NewNop(), } @@ -165,25 +151,7 @@ func TestSnapshotStateFiltersEntriesAndProvenanceTogether(t *testing.T) { local state, err = snap:state() assert(err == nil and #state.entries == 1) assert(state.entries[1].id == "app:visible") - assert(state.provenance["app:visible"].module == "org/visible") - assert(state.provenance["app:hidden"] == nil) - `) -} - -func TestOverlaySnapshotStateRequiresProvenance(t *testing.T) { - const owner = "runtime:overlay" - snap := &Snapshot{ - overlayOwner: owner, - entries: regapi.State{{ID: regapi.NewID("app", "draft")}}, - log: zap.NewNop(), - } - ctx, release := strictOverlayContext(t, "registry.overlay.get\x00"+owner) - defer release() - - runSnapshotState(ctx, t, snap, ` - local state, err = snap:state() - assert(state == nil and err ~= nil) - assert(err:kind() == errors.UNAVAILABLE) + assert(state.entries[1].registry.owner == "org/visible") `) } diff --git a/runtime/lua/modules/registry/types.go b/runtime/lua/modules/registry/types.go index cea57d6cd..49348980a 100644 --- a/runtime/lua/modules/registry/types.go +++ b/runtime/lua/modules/registry/types.go @@ -13,27 +13,58 @@ var idType = typ.NewRecord(). Field("name", typ.String). Build() -// Entry type represents a registry entry. root marks an ns.dependency selected -// as a deployment root. It is optional on the way in and always present on the -// way out, so a writer may omit it while a reader can always carry it back. +// Entry type represents an author-facing registry entry. dependency_root is a +// write-side control used when declaring a deployment root; registry-owned +// metadata is exposed only by the snapshot state API. var entryType = typ.NewRecord(). Field("id", typ.String). Field("kind", typ.String). Field("meta", typ.NewMap(typ.String, typ.Any)). Field("data", typ.Any). - OptField("root", typ.Boolean). + Field("dependency_root", typ.Boolean). Build() -var entryProvenanceType = typ.NewRecord(). - Field("module", typ.String). +var entryMetadataType = typ.NewRecord(). + Field("owner", typ.String). + Field("root", typ.Boolean). + Build() + +var stateEntryType = typ.NewRecord(). + Field("id", typ.String). + Field("kind", typ.String). + Field("meta", typ.NewMap(typ.String, typ.Any)). + Field("data", typ.Any). + Field("registry", entryMetadataType). + Build() + +var dependencyRootType = typ.NewRecord(). + Field("id", typ.String). + Field("component", typ.String). + Field("version", typ.String). + Build() + +var resolvedModuleType = typ.NewRecord(). + Field("name", typ.String). Field("version", typ.String). + OptField("version_id", typ.String). + OptField("source", typ.String). + OptField("digest", typ.String). + OptField("size_bytes", typ.Number). + OptField("protected", typ.Boolean). + Build() + +var resolutionType = typ.NewRecord(). Field("digest", typ.String). - Field("root", typ.Boolean). + Field("input_digest", typ.String). + OptField("baseline_digest", typ.String). + Field("roots", typ.NewArray(dependencyRootType)). + OptField("references", typ.NewArray(dependencyRootType)). + Field("modules", typ.NewArray(resolvedModuleType)). Build() -var provenancedStateType = typ.NewRecord(). - Field("entries", typ.NewArray(entryType)). - Field("provenance", typ.NewMap(typ.String, entryProvenanceType)). +var stateType = typ.NewRecord(). + Field("entries", typ.NewArray(stateEntryType)). + OptField("resolution", resolutionType). Build() // Forward declarations for self-referential types @@ -65,7 +96,7 @@ func init() { // Snapshot type (references changesType and versionType) snapshotType = typ.NewInterface("registry.Snapshot", []typ.Method{ {Name: "entries", Type: typ.Func().Param("self", typ.Self).Returns(typ.NewArray(entryType)).Build()}, - {Name: "state", Type: typ.Func().Param("self", typ.Self).Returns(provenancedStateType, typ.NewOptional(typ.LuaError)).Build()}, + {Name: "state", Type: typ.Func().Param("self", typ.Self).Returns(stateType, typ.NewOptional(typ.LuaError)).Build()}, {Name: "get", Type: typ.Func().Param("self", typ.Self).Param("key", typ.String).Returns(entryType, typ.NewOptional(typ.LuaError)).Build()}, {Name: "namespace", Type: typ.Func().Param("self", typ.Self).Param("ns", typ.String).Returns(typ.NewArray(entryType)).Build()}, {Name: "find", Type: typ.Func().Param("self", typ.Self).Param("query", typ.Any).Returns(typ.NewArray(entryType)).Build()}, @@ -91,8 +122,6 @@ func ModuleTypes() *typio.Manifest { m.DefineType("History", historyType) m.DefineType("ID", idType) m.DefineType("Entry", entryType) - m.DefineType("EntryProvenance", entryProvenanceType) - m.DefineType("ProvenancedState", provenancedStateType) moduleType := typ.NewInterface("registry", []typ.Method{ {Name: "get", Type: typ.Func().Param("key", typ.String).Returns(entryType, typ.NewOptional(typ.LuaError)).Build()}, @@ -106,8 +135,6 @@ func ModuleTypes() *typio.Manifest { {Name: "apply_version", Type: typ.Func().Param("version", versionType).Returns(typ.Boolean, typ.NewOptional(typ.LuaError)).Build()}, {Name: "build_delta", Type: typ.Func().Param("from", versionType).Param("to", versionType).Returns(typ.Any, typ.NewOptional(typ.LuaError)).Build()}, {Name: "overlay", Type: typ.Func().Param("id", typ.String).Returns(snapshotType, typ.NewOptional(typ.LuaError)).Build()}, - {Name: "provenance", Type: typ.Func().Param("id", typ.String).Returns(typ.NewOptional(entryProvenanceType), typ.NewOptional(typ.LuaError)).Build()}, - {Name: "set_root", Type: typ.Func().Param("id", typ.String).Param("root", typ.Boolean).Returns(typ.Boolean, typ.NewOptional(typ.LuaError)).Build()}, }) m.SetExport(moduleType) diff --git a/runtime/lua/modules/registry/utils.go b/runtime/lua/modules/registry/utils.go index 94b424205..b3518f08a 100644 --- a/runtime/lua/modules/registry/utils.go +++ b/runtime/lua/modules/registry/utils.go @@ -60,6 +60,17 @@ func luaTableToEntry(l *lua.LState, table *lua.LTable) (regapi.Entry, error) { entry.Meta = attrs.Bag{} } + // Extract deployment-root status. Ownership is assigned by the dependency + // directive; callers may only select whether an ns.dependency is a root. + rootVal := table.RawGetString("dependency_root") + if rootVal != lua.LNil { + root, ok := rootVal.(lua.LBool) + if !ok { + return entry, errors.New("entry dependency_root must be a boolean") + } + entry.Registry.Root = bool(root) + } + // Extract data dataVal := table.RawGetString("data") if dataVal != lua.LNil { @@ -79,10 +90,6 @@ func entryToLuaTable(l *lua.LState, entry regapi.Entry) (*lua.LTable, error) { // Add kind entryTable.RawSetString("kind", lua.LString(entry.Kind)) - // Root-ness is served from provenance by live readers; the raw entry - // field only survives on decoded legacy payloads. - entryTable.RawSetString("root", lua.LBool(entry.DependencyRoot)) - // Convert metadata metaTable := l.CreateTable(0, len(entry.Meta)) for k, v := range entry.Meta { @@ -122,6 +129,70 @@ func entryToLuaTable(l *lua.LState, entry regapi.Entry) (*lua.LTable, error) { return entryTable, nil } +// stateEntryToLuaTable includes registry-owned metadata for the state API. +// The ordinary entry APIs intentionally expose only the author-facing shape. +func stateEntryToLuaTable(l *lua.LState, entry regapi.Entry) (*lua.LTable, error) { + entryTable, err := entryToLuaTable(l, entry) + if err != nil { + return nil, err + } + metadata := l.CreateTable(0, 2) + metadata.RawSetString("owner", lua.LString(entry.Registry.Owner)) + metadata.RawSetString("root", lua.LBool(entry.Registry.Root)) + entryTable.RawSetString("registry", metadata) + return entryTable, nil +} + +func resolutionToLuaTable(l *lua.LState, resolution *regapi.DependencyResolution) *lua.LTable { + table := l.CreateTable(0, 6) + table.RawSetString("digest", lua.LString(resolution.Digest)) + table.RawSetString("input_digest", lua.LString(resolution.InputDigest)) + if resolution.BaselineDigest != "" { + table.RawSetString("baseline_digest", lua.LString(resolution.BaselineDigest)) + } + table.RawSetString("roots", dependencyRootsToLuaTable(l, resolution.Roots)) + if len(resolution.References) > 0 { + table.RawSetString("references", dependencyRootsToLuaTable(l, resolution.References)) + } + + modules := l.CreateTable(len(resolution.Modules), 0) + for i, module := range resolution.Modules { + item := l.CreateTable(0, 7) + item.RawSetString("name", lua.LString(module.Name)) + item.RawSetString("version", lua.LString(module.Version)) + if module.VersionID != "" { + item.RawSetString("version_id", lua.LString(module.VersionID)) + } + if module.Source != "" { + item.RawSetString("source", lua.LString(module.Source)) + } + if module.Digest != "" { + item.RawSetString("digest", lua.LString(module.Digest)) + } + if module.SizeBytes != 0 { + item.RawSetString("size_bytes", lua.LNumber(module.SizeBytes)) + } + if module.Protected { + item.RawSetString("protected", lua.LTrue) + } + modules.RawSetInt(i+1, item) + } + table.RawSetString("modules", modules) + return table +} + +func dependencyRootsToLuaTable(l *lua.LState, roots []regapi.DependencyRoot) *lua.LTable { + table := l.CreateTable(len(roots), 0) + for i, root := range roots { + item := l.CreateTable(0, 3) + item.RawSetString("id", lua.LString(root.ID)) + item.RawSetString("component", lua.LString(root.Component)) + item.RawSetString("version", lua.LString(root.Version)) + table.RawSetInt(i+1, item) + } + return table +} + // convertFilterToMetadata converts a Lua filter table to registry metadata func convertFilterToMetadata(_ *lua.LState, filterTable *lua.LTable) attrs.Bag { meta := attrs.Bag{} diff --git a/runtime/lua/modules/registry/utils_test.go b/runtime/lua/modules/registry/utils_test.go index c96c4a426..3a68444d6 100644 --- a/runtime/lua/modules/registry/utils_test.go +++ b/runtime/lua/modules/registry/utils_test.go @@ -6,7 +6,6 @@ import ( "testing" lua "github.com/wippyai/go-lua" - "github.com/wippyai/runtime/api/attrs" regapi "github.com/wippyai/runtime/api/registry" ) @@ -223,71 +222,59 @@ func TestValuesEqualArrayVsNonArray(t *testing.T) { } } -// root marks an ns.dependency selected as a deployment root and is the sole -// authority for that status: meta is user space and carries no trust. A Lua -// write path that cannot read or emit the field silently demotes every root. -// Root-ness never lands on the entry payload: the table field is view data. -func TestLuaTableToEntryIgnoresRoot(t *testing.T) { +func TestLuaTableToEntryDoesNotAcceptRegistryMetadata(t *testing.T) { l := newTestState() defer l.Close() table := l.CreateTable(0, 3) - table.RawSetString("id", lua.LString("app.deps:crm")) - table.RawSetString("kind", lua.LString("ns.dependency")) - table.RawSetString("root", lua.LTrue) + table.RawSetString("id", lua.LString("app:handler")) + table.RawSetString("kind", lua.LString("function.lua")) + metadata := l.CreateTable(0, 2) + metadata.RawSetString("owner", lua.LString("forged/module")) + metadata.RawSetString("root", lua.LTrue) + table.RawSetString("registry", metadata) entry, err := luaTableToEntry(l, table) if err != nil { t.Fatalf("unexpected error: %v", err) } - if entry.DependencyRoot { - t.Error("entry payloads never carry the deployment-root flag") + if entry.Registry != (regapi.EntryMetadata{}) { + t.Fatalf("registry metadata must not be accepted from Lua: %#v", entry.Registry) } } -func TestLuaTableToEntryDefaultsRootFalse(t *testing.T) { +func TestLuaTableToEntryAcceptsDependencyRootControl(t *testing.T) { l := newTestState() defer l.Close() - table := l.CreateTable(0, 2) - table.RawSetString("id", lua.LString("app.deps:crm")) + table := l.CreateTable(0, 3) + table.RawSetString("id", lua.LString("app.deps:knowledge")) table.RawSetString("kind", lua.LString("ns.dependency")) + table.RawSetString("dependency_root", lua.LTrue) entry, err := luaTableToEntry(l, table) if err != nil { t.Fatalf("unexpected error: %v", err) } - if entry.DependencyRoot { - t.Error("expected root to default to false when absent") + if !entry.Registry.Root { + t.Fatal("dependency_root must select the registry-owned root bit") + } + if entry.Registry.Owner != "" { + t.Fatalf("Lua must not assign registry ownership: %q", entry.Registry.Owner) } } -// A decoded legacy payload still emits its flag for display; converting the -// table back never rebuilds it on the entry. -func TestEntryLuaRoundTripDropsRootFromPayload(t *testing.T) { +func TestLuaTableToEntryRejectsInvalidDependencyRoot(t *testing.T) { l := newTestState() defer l.Close() - original := regapi.Entry{ - ID: regapi.ParseID("app.deps:crm"), - Kind: "ns.dependency", - Meta: attrs.Bag{"module": "kickside/app"}, - DependencyRoot: true, - } - - table, err := entryToLuaTable(l, original) - if err != nil { - t.Fatalf("unexpected error converting entry to table: %v", err) - } - if table.RawGetString("root") != lua.LTrue { - t.Fatal("expected root to be emitted onto the Lua table") - } + table := l.CreateTable(0, 3) + table.RawSetString("id", lua.LString("app.deps:knowledge")) + table.RawSetString("kind", lua.LString("ns.dependency")) + table.RawSetString("dependency_root", lua.LString("true")) - back, err := luaTableToEntry(l, table) - if err != nil { - t.Fatalf("unexpected error converting table to entry: %v", err) - } - if back.DependencyRoot { - t.Error("entry payloads never carry the deployment-root flag") + _, err := luaTableToEntry(l, table) + if err == nil { + t.Fatal("expected invalid dependency_root to fail") } } diff --git a/runtime/lua/modules/system/module_test.go b/runtime/lua/modules/system/module_test.go index 2642b7105..75486a4c9 100644 --- a/runtime/lua/modules/system/module_test.go +++ b/runtime/lua/modules/system/module_test.go @@ -63,8 +63,8 @@ func TestSourceLoadReturnsAtomicOwnersAndEntriesWithoutPaths(t *testing.T) { "acme/alpha": {LoadPath: "/private/alpha", ResourceRoot: "/private/alpha", Owner: "acme/alpha"}, "acme/packed": {LoadPath: "/private/packed.wapp"}, }) - sources.SetLoader(func(context.Context, moduleapi.Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { - return []regapi.Entry{{ID: regapi.NewID("example.source", "probe"), Kind: "registry.entry"}}, nil, nil + sources.SetLoader(func(context.Context, moduleapi.Sources) ([]regapi.Entry, error) { + return []regapi.Entry{{ID: regapi.NewID("example.source", "probe"), Kind: "registry.entry"}}, nil }) ctx = moduleapi.WithSourceRegistry(ctx, sources) l.SetContext(ctx) @@ -118,8 +118,8 @@ func TestSourceLoadDoesNotExposePrivatePathInError(t *testing.T) { Owner: moduleapi.ApplicationSourceID, }, }) - sources.SetLoader(func(context.Context, moduleapi.Sources) ([]regapi.Entry, regapi.ProvenanceMap, error) { - return nil, nil, errors.New("open /private/application/source/_index.yaml: permission denied") + sources.SetLoader(func(context.Context, moduleapi.Sources) ([]regapi.Entry, error) { + return nil, errors.New("open /private/application/source/_index.yaml: permission denied") }) ctx = moduleapi.WithSourceRegistry(ctx, sources) l.SetContext(ctx) diff --git a/service/fs/directory/manager.go b/service/fs/directory/manager.go index af596b54a..4a5e93904 100644 --- a/service/fs/directory/manager.go +++ b/service/fs/directory/manager.go @@ -141,7 +141,7 @@ func (m *Manager) registerFS(ctx context.Context, id registry.ID, cfg *dirapi.Co func (m *Manager) registerFSLocked(ctx context.Context, entry registry.Entry, cfg *dirapi.Config) error { id := entry.ID - dirPath := resolveDirectoryPath(ctx, cfg) + dirPath := resolveDirectoryPath(ctx, entry, cfg) fs, err := m.factory.CreateFS(CreateFSConfig{ DirPath: dirPath, Mode: cfg.GetMode(), @@ -173,20 +173,8 @@ func (m *Manager) registerFSLocked(ctx context.Context, entry registry.Entry, cf return nil } -// resolveDirectoryPath resolves the load path against the source root of the -// module the dispatched operation names as the entry's owner. A caller outside -// a provenance-carrying transition, and a host-authored entry, resolve -// working-directory relative. -func resolveDirectoryPath(ctx context.Context, cfg *dirapi.Config) string { - return dirapi.ResolveDirectory(ctx, owningModule(ctx), cfg) -} - -func owningModule(ctx context.Context) string { - op, ok := registry.OpProvenanceFromContext(ctx) - if !ok || op.Effective == nil { - return "" - } - return op.Effective.Module +func resolveDirectoryPath(ctx context.Context, entry registry.Entry, cfg *dirapi.Config) string { + return dirapi.ResolveDirectory(ctx, entry, cfg) } // removeFS removes the filesystem from the fs system diff --git a/service/fs/directory/manager_test.go b/service/fs/directory/manager_test.go index a798e08b9..90e95d6ac 100644 --- a/service/fs/directory/manager_test.go +++ b/service/fs/directory/manager_test.go @@ -3,7 +3,6 @@ package directory import ( - "context" "errors" "fmt" "io/fs" @@ -495,91 +494,83 @@ func TestManager_RegisterFS(t *testing.T) { func TestResolveDirectoryPath(t *testing.T) { moduleRoot := t.TempDir() - withSources := ctxapi.NewRootContext() + ctx := ctxapi.NewRootContext() sources := moduleapi.NewSourceRegistry() sources.Set(moduleapi.Sources{"acme/ui": { LoadPath: moduleRoot, ResourceRoot: moduleRoot, Owner: "acme/ui", }}) - withSources = moduleapi.WithSourceRegistry(withSources, sources) + ctx = moduleapi.WithSourceRegistry(ctx, sources) + + entry := registry.Entry{ + ID: registry.NewID("acme.ui", "static_fs"), + Registry: registry.EntryMetadata{Owner: "acme/ui"}, + } tests := []struct { - cfg *dirapi.Config - name string - module string - want string - sources bool + name string + cfg *dirapi.Config + want string }{ { - name: "module-owned entry with no base resolves against module source root", - module: "acme/ui", - sources: true, - cfg: &dirapi.Config{Directory: "./frontend"}, - want: filepath.Join(moduleRoot, "frontend"), + name: "module-owned entry with no base resolves against module source root", + cfg: &dirapi.Config{Directory: "./frontend"}, + want: filepath.Join(moduleRoot, "frontend"), + }, + { + name: "project base remains working-directory relative", + cfg: &dirapi.Config{Directory: "./frontend", Base: dirapi.BaseProject}, + want: "./frontend", }, { - name: "project base remains working-directory relative", - module: "acme/ui", - sources: true, - cfg: &dirapi.Config{Directory: "./frontend", Base: dirapi.BaseProject}, - want: "./frontend", + name: "module base resolves against module source root", + cfg: &dirapi.Config{Directory: "./static/app", Base: dirapi.BaseModule}, + want: filepath.Join(moduleRoot, "static/app"), }, { - name: "module base resolves against module source root", - module: "acme/ui", - sources: true, - cfg: &dirapi.Config{Directory: "./static/app", Base: dirapi.BaseModule}, - want: filepath.Join(moduleRoot, "static/app"), + name: "absolute module path is preserved", + cfg: &dirapi.Config{Directory: "/srv/static", Base: dirapi.BaseModule}, + want: "/srv/static", }, { - name: "absolute module path is preserved", - module: "acme/ui", - sources: true, - cfg: &dirapi.Config{Directory: "/srv/static", Base: dirapi.BaseModule}, - want: "/srv/static", + name: "module base without module metadata falls back to raw path", + cfg: &dirapi.Config{Directory: "./static/app", Base: dirapi.BaseModule}, + want: "./static/app", }, { - name: "module base without operation provenance falls back to raw path", - sources: true, - cfg: &dirapi.Config{Directory: "./static/app", Base: dirapi.BaseModule}, - want: "./static/app", + name: "app-owned entry (no module meta) with no base stays working-directory relative", + cfg: &dirapi.Config{Directory: "./frontend"}, + want: "./frontend", }, { - name: "host-authored entry with no base stays working-directory relative", - sources: true, - cfg: &dirapi.Config{Directory: "./frontend"}, - want: "./frontend", + name: "module-owned entry with explicit project base stays working-directory relative", + cfg: &dirapi.Config{Directory: "./frontend", Base: dirapi.BaseProject}, + want: "./frontend", }, { - name: "module-owned entry with no base falls back to raw path when source root missing", - module: "acme/ui", - cfg: &dirapi.Config{Directory: "./frontend"}, - want: "./frontend", + name: "module-owned entry with no base falls back to raw path when source root missing", + cfg: &dirapi.Config{Directory: "./frontend"}, + want: "./frontend", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := ctxapi.NewRootContext() - if tt.sources { - ctx = withSources - } - if tt.module != "" { - ctx = moduleOpContext(ctx, tt.module) + testEntry := entry + testCtx := ctx + + switch tt.name { + case "module base without module metadata falls back to raw path", + "app-owned entry (no module meta) with no base stays working-directory relative": + testEntry.Registry.Owner = "" + case "module-owned entry with no base falls back to raw path when source root missing": + testCtx = ctxapi.NewRootContext() } - assert.Equal(t, tt.want, resolveDirectoryPath(ctx, tt.cfg)) + assert.Equal(t, tt.want, resolveDirectoryPath(testCtx, testEntry, tt.cfg)) }) } } -// moduleOpContext builds the context a transition hands a listener for an -// operation owned by a module. -func moduleOpContext(ctx context.Context, module string) context.Context { - return registry.WithOpProvenance(ctx, registry.OpProvenance{ - Effective: ®istry.EntryProvenance{Module: module}, - }) -} - func TestManager_AddUsesModuleBaseForDirectoryPath(t *testing.T) { moduleRoot := t.TempDir() ctx := ctxapi.NewRootContext() @@ -587,14 +578,15 @@ func TestManager_AddUsesModuleBaseForDirectoryPath(t *testing.T) { sources.Set(moduleapi.Sources{"acme/ui": { LoadPath: moduleRoot, ResourceRoot: moduleRoot, Owner: "acme/ui", }}) - ctx = moduleOpContext(moduleapi.WithSourceRegistry(ctx, sources), "acme/ui") + ctx = moduleapi.WithSourceRegistry(ctx, sources) factory := NewMockFactory(&MockFS{}, nil) manager := NewDirectoryManager(eventbus.NewBus(), &MockTranscoder{}, factory, zap.NewNop()) entry := registry.Entry{ - ID: registry.NewID("acme.ui", "static_fs"), - Kind: dirapi.Kind, + ID: registry.NewID("acme.ui", "static_fs"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "acme/ui"}, Data: NewMockPayload(&dirapi.Config{ Directory: "./static/app", Base: dirapi.BaseModule, @@ -619,14 +611,15 @@ func TestManager_AddResolvesModuleRelativePathWhenBaseOmitted(t *testing.T) { sources.Set(moduleapi.Sources{"wippy/facade": { LoadPath: moduleRoot, ResourceRoot: moduleRoot, Owner: "wippy/facade", }}) - ctx = moduleOpContext(moduleapi.WithSourceRegistry(ctx, sources), "wippy/facade") + ctx = moduleapi.WithSourceRegistry(ctx, sources) factory := NewMockFactory(&MockFS{}, nil) manager := NewDirectoryManager(eventbus.NewBus(), &MockTranscoder{}, factory, zap.NewNop()) entry := registry.Entry{ - ID: registry.NewID("wippy.facade", "public_files"), - Kind: dirapi.Kind, + ID: registry.NewID("wippy.facade", "public_files"), + Kind: dirapi.Kind, + Registry: registry.EntryMetadata{Owner: "wippy/facade"}, Data: NewMockPayload(&dirapi.Config{ Directory: "./public", Mode: "0755", diff --git a/service/fs/embed/integration_test.go b/service/fs/embed/integration_test.go index 9725af56d..f54f64168 100644 --- a/service/fs/embed/integration_test.go +++ b/service/fs/embed/integration_test.go @@ -5,7 +5,6 @@ package embed import ( "context" "io" - "io/fs" "testing" "github.com/stretchr/testify/assert" @@ -17,43 +16,25 @@ import ( "go.uber.org/zap" ) -// TestLiveInstall_PackRegisteredResolvesForEntry reproduces the live hub-install -// path: a module pack is registered into the concrete Registry, then the embed -// Manager adds the module's fs.embed entry under the operation provenance the -// transition dispatches. The entry must resolve to the registered pack. -func TestLiveInstall_PackRegisteredResolvesForEntry(t *testing.T) { +func TestLiveInstall_RegisteredPackServesResource(t *testing.T) { reg := NewRegistry() reader := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "live"}) require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) manager := NewManager(eventbus.NewBus(), &mockDTT{}, reg, zap.NewNop()) - entry := registry.Entry{ ID: registry.NewID("ui", "app"), Kind: embedapi.Kind, Data: payload.New(&embedapi.Config{}), } - ctx := moduleOpContext(t, "org/mod", "1.0.0") - - require.NoError(t, manager.Add(ctx, entry)) - - fsys, err := manager.resolveFS(ctx, entry) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "index.html") - require.NoError(t, err) - assert.Equal(t, "live", string(data)) + require.NoError(t, manager.Add(context.Background(), entry)) + assert.Equal(t, "live", readManagerFile(t, manager, entry.ID, "index.html")) } -// TestLiveUpdate_NewVersionResolvesWhileOldStaged proves version-aware -// resolution during an update: both pack versions are registered simultaneously -// (as they are between Prepare and Commit), and each operation resolves to the -// version its own provenance names. -func TestLiveUpdate_NewVersionResolvesWhileOldStaged(t *testing.T) { +func TestLiveInstall_StagedResourceBecomesReadableAtCommit(t *testing.T) { reg := NewRegistry() - oldReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "1"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "2"}) - require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - require.NoError(t, reg.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) + reader := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "live"}) + require.NoError(t, reg.StagePack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) manager := NewManager(eventbus.NewBus(), &mockDTT{}, reg, zap.NewNop()) entry := registry.Entry{ @@ -61,35 +42,23 @@ func TestLiveUpdate_NewVersionResolvesWhileOldStaged(t *testing.T) { Kind: embedapi.Kind, Data: payload.New(&embedapi.Config{}), } - oldCtx := moduleOpContext(t, "org/mod", "1.0.0") - require.NoError(t, manager.Add(oldCtx, entry)) - assert.Equal(t, "1", readManagerFile(t, manager, entry.ID, "v.txt")) - - newCtx := moduleOpContext(t, "org/mod", "2.0.0") - require.NoError(t, manager.Update(newCtx, entry)) - assert.Equal(t, "2", readManagerFile(t, manager, entry.ID, "v.txt")) - - fsys, err := manager.resolveFS(newCtx, entry) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "v.txt") + // A transition listener can install and use the newly staged filesystem. + require.NoError(t, manager.Add(context.Background(), entry)) + manager.mu.RLock() + fsys := manager.filesystems[entry.ID] + manager.mu.RUnlock() + file, err := fsys.Open("index.html") require.NoError(t, err) - assert.Equal(t, "2", string(data)) + _ = file.Close() - // After the old pack is dropped on commit, resolution still works. - require.NoError(t, reg.UnregisterModule("org/mod", "1.0.0")) - fsys, err = manager.resolveFS(newCtx, entry) - require.NoError(t, err) - data, err = fs.ReadFile(fsys, "v.txt") - require.NoError(t, err) - assert.Equal(t, "2", string(data)) + require.NoError(t, reg.ActivatePack("org/mod-v1.0.0.wapp")) + assert.Equal(t, "live", readManagerFile(t, manager, entry.ID, "index.html")) } -// TestLiveUpdate_MissingNewVersionDoesNotSwapToOld proves update is strict about -// the version the operation provenance names. If the new pack was not staged, -// updating the fs.embed entry must fail and leave the live filesystem in place. -func TestLiveUpdate_MissingNewVersionDoesNotSwapToOld(t *testing.T) { +func TestLiveUpdate_ActiveResourceMovesAndRollsBack(t *testing.T) { reg := NewRegistry() oldReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "1"}) + newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "2"}) require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) manager := NewManager(eventbus.NewBus(), &mockDTT{}, reg, zap.NewNop()) @@ -98,51 +67,42 @@ func TestLiveUpdate_MissingNewVersionDoesNotSwapToOld(t *testing.T) { Kind: embedapi.Kind, Data: payload.New(&embedapi.Config{}), } - require.NoError(t, manager.Add(moduleOpContext(t, "org/mod", "1.0.0"), entry)) + require.NoError(t, manager.Add(context.Background(), entry)) assert.Equal(t, "1", readManagerFile(t, manager, entry.ID, "v.txt")) - newCtx := moduleOpContext(t, "org/mod", "2.0.0") - err := manager.Update(newCtx, entry) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get embedded filesystem") + // Existing service handles remain on the committed pack through Prepare. + require.NoError(t, reg.StagePack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) assert.Equal(t, "1", readManagerFile(t, manager, entry.ID, "v.txt")) + staged, err := reg.GetFS(entry.ID) + require.NoError(t, err) + stagedFile, err := staged.Open("v.txt") + require.NoError(t, err) + data, err := io.ReadAll(stagedFile) + require.NoError(t, err) + require.NoError(t, stagedFile.Close()) + assert.Equal(t, "2", string(data)) - _, err = manager.resolveFS(newCtx, entry) - require.Error(t, err) -} - -// TestUninstall_PackUnregisteredNoLongerResolves confirms that once a module pack -// is unregistered, its fs.embed entry no longer resolves. -func TestUninstall_PackUnregisteredNoLongerResolves(t *testing.T) { - reg := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "x"}) - require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) - - manager := NewManager(eventbus.NewBus(), &mockDTT{}, reg, zap.NewNop()) - entry := registry.Entry{ - ID: registry.NewID("ui", "app"), - Kind: embedapi.Kind, - Data: payload.New(&embedapi.Config{}), - } - ctx := moduleOpContext(t, "org/mod", "1.0.0") + // Commit switches the active resource. The existing service filesystem + // follows it even when the entry bytes did not change. + require.NoError(t, reg.ActivatePack("org/mod-v2.0.0.wapp")) + assert.Equal(t, "2", readManagerFile(t, manager, entry.ID, "v.txt")) - _, err := manager.resolveFS(ctx, entry) - require.NoError(t, err) + // A failed transition removes the staged pack and restores the prior pack. + require.NoError(t, reg.UnregisterPack("org/mod-v2.0.0.wapp")) + assert.Equal(t, "1", readManagerFile(t, manager, entry.ID, "v.txt")) + // A successful retry moves the active resource again; finalizing the old + // module leaves the active mapping untouched. + require.NoError(t, reg.StagePack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) + require.NoError(t, reg.ActivatePack("org/mod-v2.0.0.wapp")) require.NoError(t, reg.UnregisterModule("org/mod", "1.0.0")) - _, err = manager.resolveFS(ctx, entry) - require.Error(t, err) + assert.Equal(t, "2", readManagerFile(t, manager, entry.ID, "v.txt")) } -// TestIdenticalVersionBump_RetargetKeepsManagerFSAlive is the op-less update: the -// entry content does not change, so no listener runs and the manager keeps the -// filesystem resolved from the old pack. Retargeting the module before the old -// pack closes keeps that filesystem serving, now from the new pack. -func TestIdenticalVersionBump_RetargetKeepsManagerFSAlive(t *testing.T) { +func TestUninstall_RemovesActiveResource(t *testing.T) { reg := NewRegistry() - oldReader, oldFile := createReaderWithResourceFile(t, "ui", "app", map[string]string{"v.txt": "1"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "2"}) - require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, oldFile)) + reader := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "x"}) + require.NoError(t, reg.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) manager := NewManager(eventbus.NewBus(), &mockDTT{}, reg, zap.NewNop()) entry := registry.Entry{ @@ -150,31 +110,18 @@ func TestIdenticalVersionBump_RetargetKeepsManagerFSAlive(t *testing.T) { Kind: embedapi.Kind, Data: payload.New(&embedapi.Config{}), } - require.NoError(t, manager.Add(moduleOpContext(t, "org/mod", "1.0.0"), entry)) - assert.Equal(t, "1", readManagerFile(t, manager, entry.ID, "v.txt")) - - // The new pack is staged; the entry is byte-identical, so no event reaches - // the manager. - require.NoError(t, reg.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - require.NoError(t, reg.RetargetModule("org/mod", "1.0.0", "2.0.0")) + require.NoError(t, manager.Add(context.Background(), entry)) require.NoError(t, reg.UnregisterModule("org/mod", "1.0.0")) - assert.Equal(t, "2", readManagerFile(t, manager, entry.ID, "v.txt")) -} - -// moduleOpContext builds the context a transition hands a listener for an -// operation owned by a module. -func moduleOpContext(t *testing.T, module, version string) context.Context { - t.Helper() - - return registry.WithOpProvenance(context.Background(), registry.OpProvenance{ - Effective: ®istry.EntryProvenance{Module: module, Version: version}, - }) + manager.mu.RLock() + fsys := manager.filesystems[entry.ID] + manager.mu.RUnlock() + _, err := fsys.Open("index.html") + require.Error(t, err) } func readManagerFile(t *testing.T, manager *Manager, id registry.ID, name string) string { t.Helper() - manager.mu.RLock() fsys := manager.filesystems[id] manager.mu.RUnlock() @@ -183,7 +130,6 @@ func readManagerFile(t *testing.T, manager *Manager, id registry.ID, name string file, err := fsys.Open(name) require.NoError(t, err) defer func() { _ = file.Close() }() - data, err := io.ReadAll(file) require.NoError(t, err) return string(data) diff --git a/service/fs/embed/manager.go b/service/fs/embed/manager.go index a6ff8c7f7..96b195366 100644 --- a/service/fs/embed/manager.go +++ b/service/fs/embed/manager.go @@ -4,7 +4,6 @@ package embed import ( "context" - "io/fs" "sync" "github.com/wippyai/runtime/api/event" @@ -81,7 +80,7 @@ func (m *Manager) Update(ctx context.Context, entry registry.Entry) error { return systemfs.NewFilesystemNotFoundError(entry.ID.String()) } - nextFS, err := m.fsForEntry(ctx, entry) + nextFS, err := m.fsForEntry(entry) if err != nil { return err } @@ -112,12 +111,9 @@ func (m *Manager) Delete(ctx context.Context, entry registry.Entry) error { return nil } -// registerFS retrieves the filesystem from the embed registry and registers it. -// Resolution is provenance-aware when the registry supports it, so updates -// select the pack of the module version the operation carries rather than an -// arbitrary pack that happens to expose the same resource ID. +// registerFS retrieves the active filesystem resource and registers it. func (m *Manager) registerFS(ctx context.Context, entry registry.Entry) error { - fs, err := m.fsForEntry(ctx, entry) + fs, err := m.fsForEntry(entry) if err != nil { return err } @@ -125,8 +121,8 @@ func (m *Manager) registerFS(ctx context.Context, entry registry.Entry) error { return nil } -func (m *Manager) fsForEntry(ctx context.Context, entry registry.Entry) (fsapi.FS, error) { - packFS, err := m.resolveFS(ctx, entry) +func (m *Manager) fsForEntry(entry registry.Entry) (fsapi.FS, error) { + packFS, err := m.embedReg.GetFS(entry.ID) if err != nil { m.log.Error("failed to get embedded filesystem", zap.String("id", entry.ID.String()), @@ -148,29 +144,6 @@ func (m *Manager) storeFS(ctx context.Context, id registry.ID, fs fsapi.FS) { }) } -// resolveFS prefers a provenance-aware lookup when the registry implements -// embedapi.EntryResolver, otherwise falls back to ID-based lookup. The -// provenance is the effective half of the operation the listener is handling; -// host entries and callers outside a transition supply none, which resolves by -// entry ID. -func (m *Manager) resolveFS(ctx context.Context, entry registry.Entry) (fs.ReadDirFS, error) { - if resolver, ok := m.embedReg.(embedapi.EntryResolver); ok { - return resolver.GetFSForEntry(entry, effectiveProvenance(ctx)) - } - return m.embedReg.GetFS(entry.ID) -} - -// effectiveProvenance returns the provenance of the entry the dispatched -// operation carries, or nil when the caller is not a provenance-carrying -// transition. -func effectiveProvenance(ctx context.Context) *registry.EntryProvenance { - op, ok := registry.OpProvenanceFromContext(ctx) - if !ok { - return nil - } - return op.Effective -} - // removeFS removes the filesystem from the fs system. func (m *Manager) removeFS(ctx context.Context, id registry.ID) { m.bus.Send(ctx, event.Event{ diff --git a/service/fs/embed/manager_test.go b/service/fs/embed/manager_test.go index 0a84dcfb9..30689c899 100644 --- a/service/fs/embed/manager_test.go +++ b/service/fs/embed/manager_test.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" eventapi "github.com/wippyai/runtime/api/event" - fsapi "github.com/wippyai/runtime/api/fs" "github.com/wippyai/runtime/api/payload" "github.com/wippyai/runtime/api/registry" embedapi "github.com/wippyai/runtime/api/service/fs/embed" @@ -191,13 +190,9 @@ func TestManager_Update_NotFound(t *testing.T) { } func TestManager_Update_ResolutionFailureKeepsExistingFS(t *testing.T) { + ctx := context.Background() bus := &recordingBus{} - embedReg := &mockEntryResolverRegistry{ - mockEmbedRegistry: mockEmbedRegistry{}, - byEntry: map[string]fs.ReadDirFS{ - "test:fs|org/mod|1.0.0": &mockReadDirFS{}, - }, - } + embedReg := &mockEmbedRegistry{filesystems: map[string]fs.ReadDirFS{"test:fs": &mockReadDirFS{}}} dtt := &mockDTT{} manager := NewManager(bus, dtt, embedReg, zap.NewNop()) @@ -206,10 +201,11 @@ func TestManager_Update_ResolutionFailureKeepsExistingFS(t *testing.T) { Kind: embedapi.Kind, Data: payload.New(&embedapi.Config{}), } - require.NoError(t, manager.Add(opContext("org/mod", "1.0.0"), entry)) + require.NoError(t, manager.Add(ctx, entry)) bus.reset() - err := manager.Update(opContext("org/mod", "2.0.0"), entry) + embedReg.filesystems = nil + err := manager.Update(ctx, entry) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get embedded filesystem") @@ -217,7 +213,6 @@ func TestManager_Update_ResolutionFailureKeepsExistingFS(t *testing.T) { _, ok := manager.filesystems[entry.ID] manager.mu.RUnlock() assert.True(t, ok, "failed update must keep the current live filesystem") - assert.Equal(t, "test:fs|org/mod|2.0.0", embedReg.lastEntryKey) assert.Empty(t, bus.events, "failed update must not send fs delete/register events") } @@ -274,98 +269,6 @@ func TestManager_Delete_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } -func TestManager_Add_UsesEntryResolver(t *testing.T) { - bus := eventbus.NewBus() - embedReg := &mockEntryResolverRegistry{ - mockEmbedRegistry: mockEmbedRegistry{ - filesystems: map[string]fs.ReadDirFS{ - "test:fs": &mockReadDirFS{}, - }, - }, - byEntry: map[string]fs.ReadDirFS{ - "test:fs|org/mod|2.0.0": &mockReadDirFS{}, - }, - } - dtt := &mockDTT{} - - manager := NewManager(bus, dtt, embedReg, zap.NewNop()) - - entry := registry.Entry{ - ID: registry.NewID("test", "fs"), - Kind: embedapi.Kind, - Data: payload.New(&embedapi.Config{}), - } - - require.NoError(t, manager.Add(opContext("org/mod", "2.0.0"), entry)) - assert.Equal(t, 1, embedReg.entryCalls) - assert.Equal(t, 0, embedReg.idCalls) - assert.Equal(t, "test:fs|org/mod|2.0.0", embedReg.lastEntryKey) -} - -// A host entry arrives without provenance and resolves by ID, the same path -// a caller outside a transition takes. -func TestManager_Add_HostEntryResolvesWithoutProvenance(t *testing.T) { - embedReg := &mockEntryResolverRegistry{ - mockEmbedRegistry: mockEmbedRegistry{ - filesystems: map[string]fs.ReadDirFS{"test:fs": &mockReadDirFS{}}, - }, - } - - manager := NewManager(eventbus.NewBus(), &mockDTT{}, embedReg, zap.NewNop()) - - entry := registry.Entry{ - ID: registry.NewID("test", "fs"), - Kind: embedapi.Kind, - Data: payload.New(&embedapi.Config{}), - } - - require.NoError(t, manager.Add(context.Background(), entry)) - assert.Equal(t, 1, embedReg.entryCalls) - assert.Equal(t, "test:fs||", embedReg.lastEntryKey) -} - -func TestManager_Update_SelectsNewVersionViaResolver(t *testing.T) { - bus := &recordingBus{} - oldFS := &mockReadDirFS{} - newFS := &mockReadDirFS{} - embedReg := &mockEntryResolverRegistry{ - mockEmbedRegistry: mockEmbedRegistry{ - filesystems: map[string]fs.ReadDirFS{"test:fs": oldFS}, - }, - byEntry: map[string]fs.ReadDirFS{ - "test:fs|org/mod|1.0.0": oldFS, - "test:fs|org/mod|2.0.0": newFS, - }, - } - dtt := &mockDTT{} - - manager := NewManager(bus, dtt, embedReg, zap.NewNop()) - - addEntry := registry.Entry{ - ID: registry.NewID("test", "fs"), - Kind: embedapi.Kind, - Data: payload.New(&embedapi.Config{}), - } - require.NoError(t, manager.Add(opContext("org/mod", "1.0.0"), addEntry)) - bus.reset() - - updateEntry := addEntry - require.NoError(t, manager.Update(opContext("org/mod", "2.0.0"), updateEntry)) - - manager.mu.RLock() - stored := manager.filesystems[updateEntry.ID] - manager.mu.RUnlock() - // The stored FS is wrapped; assert resolver was asked for the new version. - require.NotNil(t, stored) - assert.Equal(t, "test:fs|org/mod|2.0.0", embedReg.lastEntryKey) - require.Len(t, bus.events, 2) - assert.Equal(t, fsapi.FsDelete, bus.events[0].Kind) - assert.Equal(t, updateEntry.ID.String(), bus.events[0].Path) - assert.Equal(t, fsapi.FsRegister, bus.events[1].Kind) - assert.Equal(t, updateEntry.ID.String(), bus.events[1].Path) - assert.NotNil(t, bus.events[1].Data) -} - // Mock implementations type recordingBus struct { @@ -414,52 +317,6 @@ func (r *mockEmbedRegistry) Register(_ string, _ any) error { return nil } -// mockEntryResolverRegistry implements embedapi.EntryResolver to verify the -// manager prefers entry-aware resolution. -type mockEntryResolverRegistry struct { - mockEmbedRegistry - byEntry map[string]fs.ReadDirFS - lastEntryKey string - entryCalls int - idCalls int -} - -func entryResolverKey(entry registry.Entry, prov *registry.EntryProvenance) string { - module := "" - version := "" - if prov != nil { - module = prov.Module - version = prov.Version - } - return entry.ID.String() + "|" + module + "|" + version -} - -// opContext builds the context a transition hands a listener for an operation -// owned by a module. -func opContext(module, version string) context.Context { - return registry.WithOpProvenance(context.Background(), registry.OpProvenance{ - Effective: ®istry.EntryProvenance{Module: module, Version: version}, - }) -} - -func (r *mockEntryResolverRegistry) GetFSForEntry( - entry registry.Entry, - prov *registry.EntryProvenance, -) (fs.ReadDirFS, error) { - r.entryCalls++ - key := entryResolverKey(entry, prov) - r.lastEntryKey = key - if fsys, ok := r.byEntry[key]; ok { - return fsys, nil - } - return r.mockEmbedRegistry.GetFS(entry.ID) -} - -func (r *mockEntryResolverRegistry) GetFS(id registry.ID) (fs.ReadDirFS, error) { - r.idCalls++ - return r.mockEmbedRegistry.GetFS(id) -} - type mockDTT struct { unmarshalErr error } diff --git a/service/fs/embed/registry.go b/service/fs/embed/registry.go index 486a63558..568d9e173 100644 --- a/service/fs/embed/registry.go +++ b/service/fs/embed/registry.go @@ -4,7 +4,6 @@ package embed import ( "context" - "fmt" "io" "io/fs" "os" @@ -17,31 +16,24 @@ import ( "github.com/wippyai/wapp" ) -// pack holds a single registered .wapp reader together with the resources it -// owns and the optional file handle that backs it. The handle is owned by the -// registry and reference counted: registration holds one reference, every -// generation serving the pack holds one, and every read in flight holds one. -// The file closes when the last reference goes away, so a read can never touch -// a closed handle. +// pack is one opened WAPP. The registry retains it while it is active; reads +// retain it independently, so retiring a pack never closes a file in use. type pack struct { reader *wapp.Reader file *os.File resources map[registry.ID]struct{} - packPath string + path string module string version string + active atomic.Bool refs atomic.Int64 } -// owns reports whether the pack exposes the given resource ID. func (p *pack) owns(id registry.ID) bool { _, ok := p.resources[id] return ok } -// retain takes a reference. It fails once the pack is closed, which is what -// makes a read against a retired generation fall through to the current one -// instead of reading a closed file. func (p *pack) retain() bool { for { refs := p.refs.Load() @@ -54,9 +46,6 @@ func (p *pack) retain() bool { } } -// release drops a reference and closes the file handle on the last one. The -// close is never waited on by another goroutine, so releasing cannot deadlock; -// a close deferred behind reads in flight reports its error to nobody. func (p *pack) release() error { if p.refs.Add(-1) != 0 { return nil @@ -64,528 +53,379 @@ func (p *pack) release() error { return closeFile(p.file) } -// generation binds one pack to the entries served from it. The file map is -// immutable once published, so readers hold no lock; serving a new entry -// publishes a copy that carries the additional entry. -type generation struct { - pack *pack - files map[registry.ID]fs.ReadDirFS -} - -// version reports the pack version this generation serves. -func (g *generation) version() string { - return g.pack.version +// Registry owns opened WAPPs and the active resource mapping. Resource IDs +// are the sole filesystem identity. Activating a staged pack switches each ID +// it carries; removing it restores the preceding active pack, if any. +// +// Module and version identify a pack only for dependency lifecycle cleanup. +// They are not consulted when resolving a filesystem. +type Registry struct { + packs map[string]*pack + resources map[registry.ID][]*pack + staged map[registry.ID][]*pack + mu sync.RWMutex } -// with returns a generation of the same pack serving one more entry. -func (g *generation) with(id registry.ID, fsys fs.ReadDirFS) *generation { - files := make(map[registry.ID]fs.ReadDirFS, len(g.files)+1) - for served, servedFS := range g.files { - files[served] = servedFS +func NewRegistry() *Registry { + return &Registry{ + packs: make(map[string]*pack), + resources: make(map[registry.ID][]*pack), + staged: make(map[registry.ID][]*pack), } - files[id] = fsys - return &generation{pack: g.pack, files: files} -} - -// lineage is the generation pointer shared by every filesystem the registry -// served from one module version. Retargeting is a single store on it: all of -// that version's filesystems change pack together, with no per-filesystem -// bookkeeping and no registry-owned reference to any of them. -type lineage struct { - current atomic.Pointer[generation] } -func newLineage(gen *generation) *lineage { - l := &lineage{} - l.current.Store(gen) - return l +// Register adds a pack that is not managed by a dependency operation. +func (r *Registry) Register(path string, reader *wapp.Reader, file *os.File) error { + return r.RegisterPack(path, "", "", reader, file) } -// acquire returns the current generation holding a reference on its pack. The -// caller must release it when its read finishes. A generation whose pack has -// already closed is skipped in favor of the generation that replaced it. -func (l *lineage) acquire() (*generation, bool) { - for { - gen := l.current.Load() - if gen == nil { - return nil, false - } - if gen.pack.retain() { - return gen, true - } - if l.current.Load() == gen { - return nil, false - } - } +// RegisterPack opens and activates a pack. Boot uses this after the selected +// pack set has been resolved. +func (r *Registry) RegisterPack(path, module, version string, reader *wapp.Reader, file *os.File) error { + return r.registerPack(path, module, version, reader, file, true) } -// entryFS is the filesystem handed to a consumer for one entry. It resolves -// through its lineage, so a retarget reaches it without the registry holding a -// reference to it. A read costs one atomic load and one map lookup. -type entryFS struct { - lineage *lineage - id registry.ID +// StagePack opens a pack without changing any active resource. Dependency +// effects call it during Prepare, before the registry transition is committed. +func (r *Registry) StagePack(path, module, version string, reader *wapp.Reader, file *os.File) error { + return r.registerPack(path, module, version, reader, file, false) } -func (f *entryFS) Open(name string) (fs.File, error) { - gen, ok := f.lineage.acquire() - if !ok { - return nil, f.notFound() - } - - fsys, ok := gen.files[f.id] - if !ok { - _ = gen.pack.release() - return nil, f.notFound() +func (r *Registry) registerPack(path, module, version string, reader *wapp.Reader, file *os.File, activate bool) error { + if path == "" { + return systemfs.NewEmptyPackPathError() } - - file, err := fsys.Open(name) - if err != nil { - _ = gen.pack.release() - return nil, err + if reader == nil { + return systemfs.NewNilReaderError() } - // Pack data is read lazily, so the reference travels with the handle. - return &refFile{file: file, pack: gen.pack, name: name}, nil -} - -func (f *entryFS) ReadDir(name string) ([]fs.DirEntry, error) { - gen, ok := f.lineage.acquire() - if !ok { - return nil, f.notFound() + resources := make(map[registry.ID]struct{}) + for _, resource := range reader.ListResources() { + resources[registry.NewID(resource.ID.Namespace, resource.ID.Name)] = struct{}{} } - defer func() { _ = gen.pack.release() }() - - fsys, ok := gen.files[f.id] - if !ok { - return nil, f.notFound() + next := &pack{ + reader: reader, + file: file, + resources: resources, + path: path, + module: module, + version: version, } - return fsys.ReadDir(name) -} - -func (f *entryFS) notFound() error { - return systemfs.NewFilesystemNotFoundWithCauseError(f.id.String(), fs.ErrNotExist) -} - -// refFile holds its pack reference until the file is closed. A consumer that -// never closes a file keeps that pack's handle open, the same contract an -// *os.File carries. -type refFile struct { - file fs.File - pack *pack - name string - released atomic.Bool -} - -func (f *refFile) Read(p []byte) (int, error) { - return f.file.Read(p) -} - -func (f *refFile) Stat() (fs.FileInfo, error) { - return f.file.Stat() -} + next.refs.Store(1) + next.active.Store(activate) -func (f *refFile) Close() error { - err := f.file.Close() - if f.released.CompareAndSwap(false, true) { - _ = f.pack.release() - } - return err -} + r.mu.Lock() + defer r.mu.Unlock() -// Seek forwards to a seekable pack file and reports the same unsupported-file -// error the standard library's fs adapters use otherwise. -func (f *refFile) Seek(offset int64, whence int) (int64, error) { - seeker, ok := f.file.(io.Seeker) - if !ok { - return 0, &fs.PathError{Op: "seek", Path: f.name, Err: fs.ErrInvalid} + if r.packs[path] != nil { + return systemfs.NewFilesystemAlreadyExistsError(path) } - return seeker.Seek(offset, whence) -} - -// ReadDir forwards to a pack directory handle. -func (f *refFile) ReadDir(n int) ([]fs.DirEntry, error) { - dir, ok := f.file.(fs.ReadDirFile) - if !ok { - return nil, &fs.PathError{Op: "readdir", Path: f.name, Err: fs.ErrInvalid} + if err := r.validateResourcesLocked(path, module, resources); err != nil { + return err } - return dir.ReadDir(n) -} - -// Registry implements embedapi.Registry by storing per-pack readers. -// -// Packs are keyed by their pack path (the cached .wapp location at runtime, or -// a synthetic key at boot). Each pack records its owning module and version so -// a specific (module, version) pack can be located and replaced independently — -// this lets module updates stage a new pack while the old one keeps serving -// until commit, and lets uninstall close exactly the pack it removed. -// -// Every filesystem the registry hands out reads through the lineage of the pack -// that served it. The registry keeps the lineages, never the filesystems, so -// retargeting reaches served filesystems without retaining them. -type Registry struct { - packs map[string]*pack - // lineages holds the lineages currently serving each pack. - lineages map[*pack][]*lineage - mu sync.RWMutex -} - -// NewRegistry creates a new embed registry. -func NewRegistry() *Registry { - return &Registry{ - packs: make(map[string]*pack), - lineages: make(map[*pack][]*lineage), + r.packs[path] = next + if activate { + for id := range resources { + r.resources[id] = append(r.resources[id], next) + } + } else { + for id := range resources { + r.staged[id] = append(r.staged[id], next) + } } + return nil } -// Register adds a pack reader to the registry without module ownership. -// Retained for callers that have no module identity. The pack path is used as -// the lookup key; if a file handle is provided it is owned by the registry and -// closed on unregister/close. -func (r *Registry) Register(packPath string, reader *wapp.Reader, file *os.File) error { - return r.RegisterPack(packPath, "", "", reader, file) +// ActivatePack makes a staged pack the active source for each resource it +// carries. It is called by Effect.Commit after registry listeners accepted the +// corresponding transition. +func (r *Registry) ActivatePack(path string) error { + return r.ActivatePacks([]string{path}) } -// RegisterPack adds a pack reader tagged with its owning module and version. -// Registering a pack path that already exists retires the previous pack: its -// filesystems stop resolving and its handle closes once the reads in flight -// against it finish. -func (r *Registry) RegisterPack(packPath, module, version string, reader *wapp.Reader, file *os.File) error { - if packPath == "" { - return systemfs.NewEmptyPackPathError() - } - if reader == nil { - return systemfs.NewNilReaderError() - } - - resources := make(map[registry.ID]struct{}) - for _, res := range reader.ListResources() { - resources[registry.NewID(res.ID.Namespace, res.ID.Name)] = struct{}{} - } - +// ActivatePacks switches a prepared pack set under one lock. Effects use this +// to avoid exposing a partial module transition. +func (r *Registry) ActivatePacks(paths []string) error { r.mu.Lock() defer r.mu.Unlock() - var replaceErr error - if existing, ok := r.packs[packPath]; ok { - replaceErr = r.removePackLocked(existing) - } - // One pack per (module, version): a retarget moves ONE lineage set, so a - // second pack at the same identity would leave readers split across packs. - // Registering the same identity again retires the previous pack. - if module != "" { - for _, existing := range r.packs { - if existing.module == module && existing.version == version { - if err := r.removePackLocked(existing); err != nil && replaceErr == nil { - replaceErr = err - } - } + packs := make([]*pack, 0, len(paths)) + seen := make(map[string]struct{}, len(paths)) + for _, path := range paths { + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + current := r.packs[path] + if current == nil { + return systemfs.NewFilesystemNotFoundError(path) + } + if current.active.Load() { + continue } + if err := r.validateResourcesLocked(path, current.module, current.resources); err != nil { + return err + } + packs = append(packs, current) } - - next := &pack{ - packPath: packPath, - module: module, - version: version, - reader: reader, - file: file, - resources: resources, + for _, current := range packs { + for id := range current.resources { + r.removeStagedLocked(id, current) + r.resources[id] = append(r.resources[id], current) + } + current.active.Store(true) } - next.refs.Store(1) - r.packs[packPath] = next + return nil +} - return replaceErr +func (r *Registry) validateResourcesLocked(path, module string, resources map[registry.ID]struct{}) error { + for id := range resources { + if staged := r.stagedPackLocked(id); staged != nil && staged.path != path { + return systemfs.NewEmbeddedResourceConflictError(id.String(), staged.path, path) + } + active := r.activePackLocked(id) + if active == nil || active.path == path || (module != "" && active.module == module) { + continue + } + return systemfs.NewEmbeddedResourceConflictError(id.String(), active.path, path) + } + return nil } -// UnregisterPack removes the pack registered under packPath and releases its -// file handle. Unregistering an unknown pack path is a no-op. -func (r *Registry) UnregisterPack(packPath string) error { +// UnregisterPack retires a pack. Any resource it was actively serving moves +// back to the preceding active pack; unrelated resource mappings are intact. +func (r *Registry) UnregisterPack(path string) error { r.mu.Lock() defer r.mu.Unlock() - - p, ok := r.packs[packPath] - if !ok { - return nil + if current := r.packs[path]; current != nil { + return r.removePackLocked(current) } - return r.removePackLocked(p) + return nil } -// UnregisterModule removes every pack owned by the given module and version, -// releasing their file handles. Unregistering a module that is not present is a -// no-op. Returns the first close error encountered, if any. +// UnregisterModule retires the exact module version after a successful +// dependency transition. It does not participate in resource selection. func (r *Registry) UnregisterModule(module, version string) error { r.mu.Lock() defer r.mu.Unlock() var firstErr error - for _, p := range r.packs { - if p.module != module || p.version != version { + for _, current := range r.packs { + if current.module != module || current.version != version { continue } - if err := r.removePackLocked(p); err != nil && firstErr == nil { + if err := r.removePackLocked(current); err != nil && firstErr == nil { firstErr = err } } return firstErr } -// removePackLocked drops a pack from the registry: the lineages still serving -// it stop resolving, and the registration reference is released. The handle -// closes here when nothing reads it, and behind the last read otherwise. -func (r *Registry) removePackLocked(p *pack) error { - delete(r.packs, p.packPath) - - for _, l := range r.lineages[p] { - if gen := l.current.Swap(nil); gen != nil { - _ = gen.pack.release() - } - } - delete(r.lineages, p) - - return p.release() -} - -// HasModulePack reports whether a pack for the exact module and version is -// currently registered. func (r *Registry) HasModulePack(module, version string) bool { r.mu.RLock() defer r.mu.RUnlock() - - return r.hasModulePackLocked(module, version) + for _, current := range r.packs { + if current.active.Load() && current.module == module && current.version == version { + return true + } + } + return false } -// GetFS implements embedapi.Registry.GetFS. -// It searches all registered packs for a resource with the given ID and returns -// the first match. Use GetFSForEntry when a specific module version must be -// selected (e.g. during an update where two versions expose the same ID). +// GetFS returns a stable resource handle. A handle issued while a pack is +// staged reads that pack for the enclosing transition; existing handles remain +// on the active pack until Commit. After Commit, both use the active mapping. func (r *Registry) GetFS(id registry.ID) (fs.ReadDirFS, error) { - r.mu.Lock() - defer r.mu.Unlock() - - for _, p := range r.packs { - if !p.owns(id) { - continue - } - fsys, err := r.serveLocked(p, id) - if err == nil { - return fsys, nil - } + r.mu.RLock() + staged := r.stagedPackLocked(id) + found := staged != nil || r.activePackLocked(id) != nil + r.mu.RUnlock() + if !found { + return nil, systemfs.NewFilesystemNotFoundWithCauseError(id.String(), fs.ErrNotExist) } - - return nil, systemfs.NewFilesystemNotFoundWithCauseError(id.String(), fs.ErrNotExist) + return resourceFS{registry: r, id: id, staged: staged}, nil } -// GetFSForEntry resolves the filesystem for an entry, preferring the pack that -// owns the module and version the operation's provenance names. This is -// deterministic across updates: while a new pack is staged alongside the old -// one, the operation's own provenance selects the correct version. Entries with -// no provenance, or host-authored provenance, fall back to GetFS. Versioned -// module entries require their exact pack to be registered so updates never -// silently serve an older pack. -func (r *Registry) GetFSForEntry(entry registry.Entry, prov *registry.EntryProvenance) (fs.ReadDirFS, error) { - if prov != nil && prov.Module != "" { - fsys, found, err := r.getFSForModuleEntry(entry.ID, prov.Module, prov.Version) - if found { - return fsys, err - } - if prov.Version != "" { - return nil, systemfs.NewFilesystemNotFoundWithCauseError(entry.ID.String(), fs.ErrNotExist) - } +func (r *Registry) activePackLocked(id registry.ID) *pack { + packs := r.resources[id] + if len(packs) == 0 { + return nil } - - return r.GetFS(entry.ID) + return packs[len(packs)-1] } -// getFSForModuleEntry resolves an entry against the module's packs. An empty -// version accepts any pack of the module. -func (r *Registry) getFSForModuleEntry(id registry.ID, module, version string) (fs.ReadDirFS, bool, error) { - r.mu.Lock() - defer r.mu.Unlock() - - for _, p := range r.packs { - if p.module != module { - continue - } - if version != "" && p.version != version { - continue - } - if !p.owns(id) { - continue - } - fsys, err := r.serveLocked(p, id) - return fsys, true, err +func (r *Registry) stagedPackLocked(id registry.ID) *pack { + packs := r.staged[id] + if len(packs) == 0 { + return nil } - - return nil, false, nil + return packs[len(packs)-1] } -// serveLocked returns the filesystem for one entry of a pack, publishing the -// entry into the pack's lineage so a later retarget carries it along. -func (r *Registry) serveLocked(p *pack, id registry.ID) (fs.ReadDirFS, error) { - l, err := r.lineageForLocked(p) - if err != nil { - return nil, err +func (r *Registry) acquire(id registry.ID) (*pack, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + current := r.activePackLocked(id) + if current == nil { + return nil, false } + return current, current.retain() +} - gen := l.current.Load() - if _, ok := gen.files[id]; !ok { - fsys, err := p.reader.GetFS(wapp.NewID(id.NS, id.Name)) - if err != nil { - return nil, err +func (r *Registry) removePackLocked(current *pack) error { + if r.packs[current.path] != current { + return nil + } + delete(r.packs, current.path) + if current.active.Load() { + for id := range current.resources { + packs := r.resources[id] + for i, candidate := range packs { + if candidate != current { + continue + } + packs = append(packs[:i], packs[i+1:]...) + break + } + if len(packs) == 0 { + delete(r.resources, id) + } else { + r.resources[id] = packs + } + } + } else { + for id := range current.resources { + r.removeStagedLocked(id, current) } - // The next generation serves the same pack, so the reference the - // retired generation held carries over unchanged. - l.current.Store(gen.with(id, fsys)) } - - return &entryFS{lineage: l, id: id}, nil + current.active.Store(false) + return current.release() } -// lineageForLocked returns the lineage new entries of a pack are served from, -// creating it on first use. -func (r *Registry) lineageForLocked(p *pack) (*lineage, error) { - if existing := r.lineages[p]; len(existing) > 0 { - return existing[0], nil +func (r *Registry) removeStagedLocked(id registry.ID, current *pack) { + packs := r.staged[id] + for i, candidate := range packs { + if candidate != current { + continue + } + packs = append(packs[:i], packs[i+1:]...) + break } - - if !p.retain() { - return nil, systemfs.NewModulePackNotFoundError(p.module, p.version) + if len(packs) == 0 { + delete(r.staged, id) + return } - l := newLineage(&generation{pack: p, files: make(map[registry.ID]fs.ReadDirFS)}) - r.lineages[p] = []*lineage{l} - return l, nil + r.staged[id] = packs } -// RetargetModule repoints every filesystem served from the module's fromVersion -// pack at its toVersion pack. It is the path for an identical-content version -// bump: entries whose content did not change receive no event, so nothing -// re-resolves them, yet Finalize retires the old pack. Calling this before the -// old pack closes keeps those filesystems reading. -// -// Each affected lineage moves in a single store, and every entry is resolved -// against the new pack before any store happens, so a new pack missing an entry -// retargets nothing and reports the failure — the caller must then keep the old -// pack. Having nothing to retarget is a no-op, including for modules that never -// had a pack. -func (r *Registry) RetargetModule(module, fromVersion, toVersion string) error { - if module == "" { - return systemfs.NewEmptyModuleError() - } - if fromVersion == toVersion { - return nil - } - +func (r *Registry) Close() error { r.mu.Lock() defer r.mu.Unlock() - stale := make(map[*pack][]*lineage) - for p, lineages := range r.lineages { - if p.module == module && p.version == fromVersion { - stale[p] = lineages + var firstErr error + for _, current := range r.packs { + if err := r.removePackLocked(current); err != nil && firstErr == nil { + firstErr = err } } - if len(stale) == 0 { - return nil - } + return firstErr +} - target := r.packForVersionLocked(module, toVersion) - if target == nil { - return systemfs.NewModulePackNotFoundError(module, toVersion) - } +type resourceFS struct { + registry *Registry + staged *pack + id registry.ID +} - type retarget struct { - lineage *lineage - next *generation +func (f resourceFS) Open(name string) (fs.File, error) { + current, ok := f.acquire() + if !ok { + return nil, f.notFound() } - var pending []retarget - for _, lineages := range stale { - for _, l := range lineages { - gen := l.current.Load() - if gen == nil { - continue - } - files := make(map[registry.ID]fs.ReadDirFS, len(gen.files)) - for id := range gen.files { - if !target.owns(id) { - return systemfs.NewFilesystemNotFoundWithCauseError(id.String(), fs.ErrNotExist) - } - fsys, err := target.reader.GetFS(wapp.NewID(id.NS, id.Name)) - if err != nil { - return err - } - files[id] = fsys - } - pending = append(pending, retarget{lineage: l, next: &generation{pack: target, files: files}}) - } + fsys, err := current.reader.GetFS(wapp.NewID(f.id.NS, f.id.Name)) + if err != nil { + _ = current.release() + return nil, err } + file, err := fsys.Open(name) + if err != nil { + _ = current.release() + return nil, err + } + return &retainedFile{File: file, pack: current}, nil +} - // Every new generation holds its own reference. The target is registered, - // so its registration reference is alive under this lock and the count can - // be taken outright, before the first store: publishing cannot fail halfway. - target.refs.Add(int64(len(pending))) - for _, entry := range pending { - if previous := entry.lineage.current.Swap(entry.next); previous != nil { - _ = previous.pack.release() - } +func (f resourceFS) ReadDir(name string) ([]fs.DirEntry, error) { + current, ok := f.acquire() + if !ok { + return nil, f.notFound() } + defer func() { _ = current.release() }() - for p, lineages := range stale { - r.lineages[target] = append(r.lineages[target], lineages...) - delete(r.lineages, p) + fsys, err := current.reader.GetFS(wapp.NewID(f.id.NS, f.id.Name)) + if err != nil { + return nil, err } - return nil + return fsys.ReadDir(name) } -func (r *Registry) packForVersionLocked(module, version string) *pack { - for _, p := range r.packs { - if p.module == module && p.version == version { - return p - } +func (f resourceFS) acquire() (*pack, bool) { + if f.staged != nil && !f.staged.active.Load() && f.staged.retain() { + return f.staged, true } - return nil + return f.registry.acquire(f.id) } -func (r *Registry) hasModulePackLocked(module, version string) bool { - return r.packForVersionLocked(module, version) != nil +func (f resourceFS) notFound() error { + return systemfs.NewFilesystemNotFoundWithCauseError(f.id.String(), fs.ErrNotExist) } -// Close implements embedapi.Registry.Close. -func (r *Registry) Close() error { - r.mu.Lock() - defer r.mu.Unlock() +type retainedFile struct { + fs.File + pack *pack + released atomic.Bool +} - var errs []error - for _, p := range r.packs { - if err := r.removePackLocked(p); err != nil { - errs = append(errs, err) - } +func (f *retainedFile) Close() error { + err := f.File.Close() + if f.released.CompareAndSwap(false, true) { + _ = f.pack.release() } - r.packs = make(map[string]*pack) - r.lineages = make(map[*pack][]*lineage) + return err +} - if len(errs) > 0 { - return fmt.Errorf("failed to close %d file(s)", len(errs)) +func (f *retainedFile) Seek(offset int64, whence int) (int64, error) { + seeker, ok := f.File.(io.Seeker) + if !ok { + return 0, &fs.PathError{Op: "seek", Err: fs.ErrInvalid} } - return nil + return seeker.Seek(offset, whence) +} + +func (f *retainedFile) ReadDir(n int) ([]fs.DirEntry, error) { + dir, ok := f.File.(fs.ReadDirFile) + if !ok { + return nil, &fs.PathError{Op: "readdir", Err: fs.ErrInvalid} + } + return dir.ReadDir(n) } -func closeFile(f *os.File) error { - if f == nil { +func closeFile(file *os.File) error { + if file == nil { return nil } - return f.Close() + return file.Close() } // GetRegistryFromContext retrieves the concrete Registry from context. -// Returns nil if not found or if the registry is a different implementation. func GetRegistryFromContext(ctx context.Context) *Registry { reg := embedapi.GetRegistry(ctx) if reg == nil { return nil } - if r, ok := reg.(*Registry); ok { - return r + if current, ok := reg.(*Registry); ok { + return current } return nil } diff --git a/service/fs/embed/registry_test.go b/service/fs/embed/registry_test.go index db434af9b..95874d6d3 100644 --- a/service/fs/embed/registry_test.go +++ b/service/fs/embed/registry_test.go @@ -8,12 +8,9 @@ import ( "io/fs" "os" "path/filepath" - "runtime" - "strings" "sync" "testing" "testing/fstest" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -72,22 +69,23 @@ func TestRegistry_Register(t *testing.T) { r.mu.RUnlock() }) - t.Run("overwrites existing reader and closes old file", func(t *testing.T) { + t.Run("rejects an existing pack path without disturbing it", func(t *testing.T) { r := NewRegistry() reader1, file1 := createTestReaderWithFile(t) reader2 := createTestReader(t) require.NoError(t, r.Register("test/pack", reader1, file1)) - require.NoError(t, r.Register("test/pack", reader2, nil)) + require.Error(t, r.Register("test/pack", reader2, nil)) r.mu.RLock() assert.Len(t, r.packs, 1) - assert.Equal(t, reader2, r.packs["test/pack"].reader) + assert.Equal(t, reader1, r.packs["test/pack"].reader) r.mu.RUnlock() - // The replaced file handle must be closed. - _, err := file1.Read(make([]byte, 1)) - require.Error(t, err) + // The active pack remains owned by the registry. + _, err := file1.Stat() + require.NoError(t, err) + require.NoError(t, r.UnregisterPack("test/pack")) }) } @@ -153,360 +151,85 @@ func TestRegistry_GetFS(t *testing.T) { }) } -func TestRegistry_GetFSForEntry(t *testing.T) { - t.Run("selects pack matching operation module and version", func(t *testing.T) { - r := NewRegistry() - // Two pack versions expose the SAME resource ID with different content. - oldReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - fsysNew, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "2.0.0")) - require.NoError(t, err) - data, err := fs.ReadFile(fsysNew, "v.txt") - require.NoError(t, err) - assert.Equal(t, "new", string(data)) - - fsysOld, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - data, err = fs.ReadFile(fsysOld, "v.txt") - require.NoError(t, err) - assert.Equal(t, "old", string(data)) - }) - - t.Run("matches by module when version omitted", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "only"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) - - fsys, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "")) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "v.txt") - require.NoError(t, err) - assert.Equal(t, "only", string(data)) - }) - - t.Run("falls back to GetFS without provenance", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "legacy"}) - require.NoError(t, r.RegisterPack("legacy.wapp", "", "", reader, nil)) - - fsys, err := r.GetFSForEntry(packEntry("ui", "app"), nil) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "v.txt") - require.NoError(t, err) - assert.Equal(t, "legacy", string(data)) - }) - - t.Run("falls back to GetFS for a host-authored entry", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "host"}) - require.NoError(t, r.RegisterPack("app.wapp", "", "", reader, nil)) - - fsys, err := r.GetFSForEntry(packEntry("ui", "app"), ®istry.EntryProvenance{}) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "v.txt") - require.NoError(t, err) - assert.Equal(t, "host", string(data)) - }) - - t.Run("does not fall back to another pack when versioned owner is missing", func(t *testing.T) { - r := NewRegistry() - oldReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) - otherReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "other"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - require.NoError(t, r.RegisterPack("org/other-v9.9.9.wapp", "org/other", "9.9.9", otherReader, nil)) +func TestRegistry_ActiveResourceMapping(t *testing.T) { + r := NewRegistry() + oldReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) + newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) + require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - _, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "2.0.0")) - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - }) + fsys, err := r.GetFS(registry.NewID("ui", "app")) + require.NoError(t, err) + data, err := fs.ReadFile(fsys, "v.txt") + require.NoError(t, err) + assert.Equal(t, "old", string(data)) - t.Run("falls back to GetFS when module version is omitted", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "legacy"}) - require.NoError(t, r.RegisterPack("legacy.wapp", "", "", reader, nil)) + require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) + data, err = fs.ReadFile(fsys, "v.txt") + require.NoError(t, err) + assert.Equal(t, "new", string(data)) - fsys, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "")) - require.NoError(t, err) - data, err := fs.ReadFile(fsys, "v.txt") - require.NoError(t, err) - assert.Equal(t, "legacy", string(data)) - }) + require.NoError(t, r.UnregisterPack("org/mod-v2.0.0.wapp")) + data, err = fs.ReadFile(fsys, "v.txt") + require.NoError(t, err) + assert.Equal(t, "old", string(data)) } -func TestRegistry_RetargetModule(t *testing.T) { - t.Run("serves the new pack after the old one closes", func(t *testing.T) { - r := NewRegistry() - oldReader, oldFile := createReaderWithResourceFile(t, "ui", "app", map[string]string{"v.txt": "old"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, oldFile)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - // The entry received no event on the version bump: the consumer still - // holds the filesystem resolved from the 1.0.0 pack. - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - assert.Equal(t, "old", readFile(t, cached, "v.txt")) - - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - assert.Equal(t, "new", readFile(t, cached, "v.txt")) - - // Finalize retires the old pack; the cached filesystem keeps reading. - require.NoError(t, r.UnregisterModule("org/mod", "1.0.0")) - assert.Equal(t, "new", readFile(t, cached, "v.txt")) - }) - - t.Run("concurrent reads survive the old pack closing", func(t *testing.T) { - r := NewRegistry() - oldReader, oldFile := createReaderWithResourceFile(t, "ui", "app", map[string]string{"v.txt": "old"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, oldFile)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - var ( - wg sync.WaitGroup - stop = make(chan struct{}) - failed = make(chan error, 64) - ) - reportFailure := func(err error) { - select { - case failed <- err: - default: - } - } - // Half the readers hold an open handle across the retarget and the - // close, which is the window a per-call reference would miss. - for i := 0; i < 8; i++ { - holdOpen := i%2 == 0 - wg.Add(1) - go func() { - defer wg.Done() - for { - select { - case <-stop: - return - default: - } - if !holdOpen { - if _, err := fs.ReadFile(cached, "v.txt"); err != nil { - reportFailure(err) - return - } - continue - } - file, err := cached.Open("v.txt") - if err != nil { - reportFailure(err) - return - } - runtime.Gosched() - if _, err := io.ReadAll(file); err != nil { - _ = file.Close() - reportFailure(err) - return - } - if err := file.Close(); err != nil { - reportFailure(err) - return - } - } - }() - } - - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - require.NoError(t, r.UnregisterModule("org/mod", "1.0.0")) - // Reads after the close must come from the new pack. - for i := 0; i < 32; i++ { - assert.Equal(t, "new", readFile(t, cached, "v.txt")) - } - close(stop) - - // Readers must drain on their own: nothing in the retarget or the close - // waits on them, so nothing can hold them. - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("readers did not finish: a read is blocked on the pack lifecycle") - } - - close(failed) - for err := range failed { - t.Fatalf("read failed while the pack was retargeted: %v", err) - } - }) - - t.Run("missing target pack fails and keeps the current pack", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) - - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - err = r.RetargetModule("org/mod", "1.0.0", "2.0.0") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") - assert.Equal(t, "old", readFile(t, cached, "v.txt")) - }) - - t.Run("entry missing from the new pack retargets nothing", func(t *testing.T) { - r := NewRegistry() - oldReader := createReaderWithResources(t, "org/mod", map[string]map[string]string{ - "ui:app": {"v.txt": "old"}, - "ui:docs": {"v.txt": "old"}, - }) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - app, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - docs, err := r.GetFSForEntry(packEntry("ui", "docs"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - err = r.RetargetModule("org/mod", "1.0.0", "2.0.0") - require.Error(t, err) - assert.Equal(t, "old", readFile(t, app, "v.txt")) - assert.Equal(t, "old", readFile(t, docs, "v.txt")) - }) - - t.Run("nothing served from the version is a no-op", func(t *testing.T) { - r := NewRegistry() - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - }) - - t.Run("same version is a no-op", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) - _, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "1.0.0")) - }) - - t.Run("empty module is rejected", func(t *testing.T) { - r := NewRegistry() - err := r.RetargetModule("", "1.0.0", "2.0.0") - require.Error(t, err) - assert.Contains(t, err.Error(), "module cannot be empty") - }) - - t.Run("one store repoints every entry of the module version", func(t *testing.T) { - r := NewRegistry() - oldReader := createReaderWithResources(t, "org/mod", map[string]map[string]string{ - "ui:app": {"v.txt": "old"}, - "ui:docs": {"v.txt": "old"}, - }) - newReader := createReaderWithResources(t, "org/mod", map[string]map[string]string{ - "ui:app": {"v.txt": "new"}, - "ui:docs": {"v.txt": "new"}, - }) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, nil)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - app, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - docs, err := r.GetFSForEntry(packEntry("ui", "docs"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - // Both entries read through the same generation, so the retarget is one - // store rather than one store per filesystem. - assert.Same(t, app.(*entryFS).lineage, docs.(*entryFS).lineage) - - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - assert.Equal(t, "new", readFile(t, app, "v.txt")) - assert.Equal(t, "new", readFile(t, docs, "v.txt")) - }) - - t.Run("a read in flight keeps its pack open past the close", func(t *testing.T) { - r := NewRegistry() - oldReader, oldFile := createReaderWithResourceFile(t, "ui", "app", map[string]string{"v.txt": "old"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, oldFile)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) - - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - - inFlight, err := cached.Open("v.txt") - require.NoError(t, err) - - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - require.NoError(t, r.UnregisterModule("org/mod", "1.0.0")) - - // The handle opened before the retarget still reads its own pack. - data, err := io.ReadAll(inFlight) - require.NoError(t, err) - assert.Equal(t, "old", string(data)) - _, err = oldFile.ReadAt(make([]byte, 1), 0) - require.NoError(t, err, "the pack file must stay open while a read holds it") - - // Closing the last reader closes the pack file. - require.NoError(t, inFlight.Close()) - _, err = oldFile.ReadAt(make([]byte, 1), 0) - require.Error(t, err) - - // New reads come from the pack the lineage was retargeted to. - assert.Equal(t, "new", readFile(t, cached, "v.txt")) - }) - - t.Run("retired generations are not referenced by the registry", func(t *testing.T) { - r := NewRegistry() - oldReader, oldFile := createReaderWithResourceFile(t, "ui", "app", map[string]string{"v.txt": "old"}) - newReader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "new"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", oldReader, oldFile)) - require.NoError(t, r.RegisterPack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", newReader, nil)) +func TestRegistry_StagedResourceBindsNewHandleUntilActivation(t *testing.T) { + r := NewRegistry() + reader := createReaderWithResource(t, "ui", "next", map[string]string{"v.txt": "next"}) + require.NoError(t, r.StagePack("org/mod-v2.0.0.wapp", "org/mod", "2.0.0", reader, nil)) - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) - assert.Equal(t, "old", readFile(t, cached, "v.txt")) + fsys, err := r.GetFS(registry.NewID("ui", "next")) + require.NoError(t, err) + data, err := fs.ReadFile(fsys, "v.txt") + require.NoError(t, err) + assert.Equal(t, "next", string(data)) - require.NoError(t, r.RetargetModule("org/mod", "1.0.0", "2.0.0")) - require.NoError(t, r.UnregisterModule("org/mod", "1.0.0")) + require.NoError(t, r.ActivatePack("org/mod-v2.0.0.wapp")) + data, err = fs.ReadFile(fsys, "v.txt") + require.NoError(t, err) + assert.Equal(t, "next", string(data)) +} - r.mu.RLock() - defer r.mu.RUnlock() - for p, lineages := range r.lineages { - assert.Equal(t, "2.0.0", p.version, "no lineage may stay keyed by the retired pack") - for _, l := range lineages { - require.NotNil(t, l.current.Load()) - assert.Equal(t, "2.0.0", l.current.Load().version()) - } - } - // Nothing holds the retired pack: its handle is closed. - _, err = oldFile.ReadAt(make([]byte, 1), 0) - require.Error(t, err) - }) +func TestRegistry_ActivatePacksDoesNotPartiallySwitch(t *testing.T) { + r := NewRegistry() + reader := createReaderWithResource(t, "ui", "one", map[string]string{"v.txt": "one"}) + require.NoError(t, r.StagePack("org/one.wapp", "org/one", "1.0.0", reader, nil)) + + require.Error(t, r.ActivatePacks([]string{"org/one.wapp", "missing.wapp"})) + r.mu.RLock() + active := r.packs["org/one.wapp"].active.Load() + r.mu.RUnlock() + assert.False(t, active) +} - t.Run("unregistering the served pack stops resolution", func(t *testing.T) { - r := NewRegistry() - reader := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "old"}) - require.NoError(t, r.RegisterPack("org/mod-v1.0.0.wapp", "org/mod", "1.0.0", reader, nil)) - cached, err := r.GetFSForEntry(packEntry("ui", "app"), moduleProvenance("org/mod", "1.0.0")) - require.NoError(t, err) +func TestRegistry_RejectsResourceCollisionAcrossModules(t *testing.T) { + r := NewRegistry() + first := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "one"}) + second := createReaderWithResource(t, "ui", "app", map[string]string{"v.txt": "two"}) + require.NoError(t, r.RegisterPack("org/one.wapp", "org/one", "1.0.0", first, nil)) + require.Error(t, r.RegisterPack("org/two.wapp", "org/two", "1.0.0", second, nil)) +} - require.NoError(t, r.UnregisterModule("org/mod", "1.0.0")) +func TestRegistry_RetiresPackAfterOpenFileCloses(t *testing.T) { + r := NewRegistry() + reader, file := createReaderWithResourceAndFile(t, "ui", "app", map[string]string{"v.txt": "open"}) + require.NoError(t, r.RegisterPack("org/mod.wapp", "org/mod", "1.0.0", reader, file)) - _, err = cached.Open("v.txt") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found") + fsys, err := r.GetFS(registry.NewID("ui", "app")) + require.NoError(t, err) + open, err := fsys.Open("v.txt") + require.NoError(t, err) - r.mu.RLock() - lineages := len(r.lineages) - r.mu.RUnlock() - assert.Equal(t, 0, lineages) - }) + // The retired pack remains readable through an open file, then releases the + // underlying handle once that read completes. + require.NoError(t, r.UnregisterPack("org/mod.wapp")) + data, err := io.ReadAll(open) + require.NoError(t, err) + assert.Equal(t, "open", string(data)) + require.NoError(t, open.Close()) + _, err = file.Read(make([]byte, 1)) + require.Error(t, err) } func TestRegistry_UnregisterPack(t *testing.T) { @@ -705,76 +428,50 @@ func createTestReaderWithFile(t *testing.T) (*wapp.Reader, *os.File) { return reader, file } -// packEntry builds the registry entry a pack resource belongs to. Ownership -// rides the operation provenance, never the entry. -func packEntry(ns, name string) registry.Entry { - return registry.Entry{ID: registry.NewID(ns, name)} -} - -func moduleProvenance(module, version string) *registry.EntryProvenance { - return ®istry.EntryProvenance{Module: module, Version: version} -} - -func readFile(t *testing.T, fsys fs.ReadDirFS, name string) string { - t.Helper() - - data, err := fs.ReadFile(fsys, name) - require.NoError(t, err) - return string(data) -} - // createReaderWithResource builds a wapp containing a single filesystem resource. func createReaderWithResource(t *testing.T, ns, name string, files map[string]string) *wapp.Reader { t.Helper() - return readerFromBytes(t, packResources(t, []wapp.ResourceSpec{{ID: wapp.NewID(ns, name), FS: resourceFS(files)}})) -} - -// createReaderWithResourceFile builds a single-resource wapp on disk and returns -// the open file handle backing it, so closing the pack breaks its reads. -func createReaderWithResourceFile(t *testing.T, ns, name string, files map[string]string) (*wapp.Reader, *os.File) { - t.Helper() - data := packResources(t, []wapp.ResourceSpec{{ID: wapp.NewID(ns, name), FS: resourceFS(files)}}) - wappPath := filepath.Join(t.TempDir(), "pack.wapp") - require.NoError(t, os.WriteFile(wappPath, data, 0644)) + mapFS := fstest.MapFS{} + for path, content := range files { + mapFS[path] = &fstest.MapFile{Data: []byte(content), Mode: 0644} + } - file, err := os.Open(wappPath) + var buf bytes.Buffer + writer := wapp.NewWriter() + err := writer.PackWithResources( + wapp.Metadata{}, + nil, + []wapp.ResourceSpec{{ID: wapp.NewID(ns, name), FS: mapFS}}, + &buf, + ) require.NoError(t, err) - reader, err := wapp.NewReader(file) - require.NoError(t, err) - return reader, file + return readerFromBytes(t, buf.Bytes()) } -// createReaderWithResources builds a wapp exposing several resources, keyed -// "namespace:name". -func createReaderWithResources(t *testing.T, _ string, resources map[string]map[string]string) *wapp.Reader { +func createReaderWithResourceAndFile(t *testing.T, ns, name string, files map[string]string) (*wapp.Reader, *os.File) { t.Helper() - - specs := make([]wapp.ResourceSpec, 0, len(resources)) - for id, files := range resources { - ns, name, ok := strings.Cut(id, ":") - require.True(t, ok, "resource id %q must be namespace:name", id) - specs = append(specs, wapp.ResourceSpec{ID: wapp.NewID(ns, name), FS: resourceFS(files)}) - } - return readerFromBytes(t, packResources(t, specs)) -} - -func resourceFS(files map[string]string) fstest.MapFS { mapFS := fstest.MapFS{} for path, content := range files { mapFS[path] = &fstest.MapFile{Data: []byte(content), Mode: 0644} } - return mapFS -} - -func packResources(t *testing.T, specs []wapp.ResourceSpec) []byte { - t.Helper() var buf bytes.Buffer writer := wapp.NewWriter() - require.NoError(t, writer.PackWithResources(wapp.Metadata{}, nil, specs, &buf)) - return buf.Bytes() + require.NoError(t, writer.PackWithResources( + wapp.Metadata{}, nil, + []wapp.ResourceSpec{{ID: wapp.NewID(ns, name), FS: mapFS}}, + &buf, + )) + + path := filepath.Join(t.TempDir(), "pack.wapp") + require.NoError(t, os.WriteFile(path, buf.Bytes(), 0644)) + file, err := os.Open(path) + require.NoError(t, err) + reader, err := wapp.NewReader(file) + require.NoError(t, err) + return reader, file } func packEntriesOnly(t *testing.T) []byte { @@ -799,28 +496,3 @@ func readerFromBytes(t *testing.T, data []byte) *wapp.Reader { require.NoError(t, err) return reader } - -// TestRegisterPack_SameIdentityRetiresPreviousPack pins the one-pack-per -// (module, version) invariant retargeting relies on: registering a second pack -// at the same identity retires the first, so a retarget always moves every -// filesystem of that identity together. -func TestRegisterPack_SameIdentityRetiresPreviousPack(t *testing.T) { - reg := NewRegistry() - defer func() { _ = reg.Close() }() - - first := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "one"}) - require.NoError(t, reg.RegisterPack("pack-a", "org/mod", "1.0.0", first, nil)) - second := createReaderWithResource(t, "ui", "app", map[string]string{"index.html": "two"}) - require.NoError(t, reg.RegisterPack("pack-b", "org/mod", "1.0.0", second, nil)) - - reg.mu.RLock() - count := 0 - for _, p := range reg.packs { - if p.module == "org/mod" && p.version == "1.0.0" { - count++ - } - } - reg.mu.RUnlock() - require.Equal(t, 1, count, "one pack per module version") - require.True(t, reg.HasModulePack("org/mod", "1.0.0")) -} diff --git a/system/eventbus/dispatcher.go b/system/eventbus/dispatcher.go index 2370092e3..7508f171e 100644 --- a/system/eventbus/dispatcher.go +++ b/system/eventbus/dispatcher.go @@ -157,12 +157,7 @@ func (d *Dispatcher) handleSubscribe(_ context.Context, cmd dispatcher.Command, func (d *Dispatcher) handleSend(ctx context.Context, cmd dispatcher.Command, tag uint64, receiver dispatcher.ResultReceiver) error { sendCmd := cmd.(event.SendCmd) - evt := event.Event{ - Data: sendCmd.Data, - System: sendCmd.System, - Kind: sendCmd.Kind, - Path: sendCmd.Path, - } + evt := event.Event(sendCmd) d.bus.Send(ctx, evt) receiver.CompleteYield(tag, true, nil) diff --git a/system/fs/errors.go b/system/fs/errors.go index e44cd169a..314f51fd7 100644 --- a/system/fs/errors.go +++ b/system/fs/errors.go @@ -120,14 +120,14 @@ func NewEmptyPackPathError() apierror.Error { return apierror.New(apierror.Invalid, "packPath cannot be empty").WithRetryable(apierror.False) } -func NewEmptyModuleError() apierror.Error { - return apierror.New(apierror.Invalid, "module cannot be empty").WithRetryable(apierror.False) -} - -func NewModulePackNotFoundError(module, version string) apierror.Error { - return apierror.New(apierror.NotFound, "pack for module "+module+" version "+version+" not found"). - WithRetryable(apierror.False). - WithDetails(attrs.NewBagFrom(map[string]any{"module": module, "module_version": version})) +func NewEmbeddedResourceConflictError(id, activePack, nextPack string) apierror.Error { + return apierror.New(apierror.Conflict, "embedded filesystem resource is provided by more than one module"). + WithRetryable(apierror.False). + WithDetails(attrs.NewBagFrom(map[string]any{ + "filesystem_id": id, + "active_pack": activePack, + "next_pack": nextPack, + })) } func NewReadOnlyOperationError(operation string) apierror.Error { diff --git a/system/registry/bench_test.go b/system/registry/bench_test.go index 97c8c7324..5f6e75205 100644 --- a/system/registry/bench_test.go +++ b/system/registry/bench_test.go @@ -58,7 +58,7 @@ func setupBenchRegistry(b *testing.B, entryCount int) *Reg { } } - err := reg.LoadState(context.Background(), hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(context.Background(), baseline, version.FromParent(nil, 0)) if err != nil { b.Fatalf("failed to load baseline: %v", err) } @@ -216,7 +216,7 @@ func BenchmarkLoadState(b *testing.B) { for i := 0; i < b.N; i++ { reg := NewRegistry(hist, runner, builder, nil, zap.NewNop()) - err := reg.LoadState(context.Background(), hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(context.Background(), baseline, version.FromParent(nil, 0)) if err != nil { b.Fatalf("LoadState failed: %v", err) } diff --git a/system/registry/errors.go b/system/registry/errors.go index a8d47e629..f66510647 100644 --- a/system/registry/errors.go +++ b/system/registry/errors.go @@ -3,8 +3,6 @@ package registry import ( - "errors" - "github.com/wippyai/runtime/api/attrs" apierror "github.com/wippyai/runtime/api/error" "github.com/wippyai/runtime/api/registry" @@ -26,14 +24,6 @@ func NewEntryNotFoundError(path registry.ID) apierror.Error { WithDetails(attrs.NewBagFrom(map[string]any{"path": path.String()})) } -// NewProvenanceInvariantError reports registry-owned state that cannot be -// paired one-to-one with its ownership records. -func NewProvenanceInvariantError(err error) apierror.Error { - return apierror.New(apierror.Internal, "registry provenance invariant violated"). - WithRetryable(apierror.False). - WithCause(err) -} - // NewApplyChangesError creates an error when applying changes fails func NewApplyChangesError(err error, rollbackErr error) apierror.Error { if rollbackErr != nil { @@ -101,15 +91,10 @@ func NewCommitEffectsError(err error, rollbackErr error) apierror.Error { } // NewConcurrentApplyError creates an error when registry state changes mid-apply. -// ErrConcurrentApply marks an apply that lost to a concurrent one; callers -// re-read and retry against the new version. -var ErrConcurrentApply = errors.New("registry changed during apply") - func NewConcurrentApplyError(expected, actual uint) apierror.Error { return apierror.New(apierror.Conflict, "registry changed during apply"). WithRetryable(apierror.True). - WithDetails(attrs.NewBagFrom(map[string]any{"expected_version": expected, "actual_version": actual})). - WithCause(ErrConcurrentApply) + WithDetails(attrs.NewBagFrom(map[string]any{"expected_version": expected, "actual_version": actual})) } // NewOverlayGenerationConflictError reports an optimistic-concurrency conflict diff --git a/system/registry/events/handler.go b/system/registry/events/handler.go index b60dc867c..f4714bead 100644 --- a/system/registry/events/handler.go +++ b/system/registry/events/handler.go @@ -62,12 +62,6 @@ func NewRegistryHandler(kinds registry.Kind, listener registry.EntryListener) ev return nil } - // The dispatcher's provenance pair travels on the envelope; the - // listener reads it from its context. - if opProv, ok := evt.Aux.(registry.OpProvenance); ok { - ctx = registry.WithOpProvenance(ctx, opProv) - } - // Validate data for non-delete operations if evt.Kind != registry.EntryDelete && entry.Data == nil { reject(ctx, bus, entry.ID, NewConfigDataRequiredError()) diff --git a/system/registry/events/provenance_delivery_test.go b/system/registry/events/provenance_delivery_test.go deleted file mode 100644 index 6475820aa..000000000 --- a/system/registry/events/provenance_delivery_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package events - -import ( - "context" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - ctxapi "github.com/wippyai/runtime/api/context" - "github.com/wippyai/runtime/api/event" - "github.com/wippyai/runtime/api/payload" - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/system/eventbus" -) - -// provenanceRecordingListener captures the operation provenance its context -// carries at the moment each callback runs. -type provenanceRecordingListener struct { - captured []registry.OpProvenance - found []bool - mu sync.Mutex -} - -func (l *provenanceRecordingListener) record(ctx context.Context) { - l.mu.Lock() - defer l.mu.Unlock() - p, ok := registry.OpProvenanceFromContext(ctx) - l.captured = append(l.captured, p) - l.found = append(l.found, ok) -} - -func (l *provenanceRecordingListener) Add(ctx context.Context, _ registry.Entry) error { - l.record(ctx) - return nil -} - -func (l *provenanceRecordingListener) Update(ctx context.Context, _ registry.Entry) error { - l.record(ctx) - return nil -} - -func (l *provenanceRecordingListener) Delete(ctx context.Context, _ registry.Entry) error { - l.record(ctx) - return nil -} - -// TestHandlerDeliversOpProvenanceThroughTheBus pins the full delivery path: -// the dispatcher puts the pair on the event envelope, the event crosses the -// bus (where the sender's context does not survive), and the adapter injects -// the pair into the context the listener receives. A context-only carrier -// fails this test. -func TestHandlerDeliversOpProvenanceThroughTheBus(t *testing.T) { - ctx, cancel := context.WithTimeout(ctxapi.NewRootContext(), 5*time.Second) - defer cancel() - - bus := eventbus.NewBus() - defer bus.Stop() - ctx = event.WithBus(ctx, bus) - - listener := &provenanceRecordingListener{} - router, err := eventbus.StartRouter(ctx, bus, eventbus.WithHandlers(NewRegistryHandler("test.*", listener))) - require.NoError(t, err) - defer func() { _ = router.Stop() }() - - accepted := make(chan event.Event, 1) - _, err = bus.SubscribeP(ctx, registry.System, registry.EntryResult, accepted) - require.NoError(t, err) - - effective := ®istry.EntryProvenance{Module: "org/mod", Version: "2.0.0", Digest: "sha256:new"} - original := ®istry.EntryProvenance{Module: "org/mod", Version: "1.0.0", Digest: "sha256:old"} - - bus.Send(ctx, event.Event{ - System: registry.System, - Kind: registry.EntryUpdate, - Path: "test:svc", - Data: registry.Entry{ - ID: registry.NewID("test", "svc"), - Kind: "test.resource", - Data: payload.NewString("data"), - }, - Aux: registry.OpProvenance{Effective: effective, Original: original}, - }) - - select { - case <-accepted: - case <-ctx.Done(): - t.Fatal("timeout waiting for the listener to acknowledge the event") - } - - listener.mu.Lock() - defer listener.mu.Unlock() - require.Len(t, listener.captured, 1) - require.True(t, listener.found[0], "the listener context must carry the operation provenance") - got := listener.captured[0] - require.NotNil(t, got.Effective) - require.NotNil(t, got.Original) - assert.Equal(t, "2.0.0", got.Effective.Version) - assert.Equal(t, "1.0.0", got.Original.Version) -} diff --git a/system/registry/expansion/apply_test.go b/system/registry/expansion/apply_test.go index 3e374fa1f..fae05c9f6 100644 --- a/system/registry/expansion/apply_test.go +++ b/system/registry/expansion/apply_test.go @@ -19,9 +19,9 @@ import ( "go.uber.org/zap" ) -type expanderFunc func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) +type expanderFunc func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) -func (f expanderFunc) Expand(ctx context.Context, op regapi.Operation, snap regapi.ProvenancedState) (regapi.DirectiveResult, error) { +func (f expanderFunc) Expand(ctx context.Context, op regapi.Operation, snap regapi.State) (regapi.DirectiveResult, error) { return f(ctx, op, snap) } @@ -117,7 +117,7 @@ func TestApplyExpansion_ScopedHistoryAndBaseline(t *testing.T) { Data: payload.NewString("svc"), } - exp := expanderFunc(func(_ context.Context, op regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, op regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { if op.Entry.Kind != "ns.dependency" { return regapi.DirectiveResult{}, nil } @@ -168,7 +168,7 @@ func TestApplyExpansion_BaselineOnly_NoHistory(t *testing.T) { } scope := regapi.ScopeBaseline - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, OriginalScope: &scope, @@ -200,7 +200,7 @@ func TestApplyExpansion_PrepareFailure_RollsBackPreparedEffects(t *testing.T) { eff1 := &testEffect{} eff2 := &testEffect{prepareErr: errors.New("prepare failed")} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Effects: []regapi.Effect{eff1, eff2}, @@ -226,7 +226,7 @@ func TestApplyExpansion_ConcurrentApply_Serializes(t *testing.T) { hist := historymem.New() block := &blockingEffect{start: make(chan struct{}), proceed: make(chan struct{})} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Effects: []regapi.Effect{block}, @@ -283,7 +283,7 @@ func TestApplyExpansion_TransitionFailure_RollsBackEffects(t *testing.T) { hist := historymem.New() eff := &testEffect{} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Effects: []regapi.Effect{eff}, @@ -306,7 +306,7 @@ func TestApplyExpansion_DirectiveError_StopsApply(t *testing.T) { runner := &applyRunner{builder: builder} hist := historymem.New() - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{}, errors.New("directive failed") }) @@ -327,7 +327,7 @@ func TestApplyExpansion_HistorySaveFailure_RollsBackStateAndEffects(t *testing.T hist := &failingHistory{Storage: historymem.New(), saveErr: errors.New("save failed")} eff := &testEffect{} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Effects: []regapi.Effect{eff}, @@ -356,7 +356,7 @@ func TestApplyExpansion_CommitFailure_RollsBackStateAndEffects_NoHistorySave(t * hist := &failingHistory{Storage: historymem.New()} eff := &testEffect{commitErr: errors.New("commit failed")} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Effects: []regapi.Effect{eff}, @@ -389,7 +389,7 @@ func TestApplyExpansion_SkipAdditionalOpsOnOriginalEntry(t *testing.T) { entry := regapi.Entry{ID: regapi.NewID("app", "dep"), Kind: "ns.dependency", Data: payload.NewString("dep")} - exp := expanderFunc(func(_ context.Context, op regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, op regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Additional: []regapi.ScopedOperation{ @@ -420,7 +420,7 @@ func TestApplyExpansion_DisallowDuplicateAdditionalIDsAcrossDirectives(t *testin shared := regapi.Entry{ID: regapi.NewID("mod", "svc"), Kind: "service"} entry := regapi.Entry{ID: regapi.NewID("app", "dep"), Kind: "ns.dependency"} - exp1 := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp1 := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Additional: []regapi.ScopedOperation{ @@ -428,7 +428,7 @@ func TestApplyExpansion_DisallowDuplicateAdditionalIDsAcrossDirectives(t *testin }, }, nil }) - exp2 := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp2 := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Additional: []regapi.ScopedOperation{ @@ -457,7 +457,7 @@ func TestApplyExpansion_DisallowDuplicateAdditionalIDsWithinDirective(t *testing shared := regapi.Entry{ID: regapi.NewID("mod", "svc"), Kind: "service"} entry := regapi.Entry{ID: regapi.NewID("app", "dep"), Kind: "ns.dependency"} - exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + exp := expanderFunc(func(_ context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, Additional: []regapi.ScopedOperation{ @@ -480,27 +480,27 @@ func TestApplyExpansion_DisallowDuplicateAdditionalIDsWithinDirective(t *testing func TestDependencyDirective_NoHandler(t *testing.T) { dir := &sysregexp.DependencyDirective{} - res, err := dir.Expand(context.Background(), regapi.Operation{}, regapi.ProvenancedState{}) + res, err := dir.Expand(context.Background(), regapi.Operation{}, nil) require.NoError(t, err) assert.False(t, res.Applied) } func TestDependencyDirective_NotApplied(t *testing.T) { - dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: false}, nil }) - res, err := dir.Expand(context.Background(), regapi.Operation{}, regapi.ProvenancedState{}) + res, err := dir.Expand(context.Background(), regapi.Operation{}, nil) require.NoError(t, err) assert.False(t, res.Applied) } func TestDependencyDirective_Error(t *testing.T) { - dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{}, errors.New("expand failed") }) - _, err := dir.Expand(context.Background(), regapi.Operation{}, regapi.ProvenancedState{}) + _, err := dir.Expand(context.Background(), regapi.Operation{}, nil) require.Error(t, err) } @@ -517,7 +517,7 @@ func TestDependencyDirective_Applied(t *testing.T) { }, } - dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + dir := sysregexp.NewDependencyDirective(func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{ Applied: true, OriginalScope: &scope, @@ -526,7 +526,7 @@ func TestDependencyDirective_Applied(t *testing.T) { }, nil }) - res, err := dir.Expand(context.Background(), regapi.Operation{}, regapi.ProvenancedState{}) + res, err := dir.Expand(context.Background(), regapi.Operation{}, nil) require.NoError(t, err) require.True(t, res.Applied) require.NotNil(t, res.OriginalScope) diff --git a/system/registry/expansion/dependency_directive.go b/system/registry/expansion/dependency_directive.go index 9d612beed..62676008c 100644 --- a/system/registry/expansion/dependency_directive.go +++ b/system/registry/expansion/dependency_directive.go @@ -10,10 +10,10 @@ import ( // DependencyDirectiveFunc provides expansion for dependency entries. // Implementations should honor the provided context. -type DependencyDirectiveFunc func(ctx context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) -type DependencyChangesDirectiveFunc func(ctx context.Context, changes registry.ChangeSet, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) -type ResolutionDirectiveFunc func(ctx context.Context, snapshot registry.ProvenancedState, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) -type ResolutionTransitionDirectiveFunc func(ctx context.Context, current registry.ProvenancedState, target registry.ProvenancedState, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) +type DependencyDirectiveFunc func(ctx context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) +type DependencyChangesDirectiveFunc func(ctx context.Context, changes registry.ChangeSet, snapshot registry.State) (registry.DirectiveResult, error) +type ResolutionDirectiveFunc func(ctx context.Context, snapshot registry.State, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) +type ResolutionTransitionDirectiveFunc func(ctx context.Context, current registry.State, target registry.State, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) // DependencyDirective expands dependency operations using a direct handler. type DependencyDirective struct { @@ -50,14 +50,14 @@ func (d *DependencyDirective) WithResolutionTransition(reconcile ResolutionTrans return d } -func (d *DependencyDirective) ExpandChanges(ctx context.Context, changes registry.ChangeSet, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { +func (d *DependencyDirective) ExpandChanges(ctx context.Context, changes registry.ChangeSet, snapshot registry.State) (registry.DirectiveResult, error) { if d == nil || d.ChangesFunc == nil { return registry.DirectiveResult{}, nil } return d.ChangesFunc(ctx, changes, snapshot) } -func (d *DependencyDirective) ReconcileResolution(ctx context.Context, snapshot registry.ProvenancedState, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) { +func (d *DependencyDirective) ReconcileResolution(ctx context.Context, snapshot registry.State, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) { if d == nil { return registry.DirectiveResult{}, nil } @@ -70,7 +70,7 @@ func (d *DependencyDirective) ReconcileResolution(ctx context.Context, snapshot return registry.DirectiveResult{}, nil } -func (d *DependencyDirective) ReconcileResolutionTransition(ctx context.Context, current registry.ProvenancedState, target registry.ProvenancedState, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) { +func (d *DependencyDirective) ReconcileResolutionTransition(ctx context.Context, current registry.State, target registry.State, resolution *registry.DependencyResolution) (registry.DirectiveResult, error) { if d == nil || d.ReconcileTransitionFunc == nil { return registry.DirectiveResult{}, nil } @@ -78,7 +78,7 @@ func (d *DependencyDirective) ReconcileResolutionTransition(ctx context.Context, } // Expand implements registry.Directive. -func (d *DependencyDirective) Expand(ctx context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { +func (d *DependencyDirective) Expand(ctx context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) { if d == nil || d.ExpandFunc == nil { return registry.DirectiveResult{}, nil } diff --git a/system/registry/expansion/directive_test.go b/system/registry/expansion/directive_test.go index 99fdd074e..da7b680a1 100644 --- a/system/registry/expansion/directive_test.go +++ b/system/registry/expansion/directive_test.go @@ -15,12 +15,12 @@ import ( func TestDependencyDirective_Expand(t *testing.T) { called := false - d := NewDependencyDirective(func(ctx context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { + d := NewDependencyDirective(func(ctx context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) { called = true return registry.DirectiveResult{Applied: true}, nil }) - result, err := d.Expand(context.Background(), registry.Operation{}, registry.ProvenancedState{}) + result, err := d.Expand(context.Background(), registry.Operation{}, nil) require.NoError(t, err) assert.True(t, result.Applied) assert.True(t, called) @@ -28,20 +28,20 @@ func TestDependencyDirective_Expand(t *testing.T) { func TestDependencyDirective_Expand_NilDirective(t *testing.T) { var d *DependencyDirective - result, err := d.Expand(context.Background(), registry.Operation{}, registry.ProvenancedState{}) + result, err := d.Expand(context.Background(), registry.Operation{}, nil) assert.NoError(t, err) assert.False(t, result.Applied) } func TestDependencyDirective_Expand_NilFunc(t *testing.T) { d := &DependencyDirective{} - result, err := d.Expand(context.Background(), registry.Operation{}, registry.ProvenancedState{}) + result, err := d.Expand(context.Background(), registry.Operation{}, nil) assert.NoError(t, err) assert.False(t, result.Applied) } func TestDependencyDirective_Expand_CancelledContext(t *testing.T) { - d := NewDependencyDirective(func(ctx context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { + d := NewDependencyDirective(func(ctx context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) { t.Fatal("should not be called") return registry.DirectiveResult{}, nil }) @@ -49,29 +49,29 @@ func TestDependencyDirective_Expand_CancelledContext(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := d.Expand(ctx, registry.Operation{}, registry.ProvenancedState{}) + _, err := d.Expand(ctx, registry.Operation{}, nil) assert.ErrorIs(t, err, context.Canceled) } func TestDependencyDirective_ReconcileResolutionCompatibility(t *testing.T) { called := false - d := NewDependencyDirective(nil, func(_ context.Context, snapshot registry.ProvenancedState, _ *registry.DependencyResolution) (registry.DirectiveResult, error) { + d := NewDependencyDirective(nil, func(_ context.Context, snapshot registry.State, _ *registry.DependencyResolution) (registry.DirectiveResult, error) { called = true - require.Len(t, snapshot.Entries, 1) + require.Len(t, snapshot, 1) return registry.DirectiveResult{Applied: true}, nil }) - result, err := d.ReconcileResolution(context.Background(), registry.ProvenancedState{Entries: registry.State{{Kind: "service"}}}, nil) + result, err := d.ReconcileResolution(context.Background(), registry.State{{Kind: "service"}}, nil) require.NoError(t, err) require.True(t, called) require.True(t, result.Applied) } func TestDependencyDirective_ReconcileResolutionTransitionKeepsSourceAndTarget(t *testing.T) { - current := registry.ProvenancedState{Entries: registry.State{{ID: registry.NewID("app", "before"), Kind: "service"}}} - target := registry.ProvenancedState{Entries: registry.State{{ID: registry.NewID("app", "after"), Kind: "service"}}} + current := registry.State{{ID: registry.NewID("app", "before"), Kind: "service"}} + target := registry.State{{ID: registry.NewID("app", "after"), Kind: "service"}} d := NewDependencyDirective(nil).WithResolutionTransition( - func(_ context.Context, gotCurrent registry.ProvenancedState, gotTarget registry.ProvenancedState, _ *registry.DependencyResolution) (registry.DirectiveResult, error) { + func(_ context.Context, gotCurrent registry.State, gotTarget registry.State, _ *registry.DependencyResolution) (registry.DirectiveResult, error) { require.Equal(t, current, gotCurrent) require.Equal(t, target, gotTarget) return registry.DirectiveResult{Applied: true}, nil diff --git a/system/registry/expansion/expansion.go b/system/registry/expansion/expansion.go index 5efbc868b..a3b66fea3 100644 --- a/system/registry/expansion/expansion.go +++ b/system/registry/expansion/expansion.go @@ -20,9 +20,6 @@ type ScopedOp struct { // Plan contains expanded operations and effects. type Plan struct { Resolution *registry.DependencyResolution - // Provenance carries resident-record updates for entries no operation - // touches, merged from directive results. - Provenance registry.ProvenanceMap Ops []ScopedOp Effects []registry.Effect Expanded bool @@ -67,7 +64,7 @@ func NewPlanner(directivesByKind map[registry.Kind][]registry.Directive, resolve } // Expand turns a changeset into a plan by applying registered directives. -func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapshot registry.ProvenancedState) (*Plan, error) { +func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapshot registry.State) (*Plan, error) { if len(changes) == 0 { return &Plan{}, nil } @@ -93,18 +90,6 @@ func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapsh } }() var resolution *registry.DependencyResolution - var provenance registry.ProvenanceMap - mergeProvenance := func(records registry.ProvenanceMap) { - if len(records) == 0 { - return - } - if provenance == nil { - provenance = make(registry.ProvenanceMap, len(records)) - } - for id, record := range records { - provenance[id] = record - } - } expanded := false originalCount := len(scoped) type batchKey struct { @@ -148,7 +133,7 @@ func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapsh return nil, err } ownedEffects = append(ownedEffects, result.Effects...) - if !result.Applied && result.OriginalScope == nil && result.Resolution == nil && len(result.Additional) == 0 && len(result.Effects) == 0 && len(result.Provenance) == 0 { + if !result.Applied && result.OriginalScope == nil && result.Resolution == nil && len(result.Additional) == 0 && len(result.Effects) == 0 { continue // Capability is present but not configured; use per-op expansion. } if result.OriginalScope != nil { @@ -194,7 +179,6 @@ func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapsh continue } expanded = true - mergeProvenance(res.Provenance) if res.Resolution != nil { canonical := res.Resolution.Canonical() if resolution != nil && resolution.Digest != canonical.Digest { @@ -225,7 +209,7 @@ func (p *Planner) Expand(ctx context.Context, changes registry.ChangeSet, snapsh } succeeded = true - return &Plan{Ops: scoped, Effects: effects, Resolution: resolution, Provenance: provenance, Expanded: expanded}, nil + return &Plan{Ops: scoped, Effects: effects, Resolution: resolution, Expanded: expanded}, nil } func recordExpandedOperation(opsByID map[registry.ID]map[string]struct{}, op registry.Operation) bool { @@ -348,8 +332,8 @@ func (p *Planner) RollbackEffects(ctx context.Context, effects []registry.Effect } } -func entryFromSnapshot(snapshot registry.ProvenancedState, id registry.ID) (registry.Entry, bool) { - for _, entry := range snapshot.Entries { +func entryFromSnapshot(snapshot registry.State, id registry.ID) (registry.Entry, bool) { + for _, entry := range snapshot { if entry.ID.Equal(id) { return entry, true } diff --git a/system/registry/expansion/expansion_test.go b/system/registry/expansion/expansion_test.go index c825459b4..977637d80 100644 --- a/system/registry/expansion/expansion_test.go +++ b/system/registry/expansion/expansion_test.go @@ -15,19 +15,19 @@ import ( ) type stubDirective struct { - expandFunc func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) + expandFunc func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) } type stubChangesDirective struct { stubDirective - expandChangesFunc func(context.Context, registry.ChangeSet, registry.ProvenancedState) (registry.DirectiveResult, error) + expandChangesFunc func(context.Context, registry.ChangeSet, registry.State) (registry.DirectiveResult, error) } -func (s *stubChangesDirective) ExpandChanges(ctx context.Context, changes registry.ChangeSet, state registry.ProvenancedState) (registry.DirectiveResult, error) { +func (s *stubChangesDirective) ExpandChanges(ctx context.Context, changes registry.ChangeSet, state registry.State) (registry.DirectiveResult, error) { return s.expandChangesFunc(ctx, changes, state) } -func (s *stubDirective) Expand(ctx context.Context, op registry.Operation, snap registry.ProvenancedState) (registry.DirectiveResult, error) { +func (s *stubDirective) Expand(ctx context.Context, op registry.Operation, snap registry.State) (registry.DirectiveResult, error) { return s.expandFunc(ctx, op, snap) } @@ -87,7 +87,7 @@ func TestNewPlanner_NilLogger(t *testing.T) { func TestPlanner_Expand_EmptyChanges(t *testing.T) { p := NewPlanner(nil, nil, zap.NewNop()) - plan, err := p.Expand(context.Background(), nil, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), nil, nil) require.NoError(t, err) require.NotNil(t, plan) assert.Empty(t, plan.Ops) @@ -98,7 +98,7 @@ func TestPlanner_Expand_NoDirectives(t *testing.T) { entry := newEntry("app", "svc", "service") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) require.Len(t, plan.Ops, 1) assert.Equal(t, entry.ID, plan.Ops[0].Operation.Entry.ID) @@ -107,7 +107,7 @@ func TestPlanner_Expand_NoDirectives(t *testing.T) { } func TestPlanner_Expand_DirectiveNotApplied(t *testing.T) { - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{Applied: false}, nil }} @@ -115,7 +115,7 @@ func TestPlanner_Expand_DirectiveNotApplied(t *testing.T) { entry := newEntry("app", "item", "svc") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) assert.Len(t, plan.Ops, 1) assert.False(t, plan.Expanded) @@ -123,7 +123,7 @@ func TestPlanner_Expand_DirectiveNotApplied(t *testing.T) { func TestPlanner_Expand_DirectiveApplied_AddsOps(t *testing.T) { extra := newEntry("mod", "extra", "service") - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -136,7 +136,7 @@ func TestPlanner_Expand_DirectiveApplied_AddsOps(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) assert.Len(t, plan.Ops, 2) assert.True(t, plan.Expanded) @@ -147,11 +147,11 @@ func TestPlanner_Expand_BatchesSameKindTransactionOnce(t *testing.T) { perOperationCalls := 0 batchCalls := 0 dir := &stubChangesDirective{ - stubDirective: stubDirective{expandFunc: func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) { + stubDirective: stubDirective{expandFunc: func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) { perOperationCalls++ return registry.DirectiveResult{Applied: true}, nil }}, - expandChangesFunc: func(_ context.Context, changes registry.ChangeSet, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + expandChangesFunc: func(_ context.Context, changes registry.ChangeSet, _ registry.State) (registry.DirectiveResult, error) { batchCalls++ require.Len(t, changes, 2) return registry.DirectiveResult{Applied: true}, nil @@ -163,7 +163,7 @@ func TestPlanner_Expand_BatchesSameKindTransactionOnce(t *testing.T) { newOp(registry.EntryCreate, newEntry("app", "two", "dep")), } - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) require.Len(t, plan.Ops, 2) require.Equal(t, 1, batchCalls) @@ -171,7 +171,7 @@ func TestPlanner_Expand_BatchesSameKindTransactionOnce(t *testing.T) { } func TestPlanner_Expand_DirectiveError(t *testing.T) { - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{}, errors.New("expand failed") }} @@ -179,14 +179,14 @@ func TestPlanner_Expand_DirectiveError(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - _, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + _, err := p.Expand(context.Background(), changes, nil) require.Error(t, err) assert.Contains(t, err.Error(), "expand failed") } func TestPlanner_Expand_SkipDuplicateFromOriginalChangeset(t *testing.T) { entry := newEntry("app", "dep", "dep") - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -198,7 +198,7 @@ func TestPlanner_Expand_SkipDuplicateFromOriginalChangeset(t *testing.T) { p := NewPlanner(map[registry.Kind][]registry.Directive{"dep": {dir}}, nil, zap.NewNop()) changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) assert.Len(t, plan.Ops, 1) assert.True(t, plan.Expanded) @@ -206,7 +206,7 @@ func TestPlanner_Expand_SkipDuplicateFromOriginalChangeset(t *testing.T) { func TestPlanner_Expand_ConflictBetweenDirectiveExpansions(t *testing.T) { shared := newEntry("mod", "svc", "service") - dir1 := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir1 := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -214,7 +214,7 @@ func TestPlanner_Expand_ConflictBetweenDirectiveExpansions(t *testing.T) { }, }, nil }} - dir2 := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir2 := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -227,7 +227,7 @@ func TestPlanner_Expand_ConflictBetweenDirectiveExpansions(t *testing.T) { p := NewPlanner(map[registry.Kind][]registry.Directive{"dep": {dir1, dir2}}, nil, zap.NewNop()) changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - _, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + _, err := p.Expand(context.Background(), changes, nil) require.Error(t, err) assert.Contains(t, err.Error(), "expansion produced entry") } @@ -235,7 +235,7 @@ func TestPlanner_Expand_ConflictBetweenDirectiveExpansions(t *testing.T) { func TestPlanner_Expand_AllowsDeleteCreateExpandedReplacement(t *testing.T) { oldEntry := newEntry("mod", "assets", "fs.embed") replacementEntry := newEntry("mod", "assets", "fs.directory") - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -249,7 +249,7 @@ func TestPlanner_Expand_AllowsDeleteCreateExpandedReplacement(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) require.Len(t, plan.Ops, 3) @@ -274,7 +274,7 @@ func TestPlanner_Expand_AllowsDeleteCreateExpandedReplacement(t *testing.T) { } func TestPlanner_Expand_InvalidResult_NotAppliedButHasData(t *testing.T) { - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: false, Additional: []registry.ScopedOperation{ @@ -287,13 +287,13 @@ func TestPlanner_Expand_InvalidResult_NotAppliedButHasData(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - _, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + _, err := p.Expand(context.Background(), changes, nil) require.Error(t, err) } func TestPlanner_Expand_ScopeOverride(t *testing.T) { scope := registry.ScopeBaseline - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, OriginalScope: &scope, @@ -304,14 +304,14 @@ func TestPlanner_Expand_ScopeOverride(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) assert.Equal(t, registry.ScopeBaseline, plan.Ops[0].Scope) } func TestPlanner_Expand_KindFromSnapshot(t *testing.T) { extra := newEntry("mod", "extra", "service") - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{ @@ -327,9 +327,9 @@ func TestPlanner_Expand_KindFromSnapshot(t *testing.T) { Kind: registry.EntryUpdate, Entry: registry.Entry{ID: registry.NewID("app", "dep")}, } - snapshot := registry.ProvenancedState{Entries: registry.State{ + snapshot := registry.State{ {ID: registry.NewID("app", "dep"), Kind: "dep"}, - }} + } plan, err := p.Expand(context.Background(), registry.ChangeSet{op}, snapshot) require.NoError(t, err) @@ -339,7 +339,7 @@ func TestPlanner_Expand_KindFromSnapshot(t *testing.T) { func TestPlanner_Expand_Effects(t *testing.T) { eff := &testEffect{} - dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + dir := &stubDirective{expandFunc: func(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{ Applied: true, Effects: []registry.Effect{eff}, @@ -350,7 +350,7 @@ func TestPlanner_Expand_Effects(t *testing.T) { entry := newEntry("app", "dep", "dep") changes := registry.ChangeSet{newOp(registry.EntryCreate, entry)} - plan, err := p.Expand(context.Background(), changes, registry.ProvenancedState{}) + plan, err := p.Expand(context.Background(), changes, nil) require.NoError(t, err) assert.Len(t, plan.Effects, 1) } @@ -513,10 +513,10 @@ func TestPlanner_ExpandErrorRollsBackEarlierUnpreparedEffects(t *testing.T) { kind := registry.Kind("test.kind") planner := NewPlanner(map[registry.Kind][]registry.Directive{ kind: { - &stubDirective{expandFunc: func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) { + &stubDirective{expandFunc: func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{Applied: true, Effects: []registry.Effect{effect}}, nil }}, - &stubDirective{expandFunc: func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) { + &stubDirective{expandFunc: func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{}, errors.New("later planning failure") }}, }, @@ -525,7 +525,7 @@ func TestPlanner_ExpandErrorRollsBackEarlierUnpreparedEffects(t *testing.T) { _, err := planner.Expand(context.Background(), registry.ChangeSet{{ Kind: registry.EntryCreate, Entry: registry.Entry{ID: registry.NewID("test", "entry"), Kind: kind}, - }}, registry.ProvenancedState{}) + }}, nil) require.ErrorContains(t, err, "later planning failure") assert.Equal(t, 1, effect.rollbackCall) } diff --git a/system/registry/history/memory/memory.go b/system/registry/history/memory/memory.go index 41055182e..610223023 100644 --- a/system/registry/history/memory/memory.go +++ b/system/registry/history/memory/memory.go @@ -87,7 +87,17 @@ func (m *Storage) Get(version registry.Version) (registry.ChangeSet, error) { return nil, NewVersionNotFoundError(version.String()) } - actionsCopy := cloneChangeSet(actions) + actionsCopy := make(registry.ChangeSet, len(actions)) + for i, op := range actions { + actionsCopy[i] = registry.Operation{ + Kind: op.Kind, + Entry: cloneEntry(op.Entry), + } + if op.OriginalEntry != nil { + clonedOriginal := cloneEntry(*op.OriginalEntry) + actionsCopy[i].OriginalEntry = &clonedOriginal + } + } return actionsCopy, nil } @@ -153,11 +163,11 @@ func cloneEntry(e registry.Entry) registry.Entry { data = payload.NewPayload(snapshotValue(e.Data.Data()), e.Data.Format()) } return registry.Entry{ - ID: e.ID, - Kind: e.Kind, - Meta: meta, - Data: data, - DependencyRoot: e.DependencyRoot, + ID: e.ID, + Kind: e.Kind, + Meta: meta, + Data: data, + Registry: e.Registry, } } @@ -198,23 +208,10 @@ func snapshotValue(value any) any { } } -func cloneProvenance(p *registry.EntryProvenance) *registry.EntryProvenance { - if p == nil { - return nil - } - cloned := *p - return &cloned -} - func cloneChangeSet(actions registry.ChangeSet) registry.ChangeSet { cloned := make(registry.ChangeSet, len(actions)) for i, op := range actions { - cloned[i] = registry.Operation{ - Kind: op.Kind, - Entry: cloneEntry(op.Entry), - Provenance: cloneProvenance(op.Provenance), - OriginalProvenance: cloneProvenance(op.OriginalProvenance), - } + cloned[i] = registry.Operation{Kind: op.Kind, Entry: cloneEntry(op.Entry)} if op.OriginalEntry != nil { original := cloneEntry(*op.OriginalEntry) cloned[i].OriginalEntry = &original diff --git a/system/registry/history/postgres/postgres.go b/system/registry/history/postgres/postgres.go index 4ea1a8600..a75e2abc1 100644 --- a/system/registry/history/postgres/postgres.go +++ b/system/registry/history/postgres/postgres.go @@ -21,6 +21,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/internal/version" historyschema "github.com/wippyai/runtime/system/registry/history/schema" + migrationstorage "github.com/wippyai/runtime/system/registry/migration/storage" schemamanager "github.com/wippyai/runtime/system/schema" "go.uber.org/zap" ) @@ -32,9 +33,18 @@ type History struct { handle *codec.MsgpackHandle log *zap.Logger queries postgresQueries + tables postgresTables mu sync.RWMutex } +type postgresTables struct { + changesets string + versions string + schemaVersion string + schemaUpdateHistory string + schemaName string +} + type postgresQueries struct { getResolution string getResolutionGraph string @@ -66,23 +76,17 @@ type encodedPayload struct { } type encodedEntry struct { - Meta attrs.Bag `codec:"Meta"` - Data *encodedPayload `codec:"Data"` - ID registry.ID `codec:"ID"` - Kind string `codec:"Kind"` - // DependencyRoot decodes from legacy rows; new rows never write it. - DependencyRoot bool `codec:"DependencyRoot,omitempty"` + Meta attrs.Bag + Data *encodedPayload + ID registry.ID + Kind string + Registry registry.EntryMetadata } -// encodedOperation is the wire shape of one changeset operation. The optional -// provenance fields are absent on rows written before they existed and decode -// as nil. type encodedOperation struct { - OriginalEntry *encodedEntry `codec:"OriginalEntry"` - Provenance *registry.EntryProvenance `codec:"prov,omitempty"` - OriginalProvenance *registry.EntryProvenance `codec:"oprov,omitempty"` - Kind string `codec:"Kind"` - Entry encodedEntry `codec:"Entry"` + OriginalEntry *encodedEntry + Kind string + Entry encodedEntry } func newMsgpackHandle() *codec.MsgpackHandle { @@ -143,6 +147,7 @@ func NewPostgres(dsn string, schemaName string, log *zap.Logger) (*History, erro handle: newMsgpackHandle(), log: log, queries: buildQueries(schemaName), + tables: buildTables(schemaName), } if err := h.ensureRootVersion(); err != nil { @@ -153,6 +158,28 @@ func NewPostgres(dsn string, schemaName string, log *zap.Logger) (*History, erro return h, nil } +// MigrateEntryMetadata normalizes persisted rows before registry replay. +func MigrateEntryMetadata(ctx context.Context, h *History, baseline registry.State) error { + h.mu.Lock() + defer h.mu.Unlock() + return migrationstorage.RewriteEntryMetadata(ctx, h.db, h.handle, migrationstorage.Tables{ + ChangeSets: h.tables.changesets, + SchemaVersion: h.tables.schemaVersion, + UpdateHistory: h.tables.schemaUpdateHistory, + }, func(index int) string { + return "$" + strconv.Itoa(index) + }, func(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "LOCK TABLE "+h.tables.versions+" IN SHARE ROW EXCLUSIVE MODE"); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "LOCK TABLE "+h.tables.changesets+" IN SHARE ROW EXCLUSIVE MODE"); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))", "registry_history:registry_history.entry_metadata", h.tables.schemaName) + return err + }, baseline) +} + func buildQueries(schemaName string) postgresQueries { table := func(name string) string { return schemamanager.QuoteIdentifier(schemaName) + "." + schemamanager.QuoteIdentifier(name) @@ -203,6 +230,19 @@ func buildQueries(schemaName string) postgresQueries { } } +func buildTables(schemaName string) postgresTables { + table := func(name string) string { + return schemamanager.QuoteIdentifier(schemaName) + "." + schemamanager.QuoteIdentifier(name) + } + return postgresTables{ + changesets: table("changesets"), + versions: table("versions"), + schemaVersion: table("schema_version"), + schemaUpdateHistory: table("schema_update_history"), + schemaName: schemaName, + } +} + func (h *History) ensureRootVersion() error { h.mu.Lock() defer h.mu.Unlock() @@ -386,10 +426,10 @@ func (h *History) decodeChangeSet(data []byte) (registry.ChangeSet, error) { cs := make(registry.ChangeSet, len(encodedOps)) for i, encOp := range encodedOps { entry := registry.Entry{ - ID: encOp.Entry.ID, - Kind: encOp.Entry.Kind, - Meta: encOp.Entry.Meta, - DependencyRoot: encOp.Entry.DependencyRoot, + ID: encOp.Entry.ID, + Kind: encOp.Entry.Kind, + Meta: encOp.Entry.Meta, + Registry: encOp.Entry.Registry, } if encOp.Entry.Data != nil { @@ -397,18 +437,16 @@ func (h *History) decodeChangeSet(data []byte) (registry.ChangeSet, error) { } op := registry.Operation{ - Kind: encOp.Kind, - Entry: entry, - Provenance: encOp.Provenance, - OriginalProvenance: encOp.OriginalProvenance, + Kind: encOp.Kind, + Entry: entry, } if encOp.OriginalEntry != nil { originalEntry := registry.Entry{ - ID: encOp.OriginalEntry.ID, - Kind: encOp.OriginalEntry.Kind, - Meta: encOp.OriginalEntry.Meta, - DependencyRoot: encOp.OriginalEntry.DependencyRoot, + ID: encOp.OriginalEntry.ID, + Kind: encOp.OriginalEntry.Kind, + Meta: encOp.OriginalEntry.Meta, + Registry: encOp.OriginalEntry.Registry, } if encOp.OriginalEntry.Data != nil { @@ -572,24 +610,24 @@ func (h *History) SaveWithDependencyResolution(v registry.Version, cs registry.C } encOriginal = &encodedEntry{ - ID: op.OriginalEntry.ID, - Kind: op.OriginalEntry.Kind, - Meta: op.OriginalEntry.Meta, - Data: encOrigPayload, + ID: op.OriginalEntry.ID, + Kind: op.OriginalEntry.Kind, + Meta: op.OriginalEntry.Meta, + Data: encOrigPayload, + Registry: op.OriginalEntry.Registry, } } encodedOps[i] = encodedOperation{ Kind: op.Kind, Entry: encodedEntry{ - ID: op.Entry.ID, - Kind: op.Entry.Kind, - Meta: op.Entry.Meta, - Data: encPayload, + ID: op.Entry.ID, + Kind: op.Entry.Kind, + Meta: op.Entry.Meta, + Data: encPayload, + Registry: op.Entry.Registry, }, - OriginalEntry: encOriginal, - Provenance: op.Provenance, - OriginalProvenance: op.OriginalProvenance, + OriginalEntry: encOriginal, } } diff --git a/system/registry/history/postgres/postgres_test.go b/system/registry/history/postgres/postgres_test.go index 71f56e7be..a6c30808f 100644 --- a/system/registry/history/postgres/postgres_test.go +++ b/system/registry/history/postgres/postgres_test.go @@ -3,6 +3,7 @@ package postgres import ( + "bytes" "context" "database/sql" "fmt" @@ -11,14 +12,45 @@ import ( "sync" "testing" + "github.com/hashicorp/go-msgpack/v2/codec" _ "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/api/payload" "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/internal/version" "go.uber.org/zap" ) +type releasedMigrationPayload struct { + Data any + Format payload.Format +} + +type releasedMigrationEntry struct { + Meta attrs.Bag + Data *releasedMigrationPayload + ID registry.ID + Kind string + DependencyRoot bool `codec:"DependencyRoot,omitempty"` +} + +type releasedMigrationRecord struct { + Module string + Version string + Digest string + Root bool +} + +type releasedMigrationOperation struct { + OriginalEntry *releasedMigrationEntry `codec:"OriginalEntry"` + Current *releasedMigrationRecord `codec:"prov,omitempty"` + Previous *releasedMigrationRecord `codec:"oprov,omitempty"` + Kind string `codec:"Kind"` + Entry releasedMigrationEntry `codec:"Entry"` +} + func TestNewPostgresRequiresDSN(t *testing.T) { hist, err := NewPostgres("", "wippy_registry", zap.NewNop()) @@ -180,6 +212,88 @@ func TestPostgresHistory_SaveAndGet(t *testing.T) { assert.Equal(t, "1.1", schemaVersion) } +func TestMigrateEntryMetadata_RewritesPostgresBranches(t *testing.T) { + dsn := os.Getenv("WIPPY_POSTGRES_HISTORY_TEST_DSN") + if strings.TrimSpace(dsn) == "" { + t.Skip("WIPPY_POSTGRES_HISTORY_TEST_DSN is not set") + } + + schemaName := fmt.Sprintf("wippy_registry_metadata_%d", os.Getpid()) + db, err := sql.Open("postgres", dsn) + require.NoError(t, err) + defer func() { _ = db.Close() }() + _, err = db.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schemaName)) + require.NoError(t, err) + defer func() { + _, _ = db.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schemaName)) + }() + + history, err := NewPostgres(dsn, schemaName, zap.NewNop()) + require.NoError(t, err) + defer func() { _ = history.Close() }() + + id := registry.NewID("app", "dependency") + other := registry.NewID("app", "other") + seedReleasedMigrationChangeSet(t, history, 1, 0, []releasedMigrationOperation{{ + Kind: registry.EntryCreate, + Entry: releasedMigrationEntry{ + ID: id, + Kind: registry.NamespaceDependency, + Meta: attrs.NewBagFrom(map[string]any{"module": "active/module", "module_version": "1.0.0", "module_digest": "sha256:old", "author_key": "preserved"}), + DependencyRoot: true, + }, + Current: &releasedMigrationRecord{Module: "active/module", Root: true}, + }}) + seedReleasedMigrationChangeSet(t, history, 2, 1, []releasedMigrationOperation{{ + Kind: registry.EntryDelete, + Entry: releasedMigrationEntry{ID: id, Kind: registry.NamespaceDependency}, + Current: &releasedMigrationRecord{Module: "active/module"}, + }}) + seedReleasedMigrationChangeSet(t, history, 3, 1, []releasedMigrationOperation{{ + Kind: registry.EntryCreate, + Entry: releasedMigrationEntry{ID: other, Kind: registry.EntryKind, Meta: attrs.NewBagFrom(map[string]any{"module": "stamped/only"})}, + }}) + + baseline := registry.State{{ID: id, Kind: registry.NamespaceDependency, Registry: registry.EntryMetadata{Owner: "active/module"}}} + require.NoError(t, MigrateEntryMetadata(context.Background(), history, baseline)) + + v1 := version.FromParent(version.New(0), 1) + v2 := version.FromParent(v1, 2) + v3 := version.FromParent(v1, 3) + create, err := history.Get(v1) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "active/module", Root: true}, create[0].Entry.Registry) + require.NotContains(t, create[0].Entry.Meta, "module") + require.Equal(t, "preserved", create[0].Entry.Meta["author_key"]) + remove, err := history.Get(v2) + require.NoError(t, err) + require.Equal(t, "active/module", remove[0].Entry.Registry.Owner) + branch, err := history.Get(v3) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "stamped/only"}, branch[0].Entry.Registry) + + before := postgresChangesetBytes(t, history, 1) + require.NoError(t, MigrateEntryMetadata(context.Background(), history, baseline)) + require.Equal(t, before, postgresChangesetBytes(t, history, 1)) +} + +func seedReleasedMigrationChangeSet(t *testing.T, history *History, id, parent uint, operations []releasedMigrationOperation) { + t.Helper() + var data bytes.Buffer + require.NoError(t, codec.NewEncoder(&data, history.handle).Encode(operations)) + _, err := history.db.ExecContext(t.Context(), history.queries.insertVersion, id, parent) + require.NoError(t, err) + _, err = history.db.ExecContext(t.Context(), "INSERT INTO "+history.tables.changesets+" (version_id, data) VALUES ($1, $2)", id, data.Bytes()) + require.NoError(t, err) +} + +func postgresChangesetBytes(t *testing.T, history *History, id uint) []byte { + t.Helper() + var data []byte + require.NoError(t, history.db.QueryRowContext(t.Context(), "SELECT data FROM "+history.tables.changesets+" WHERE version_id = $1", id).Scan(&data)) + return data +} + func TestPostgresHistory_ConcurrentColdOpenInitializesRootOnce(t *testing.T) { dsn := os.Getenv("WIPPY_POSTGRES_HISTORY_TEST_DSN") if strings.TrimSpace(dsn) == "" { diff --git a/system/registry/history/postgres/wire_provenance_test.go b/system/registry/history/postgres/wire_provenance_test.go deleted file mode 100644 index 0c6dd8493..000000000 --- a/system/registry/history/postgres/wire_provenance_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package postgres - -import ( - "bytes" - "testing" - - "github.com/hashicorp/go-msgpack/v2/codec" - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/registry" -) - -// legacyEncodedOperation is the operation wire shape before provenance fields -// existed, used to produce byte-exact legacy rows. -type legacyEncodedOperation struct { - OriginalEntry *encodedEntry - Kind string - Entry encodedEntry -} - -// TestWire_LegacyRowDecodes pins that a row written by the pre-provenance -// encoder decodes under the current decoder with nil provenance fields. -func TestWire_LegacyRowDecodes(t *testing.T) { - h := &History{handle: newMsgpackHandle()} - - legacy := []legacyEncodedOperation{{ - Kind: registry.EntryUpdate, - Entry: encodedEntry{ - ID: registry.NewID("test", "cache"), - Kind: registry.NamespaceDependency, - DependencyRoot: true, - }, - }} - var buf bytes.Buffer - require.NoError(t, codec.NewEncoder(&buf, h.handle).Encode(legacy)) - - cs, err := h.decodeChangeSet(buf.Bytes()) - require.NoError(t, err) - require.Len(t, cs, 1) - require.Equal(t, registry.EntryUpdate, cs[0].Kind) - require.True(t, cs[0].Entry.DependencyRoot, "PostgreSQL must decode the legacy root statement") - require.Nil(t, cs[0].Provenance) - require.Nil(t, cs[0].OriginalProvenance) -} - -// TestWire_EmittedKeys pins the map keys the encoder emits: the v5 prov/oprov -// schema with empty halves omitted, the dead root flag never written, and a -// legacy decoder tolerating a new row. -func TestWire_EmittedKeys(t *testing.T) { - handle := newMsgpackHandle() - ops := []encodedOperation{{ - Kind: registry.EntryCreate, - Entry: encodedEntry{ID: registry.NewID("test", "cache"), Kind: "store.memory"}, - Provenance: ®istry.EntryProvenance{Module: "org/mod"}, - }} - var buf bytes.Buffer - require.NoError(t, codec.NewEncoder(&buf, handle).Encode(ops)) - - var raw []map[string]any - require.NoError(t, codec.NewDecoder(bytes.NewReader(buf.Bytes()), handle).Decode(&raw)) - require.Len(t, raw, 1) - require.Contains(t, raw[0], "prov") - require.NotContains(t, raw[0], "oprov", "an absent pair half is omitted") - entry, ok := raw[0]["Entry"].(map[string]any) - require.True(t, ok) - require.NotContains(t, entry, "DependencyRoot", "new rows never write the dead root flag") - prov, ok := raw[0]["prov"].(map[string]any) - require.True(t, ok) - require.Equal(t, map[string]any{"module": "org/mod"}, prov) - - var legacy []legacyEncodedOperation - require.NoError(t, codec.NewDecoder(bytes.NewReader(buf.Bytes()), handle).Decode(&legacy)) - require.Len(t, legacy, 1) - require.Equal(t, registry.EntryCreate, legacy[0].Kind) -} diff --git a/system/registry/history/sqlite/metadata_migration_test.go b/system/registry/history/sqlite/metadata_migration_test.go new file mode 100644 index 000000000..65adc7aac --- /dev/null +++ b/system/registry/history/sqlite/metadata_migration_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 + +package sqlite + +import ( + "bytes" + "context" + "path/filepath" + "sync" + "testing" + + "github.com/hashicorp/go-msgpack/v2/codec" + "github.com/stretchr/testify/require" + "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/api/payload" + "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/internal/version" + "go.uber.org/zap" +) + +type releasedPayload struct { + Data any + Format payload.Format +} + +type releasedEntry struct { + Meta attrs.Bag + Data *releasedPayload + ID registry.ID + Kind string + DependencyRoot bool `codec:"DependencyRoot,omitempty"` +} + +type releasedRecord struct { + Module string + Version string + Digest string + Root bool +} + +type releasedOperation struct { + OriginalEntry *releasedEntry `codec:"OriginalEntry"` + Current *releasedRecord `codec:"prov,omitempty"` + Previous *releasedRecord `codec:"oprov,omitempty"` + Kind string `codec:"Kind"` + Entry releasedEntry `codec:"Entry"` +} + +func TestMigrateEntryMetadata_RewritesAllHistoryBranches(t *testing.T) { + path := filepath.Join(t.TempDir(), "registry.db") + history, err := NewSQLite(path, zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, history.Close()) }) + + id := registry.NewID("app", "dependency") + other := registry.NewID("app", "other") + seedReleasedChangeset(t, history, 1, 0, []releasedOperation{{ + Kind: registry.EntryCreate, + Entry: releasedEntry{ + ID: id, + Kind: registry.NamespaceDependency, + Meta: attrs.NewBagFrom(map[string]any{"module": "active/module", "module_version": "1.0.0", "module_digest": "sha256:old", "author_key": "preserved"}), + DependencyRoot: true, + }, + Current: &releasedRecord{Module: "active/module", Version: "1.0.0", Digest: "sha256:released", Root: true}, + }}) + seedReleasedChangeset(t, history, 2, 1, []releasedOperation{{ + Kind: registry.EntryUpdate, + Entry: releasedEntry{ID: id, Kind: registry.NamespaceDependency}, + OriginalEntry: &releasedEntry{ + ID: id, Kind: registry.NamespaceDependency, + }, + Current: &releasedRecord{Module: "active/module", Root: false}, + }}) + seedReleasedChangeset(t, history, 3, 2, []releasedOperation{{ + Kind: registry.EntryDelete, + Entry: releasedEntry{ID: id, Kind: registry.NamespaceDependency}, + Current: &releasedRecord{Module: "active/module"}, + }}) + // v4 is a sibling of v1, proving every branch is converted rather than only + // the current head lineage. + seedReleasedChangeset(t, history, 4, 1, []releasedOperation{{ + Kind: registry.EntryCreate, + Entry: releasedEntry{ID: other, Kind: registry.EntryKind, Meta: attrs.NewBagFrom(map[string]any{"module": "stamped/only"})}, + }}) + + baseline := registry.State{{ + ID: id, + Kind: registry.NamespaceDependency, + Registry: registry.EntryMetadata{Owner: "active/module", Root: true}, + }} + require.NoError(t, MigrateEntryMetadata(context.Background(), history, baseline)) + var migrationVersion string + require.NoError(t, history.db.QueryRowContext(t.Context(), `SELECT curr_version FROM schema_version WHERE name = 'registry_history.entry_metadata'`).Scan(&migrationVersion)) + require.Equal(t, "1.1", migrationVersion) + var migrationAudits int + require.NoError(t, history.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM schema_update_history WHERE name = 'registry_history.entry_metadata'`).Scan(&migrationAudits)) + require.Equal(t, 1, migrationAudits) + + v1 := version.FromParent(version.New(0), 1) + v2 := version.FromParent(v1, 2) + v3 := version.FromParent(v2, 3) + v4 := version.FromParent(v1, 4) + first, err := history.Get(v1) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "active/module", Root: true}, first[0].Entry.Registry) + require.NotContains(t, first[0].Entry.Meta, "module") + require.NotContains(t, first[0].Entry.Meta, "module_version") + require.NotContains(t, first[0].Entry.Meta, "module_digest") + require.Equal(t, "preserved", first[0].Entry.Meta["author_key"]) + + second, err := history.Get(v2) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "active/module"}, second[0].Entry.Registry) + require.NotNil(t, second[0].OriginalEntry) + require.Equal(t, registry.EntryMetadata{Owner: "active/module", Root: true}, second[0].OriginalEntry.Registry, "rollback input inherits the active baseline root when no persisted root state exists") + + third, err := history.Get(v3) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "active/module"}, third[0].Entry.Registry, "delete rollback input keeps the active baseline root") + + branch, err := history.Get(v4) + require.NoError(t, err) + require.Equal(t, registry.EntryMetadata{Owner: "stamped/only"}, branch[0].Entry.Registry) + require.NotContains(t, branch[0].Entry.Meta, "module") + + before := changesetBytes(t, history, 1) + require.NoError(t, history.Close()) + history, err = NewSQLite(path, zap.NewNop()) + require.NoError(t, err) + // A later deployment baseline must not rewrite already-normalized history. + changedBaseline := append(registry.State(nil), baseline...) + changedBaseline[0].Registry.Owner = "next/module" + require.NoError(t, MigrateEntryMetadata(context.Background(), history, changedBaseline)) + require.Equal(t, before, changesetBytes(t, history, 1), "second boot must not rewrite normalized history") + first, err = history.Get(v1) + require.NoError(t, err) + require.Equal(t, "active/module", first[0].Entry.Registry.Owner) +} + +func TestMigrateEntryMetadata_RejectsConflictingOwnerAtomically(t *testing.T) { + path := filepath.Join(t.TempDir(), "registry.db") + history, err := NewSQLite(path, zap.NewNop()) + require.NoError(t, err) + defer func() { require.NoError(t, history.Close()) }() + + id := registry.NewID("app", "entry") + seedReleasedChangeset(t, history, 1, 0, []releasedOperation{{ + Kind: registry.EntryCreate, + Entry: releasedEntry{ID: id, Kind: registry.EntryKind}, + Current: &releasedRecord{Module: "released/module"}, + }}) + before := changesetBytes(t, history, 1) + err = MigrateEntryMetadata(context.Background(), history, registry.State{{ + ID: id, Kind: registry.EntryKind, Registry: registry.EntryMetadata{Owner: "active/module"}, + }}) + require.ErrorContains(t, err, "conflicting owners") + require.Equal(t, before, changesetBytes(t, history, 1), "failed migration must not write any history row") + var count int + require.NoError(t, history.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM schema_version WHERE name = 'registry_history.entry_metadata'`).Scan(&count)) + require.Zero(t, count, "failed migration must not advance its ledger") +} + +func TestMigrateEntryMetadata_ConcurrentOpenersAdvanceLedgerOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "registry.db") + first, err := NewSQLite(path, zap.NewNop()) + require.NoError(t, err) + defer func() { require.NoError(t, first.Close()) }() + second, err := NewSQLite(path, zap.NewNop()) + require.NoError(t, err) + defer func() { require.NoError(t, second.Close()) }() + + id := registry.NewID("app", "entry") + seedReleasedChangeset(t, first, 1, 0, []releasedOperation{{ + Kind: registry.EntryCreate, + Entry: releasedEntry{ID: id, Kind: registry.EntryKind}, + Current: &releasedRecord{Module: "active/module"}, + }}) + baseline := registry.State{{ID: id, Kind: registry.EntryKind, Registry: registry.EntryMetadata{Owner: "active/module"}}} + + start := make(chan struct{}) + errs := make(chan error, 2) + var wait sync.WaitGroup + for _, history := range []*History{first, second} { + wait.Add(1) + go func(history *History) { + defer wait.Done() + <-start + errs <- MigrateEntryMetadata(context.Background(), history, baseline) + }(history) + } + close(start) + wait.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + var count int + require.NoError(t, first.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM schema_update_history WHERE name = 'registry_history.entry_metadata'`).Scan(&count)) + require.Equal(t, 1, count) +} + +func seedReleasedChangeset(t *testing.T, history *History, id, parent uint, operations []releasedOperation) { + t.Helper() + var data bytes.Buffer + encoder := codec.NewEncoder(&data, history.handle) + require.NoError(t, encoder.Encode(operations)) + _, err := history.db.ExecContext(t.Context(), `INSERT INTO versions (id, parent_id) VALUES (?, ?)`, id, parent) + require.NoError(t, err) + _, err = history.db.ExecContext(t.Context(), `INSERT INTO changesets (version_id, data) VALUES (?, ?)`, id, data.Bytes()) + require.NoError(t, err) +} + +func changesetBytes(t *testing.T, history *History, id uint) []byte { + t.Helper() + var data []byte + require.NoError(t, history.db.QueryRowContext(t.Context(), `SELECT data FROM changesets WHERE version_id = ?`, id).Scan(&data)) + return data +} diff --git a/system/registry/history/sqlite/sqlite.go b/system/registry/history/sqlite/sqlite.go index 842bb9169..accb45e82 100644 --- a/system/registry/history/sqlite/sqlite.go +++ b/system/registry/history/sqlite/sqlite.go @@ -21,6 +21,7 @@ import ( "github.com/wippyai/runtime/api/registry" "github.com/wippyai/runtime/internal/version" historyschema "github.com/wippyai/runtime/system/registry/history/schema" + migrationstorage "github.com/wippyai/runtime/system/registry/migration/storage" schemamanager "github.com/wippyai/runtime/system/schema" "go.uber.org/zap" ) @@ -48,23 +49,17 @@ type encodedPayload struct { } type encodedEntry struct { - Meta attrs.Bag `codec:"Meta"` - Data *encodedPayload `codec:"Data"` - ID registry.ID `codec:"ID"` - Kind string `codec:"Kind"` - // DependencyRoot decodes from legacy rows; new rows never write it. - DependencyRoot bool `codec:"DependencyRoot,omitempty"` + Meta attrs.Bag + Data *encodedPayload + ID registry.ID + Kind string + Registry registry.EntryMetadata } -// encodedOperation is the wire shape of one changeset operation. The optional -// provenance fields are absent on rows written before they existed and decode -// as nil. type encodedOperation struct { - OriginalEntry *encodedEntry `codec:"OriginalEntry"` - Provenance *registry.EntryProvenance `codec:"prov,omitempty"` - OriginalProvenance *registry.EntryProvenance `codec:"oprov,omitempty"` - Kind string `codec:"Kind"` - Entry encodedEntry `codec:"Entry"` + OriginalEntry *encodedEntry + Kind string + Entry encodedEntry } func newMsgpackHandle() *codec.MsgpackHandle { @@ -131,6 +126,23 @@ func NewSQLite(dbPath string, log *zap.Logger) (*History, error) { return h, nil } +// MigrateEntryMetadata normalizes persisted rows before registry replay. +func MigrateEntryMetadata(ctx context.Context, h *History, baseline registry.State) error { + h.mu.Lock() + defer h.mu.Unlock() + return migrationstorage.RewriteEntryMetadata(ctx, h.db, h.handle, migrationstorage.Tables{ + ChangeSets: "changesets", + SchemaVersion: "schema_version", + UpdateHistory: "schema_update_history", + }, func(int) string { return "?" }, func(ctx context.Context, tx *sql.Tx) error { + // BeginTx is deferred in SQLite. This no-op write acquires the writer + // reservation before history rows are read, so an existing writer + // finishes first and a new writer waits for this migration to commit. + _, err := tx.ExecContext(ctx, "UPDATE schema_version SET updated_at = updated_at WHERE name = ?", historyschema.BundleName) + return err + }, baseline) +} + func (h *History) ensureRootVersion() error { h.mu.Lock() defer h.mu.Unlock() @@ -315,10 +327,10 @@ func (h *History) decodeChangeSet(data []byte) (registry.ChangeSet, error) { cs := make(registry.ChangeSet, len(encodedOps)) for i, encOp := range encodedOps { entry := registry.Entry{ - ID: encOp.Entry.ID, - Kind: encOp.Entry.Kind, - Meta: encOp.Entry.Meta, - DependencyRoot: encOp.Entry.DependencyRoot, + ID: encOp.Entry.ID, + Kind: encOp.Entry.Kind, + Meta: encOp.Entry.Meta, + Registry: encOp.Entry.Registry, } if encOp.Entry.Data != nil { @@ -326,18 +338,16 @@ func (h *History) decodeChangeSet(data []byte) (registry.ChangeSet, error) { } op := registry.Operation{ - Kind: encOp.Kind, - Entry: entry, - Provenance: encOp.Provenance, - OriginalProvenance: encOp.OriginalProvenance, + Kind: encOp.Kind, + Entry: entry, } if encOp.OriginalEntry != nil { originalEntry := registry.Entry{ - ID: encOp.OriginalEntry.ID, - Kind: encOp.OriginalEntry.Kind, - Meta: encOp.OriginalEntry.Meta, - DependencyRoot: encOp.OriginalEntry.DependencyRoot, + ID: encOp.OriginalEntry.ID, + Kind: encOp.OriginalEntry.Kind, + Meta: encOp.OriginalEntry.Meta, + Registry: encOp.OriginalEntry.Registry, } if encOp.OriginalEntry.Data != nil { @@ -510,24 +520,24 @@ func (h *History) SaveWithDependencyResolution(v registry.Version, cs registry.C } encOriginal = &encodedEntry{ - ID: op.OriginalEntry.ID, - Kind: op.OriginalEntry.Kind, - Meta: op.OriginalEntry.Meta, - Data: encOrigPayload, + ID: op.OriginalEntry.ID, + Kind: op.OriginalEntry.Kind, + Meta: op.OriginalEntry.Meta, + Data: encOrigPayload, + Registry: op.OriginalEntry.Registry, } } encodedOps[i] = encodedOperation{ Kind: op.Kind, Entry: encodedEntry{ - ID: op.Entry.ID, - Kind: op.Entry.Kind, - Meta: op.Entry.Meta, - Data: encPayload, + ID: op.Entry.ID, + Kind: op.Entry.Kind, + Meta: op.Entry.Meta, + Data: encPayload, + Registry: op.Entry.Registry, }, - OriginalEntry: encOriginal, - Provenance: op.Provenance, - OriginalProvenance: op.OriginalProvenance, + OriginalEntry: encOriginal, } } diff --git a/system/registry/history/sqlite/sqlite_test.go b/system/registry/history/sqlite/sqlite_test.go index 1829f4343..b8b93e463 100644 --- a/system/registry/history/sqlite/sqlite_test.go +++ b/system/registry/history/sqlite/sqlite_test.go @@ -22,15 +22,6 @@ import ( "go.uber.org/zap" ) -// hostProvenanced wraps a raw test state with host provenance records. -func hostProvenanced(s registry.State) registry.ProvenancedState { - prov := make(registry.ProvenanceMap, len(s)) - for _, e := range s { - prov[e.ID] = registry.EntryProvenance{} - } - return registry.ProvenancedState{Entries: s, Provenance: prov} -} - type testRunner struct{} func (r *testRunner) Transition(_ context.Context, from registry.State, cs registry.ChangeSet) (registry.State, error) { @@ -505,7 +496,7 @@ func TestHistoryReplayUpdateReplacesCanonicalBaselineID(t *testing.T) { require.NoError(t, hist.Save(v1, registry.ChangeSet{{Kind: registry.EntryUpdate, Entry: updated, OriginalEntry: &baseline}}, true)) reg := registrysystem.NewRegistry(hist, &testRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop()) - require.NoError(t, reg.LoadState(context.Background(), hostProvenanced(registry.State{baseline}), v1)) + require.NoError(t, reg.LoadState(context.Background(), registry.State{baseline}, v1)) entry, err := reg.GetEntry(id) require.NoError(t, err) data, ok := entry.Data.Data().(map[string]any) @@ -743,30 +734,29 @@ func TestSQLitePersistence_OriginalEntry(t *testing.T) { Data: payload.NewString("baseline"), }, } - err = reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err = reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) entryID := registry.NewID("test", "entry1") v1, err := reg.Apply(ctx, registry.ChangeSet{ {Kind: registry.EntryCreate, Entry: registry.Entry{ - ID: entryID, Kind: "service", Data: payload.NewString("v1"), DependencyRoot: true, + ID: entryID, Kind: "service", Data: payload.NewString("v1"), }}, }) require.NoError(t, err) - rootProv := ®istry.EntryProvenance{Root: true} v2, err := reg.Apply(ctx, registry.ChangeSet{ {Kind: registry.EntryUpdate, Entry: registry.Entry{ ID: entryID, Kind: "service", Data: payload.NewString("v2"), - }, Provenance: rootProv}, + }}, }) require.NoError(t, err) _, err = reg.Apply(ctx, registry.ChangeSet{ {Kind: registry.EntryUpdate, Entry: registry.Entry{ ID: entryID, Kind: "service", Data: payload.NewString("v3"), - }, Provenance: rootProv}, + }}, }) require.NoError(t, err) @@ -781,15 +771,12 @@ func TestSQLitePersistence_OriginalEntry(t *testing.T) { require.NoError(t, err) require.Len(t, cs1, 1) assert.Nil(t, cs1[0].OriginalEntry, "Create operation should not have OriginalEntry") - assert.False(t, cs1[0].Entry.DependencyRoot, "entry payloads never carry the deployment-root flag") cs2, err := hist2.Get(v2) require.NoError(t, err) require.Len(t, cs2, 1) require.NotNil(t, cs2[0].OriginalEntry, "Update operation MUST have OriginalEntry after loading from SQLite") assert.Equal(t, "v1", cs2[0].OriginalEntry.Data.Data().(string), "OriginalEntry should contain v1 data") - require.NotNil(t, cs2[0].Provenance, "operation provenance must survive SQLite persistence") - assert.True(t, cs2[0].Provenance.Root, "root provenance must survive SQLite persistence") runner2 := &testRunner{} builder2 := topology.NewStateBuilder(logger, nil) @@ -797,7 +784,7 @@ func TestSQLitePersistence_OriginalEntry(t *testing.T) { head, err := hist2.Head() require.NoError(t, err) - err = reg2.LoadState(ctx, hostProvenanced(baseline), head) + err = reg2.LoadState(ctx, baseline, head) require.NoError(t, err) err = reg2.ApplyVersion(ctx, v1) diff --git a/system/registry/history/sqlite/wire_provenance_test.go b/system/registry/history/sqlite/wire_provenance_test.go deleted file mode 100644 index af743c38e..000000000 --- a/system/registry/history/sqlite/wire_provenance_test.go +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package sqlite - -import ( - "bytes" - "path/filepath" - "testing" - - "github.com/hashicorp/go-msgpack/v2/codec" - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" - "go.uber.org/zap" -) - -// legacyEncodedOperation is the operation wire shape before provenance fields -// existed, used to produce byte-exact legacy rows. -type legacyEncodedOperation struct { - OriginalEntry *encodedEntry - Kind string - Entry encodedEntry -} - -func provenancedOp(id registry.ID) registry.Operation { - return registry.Operation{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: id, Kind: "store.memory"}, - Provenance: ®istry.EntryProvenance{ - Module: "org/mod", - Version: "1.2.3", - Digest: "sha256:abc", - Root: true, - }, - } -} - -// TestWire_LegacyRowDecodes pins that a row written by the pre-provenance -// encoder decodes under the current decoder with nil provenance fields. -func TestWire_LegacyRowDecodes(t *testing.T) { - h := &History{handle: newMsgpackHandle()} - - legacy := []legacyEncodedOperation{{ - Kind: registry.EntryUpdate, - Entry: encodedEntry{ - ID: registry.NewID("test", "cache"), - Kind: registry.NamespaceDependency, - DependencyRoot: true, - }, - }} - var buf bytes.Buffer - require.NoError(t, codec.NewEncoder(&buf, h.handle).Encode(legacy)) - - cs, err := h.decodeChangeSet(buf.Bytes()) - require.NoError(t, err) - require.Len(t, cs, 1) - require.Equal(t, registry.EntryUpdate, cs[0].Kind) - require.True(t, cs[0].Entry.DependencyRoot, "SQLite must decode the legacy root statement") - require.Nil(t, cs[0].Provenance) - require.Nil(t, cs[0].OriginalProvenance) -} - -// TestWire_EmptyLegacyRowDecodes pins the empty-array row the schema seeds for -// the root version. -func TestWire_EmptyLegacyRowDecodes(t *testing.T) { - h := &History{handle: newMsgpackHandle()} - - cs, err := h.decodeChangeSet([]byte{0x90}) - require.NoError(t, err) - require.Empty(t, cs) -} - -// TestWire_ProvenanceRoundTrip pins the full Save/Get path: provenance fields -// survive the database round trip and land on the decoded operations. -func TestWire_ProvenanceRoundTrip(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "wire.db") - hist, err := NewSQLite(dbPath, zap.NewNop()) - require.NoError(t, err) - defer func() { _ = hist.Close() }() - - root, err := hist.Head() - require.NoError(t, err) - - op := provenancedOp(registry.NewID("test", "cache")) - v1 := version.FromParent(root, root.ID()+1) - require.NoError(t, hist.Save(v1, registry.ChangeSet{op}, true)) - - decoded, err := hist.Get(v1) - require.NoError(t, err) - require.Len(t, decoded, 1) - require.NotNil(t, decoded[0].Provenance) - require.Equal(t, *op.Provenance, *decoded[0].Provenance) - require.Nil(t, decoded[0].OriginalProvenance) -} - -// TestWire_EmittedKeys pins the map keys the encoder emits, so the wire shape -// cannot drift silently: a legacy decoder ignores the two new keys, and the -// current decoder finds them under exactly these names. -func TestWire_EmittedKeys(t *testing.T) { - handle := newMsgpackHandle() - ops := []encodedOperation{{ - Kind: registry.EntryCreate, - Entry: encodedEntry{ID: registry.NewID("test", "cache"), Kind: "store.memory"}, - Provenance: ®istry.EntryProvenance{Module: "org/mod"}, - }} - var buf bytes.Buffer - require.NoError(t, codec.NewEncoder(&buf, handle).Encode(ops)) - - var raw []map[string]any - require.NoError(t, codec.NewDecoder(bytes.NewReader(buf.Bytes()), handle).Decode(&raw)) - require.Len(t, raw, 1) - require.Contains(t, raw[0], "prov") - require.NotContains(t, raw[0], "oprov", "an absent pair half is omitted") - require.Contains(t, raw[0], "Entry") - require.Contains(t, raw[0], "Kind") - entry, ok := raw[0]["Entry"].(map[string]any) - require.True(t, ok) - require.NotContains(t, entry, "DependencyRoot", "new rows never write the dead root flag") - prov, ok := raw[0]["prov"].(map[string]any) - require.True(t, ok) - require.Equal(t, map[string]any{"module": "org/mod"}, prov, "provenance serializes under its json tags with empty fields omitted") - - // A legacy decoder is the old struct shape: it must decode a new row - // without error, dropping the provenance it does not know. - var legacy []legacyEncodedOperation - require.NoError(t, codec.NewDecoder(bytes.NewReader(buf.Bytes()), handle).Decode(&legacy)) - require.Len(t, legacy, 1) - require.Equal(t, registry.EntryCreate, legacy[0].Kind) -} diff --git a/system/registry/legacy_provenance_replay_test.go b/system/registry/legacy_provenance_replay_test.go deleted file mode 100644 index 48cdb8dad..000000000 --- a/system/registry/legacy_provenance_replay_test.go +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "bytes" - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/hashicorp/go-msgpack/v2/codec" - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/attrs" - "github.com/wippyai/runtime/api/payload" - regapi "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" - historypostgres "github.com/wippyai/runtime/system/registry/history/postgres" - historysqlite "github.com/wippyai/runtime/system/registry/history/sqlite" - "github.com/wippyai/runtime/system/registry/topology" - "go.uber.org/zap" -) - -type legacyReplayPayload struct { - Data any - Format payload.Format -} - -type legacyReplayEntry struct { - Meta attrs.Bag `codec:"Meta"` - Data *legacyReplayPayload `codec:"Data"` - ID regapi.ID `codec:"ID"` - Kind string `codec:"Kind"` - DependencyRoot bool `codec:"DependencyRoot,omitempty"` -} - -type legacyReplayOperation struct { - OriginalEntry *legacyReplayEntry `codec:"OriginalEntry"` - Kind string `codec:"Kind"` - Entry legacyReplayEntry `codec:"Entry"` -} - -func encodeLegacyReplay(t *testing.T, op legacyReplayOperation) []byte { - t.Helper() - handle := &codec.MsgpackHandle{} - handle.MapType = reflect.TypeOf(map[string]any(nil)) - handle.RawToString = true - handle.Canonical = true - var encoded bytes.Buffer - require.NoError(t, codec.NewEncoder(&encoded, handle).Encode([]legacyReplayOperation{op})) - return encoded.Bytes() -} - -func legacyDependencyRootReplay(t *testing.T) (regapi.ProvenancedState, regapi.ID, regapi.Version, []byte) { - t.Helper() - dependencyID := regapi.NewID("kickside.knowledge.requirements", "kb10") - entry := regapi.Entry{ - ID: dependencyID, - Kind: regapi.NamespaceDependency, - Data: payload.New(map[string]any{ - "component": "kickside/kb10", - "version": "1.0.0", - }), - } - baseline := regapi.ProvenancedState{ - Entries: regapi.State{entry}, - Provenance: regapi.ProvenanceMap{ - dependencyID: {Module: "kickside/knowledge", Version: "1.0.0", Digest: "sha256:knowledge"}, - }, - } - v1 := version.FromParent(version.New(regapi.RootVersion), 1) - legacyRow := encodeLegacyReplay(t, legacyReplayOperation{ - Kind: regapi.EntryUpdate, - Entry: legacyReplayEntry{ - ID: entry.ID, - Kind: entry.Kind, - Data: &legacyReplayPayload{Data: entry.Data.Data(), Format: entry.Data.Format()}, - DependencyRoot: true, - }, - }) - return baseline, dependencyID, v1, legacyRow -} - -func assertLegacyDependencyRootReplay( - ctx context.Context, - t *testing.T, - history regapi.History, - baseline regapi.ProvenancedState, - dependencyID regapi.ID, - v1 regapi.Version, -) { - t.Helper() - stored, err := history.Get(v1) - require.NoError(t, err) - require.Len(t, stored, 1) - require.Nil(t, stored[0].Provenance, "fixture must remain a legacy operation") - require.True(t, stored[0].Entry.DependencyRoot, "legacy wire flag is the only root statement") - - resolver := topology.NewResolver() - reg := NewRegistry( - history, - NewTestRunner(), - topology.NewStateBuilder(zap.NewNop(), resolver), - resolver, - zap.NewNop(), - ) - require.NoError(t, reg.LoadState(ctx, baseline, v1)) - require.Equal(t, []regapi.ID{dependencyID}, reg.DependencyRoots(), - "every cold replay must preserve the legacy application root") - record, ok := reg.EntryProvenance(dependencyID) - require.True(t, ok) - require.True(t, record.Root) - require.Equal(t, "kickside/knowledge", record.Module, - "root promotion must not replace module ownership") -} - -// TestLoadStatePromotesLegacyDependencyRoot exercises the pre-provenance wire -// boundary. The history operation deliberately has no provenance record: its -// Entry.DependencyRoot flag is the only durable statement that the dependency -// was promoted to an application root. -func TestLoadStatePromotesLegacyDependencyRoot(t *testing.T) { - ctx := context.Background() - dbPath := filepath.Join(t.TempDir(), "registry.db") - baseline, dependencyID, v1, legacyRow := legacyDependencyRootReplay(t) - - history, err := historysqlite.NewSQLite(dbPath, zap.NewNop()) - require.NoError(t, err) - require.NoError(t, history.Save(v1, nil, true)) - require.NoError(t, history.Close()) - - // Replace the modern empty row with the byte shape emitted before - // provenance existed. Going through today's Save would intentionally strip - // DependencyRoot and would not exercise an upgrade at all. - db, err := sql.Open("sqlite3", dbPath) - require.NoError(t, err) - _, err = db.ExecContext(ctx, "UPDATE changesets SET data = ? WHERE version_id = ?", legacyRow, v1.ID()) - require.NoError(t, err) - require.NoError(t, db.Close()) - - for i := 0; i < 2; i++ { - history, err := historysqlite.NewSQLite(dbPath, zap.NewNop()) - require.NoError(t, err) - assertLegacyDependencyRootReplay(ctx, t, history, baseline, dependencyID, v1) - require.NoError(t, history.Close()) - } -} - -func TestLoadStatePromotesLegacyDependencyRootPostgres(t *testing.T) { - dsn := strings.TrimSpace(os.Getenv("WIPPY_POSTGRES_HISTORY_TEST_DSN")) - if dsn == "" { - t.Skip("WIPPY_POSTGRES_HISTORY_TEST_DSN is not set") - } - - ctx := context.Background() - schema := fmt.Sprintf("wippy_registry_legacy_root_%d", os.Getpid()) - baseline, dependencyID, v1, legacyRow := legacyDependencyRootReplay(t) - - history, err := historypostgres.NewPostgres(dsn, schema, zap.NewNop()) - require.NoError(t, err) - require.NoError(t, history.Save(v1, nil, true)) - require.NoError(t, history.Close()) - - db, err := sql.Open("postgres", dsn) - require.NoError(t, err) - t.Cleanup(func() { - _, _ = db.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %q CASCADE", schema)) - _ = db.Close() - }) - _, err = db.ExecContext(ctx, - fmt.Sprintf("UPDATE %q.changesets SET data = $1 WHERE version_id = $2", schema), legacyRow, v1.ID()) - require.NoError(t, err) - - for i := 0; i < 2; i++ { - history, err := historypostgres.NewPostgres(dsn, schema, zap.NewNop()) - require.NoError(t, err) - assertLegacyDependencyRootReplay(ctx, t, history, baseline, dependencyID, v1) - require.NoError(t, history.Close()) - } -} diff --git a/system/registry/load_state_duplicate_test.go b/system/registry/load_state_duplicate_test.go index 1dac2f0b0..03287586d 100644 --- a/system/registry/load_state_duplicate_test.go +++ b/system/registry/load_state_duplicate_test.go @@ -44,7 +44,7 @@ func TestRegistry_LoadState_RejectsDuplicateBaselineIDs(t *testing.T) { head, err := reg.Current() require.NoError(t, err) - err = reg.LoadState(ctx, hostProvenanced(baseline), head) + err = reg.LoadState(ctx, baseline, head) require.Error(t, err) require.Contains(t, err.Error(), "duplicate entry id") require.Contains(t, err.Error(), "crm") @@ -79,7 +79,7 @@ func TestRegistry_LoadState_RejectsHydratedDuplicateBaselineIDs(t *testing.T) { head, err := reg.Current() require.NoError(t, err) - err = reg.LoadState(ctx, hostProvenanced(baseline), head) + err = reg.LoadState(ctx, baseline, head) require.Error(t, err) require.Contains(t, err.Error(), "duplicate entry id") } @@ -102,7 +102,7 @@ func TestRegistry_LoadStateCanonicalizesBaselineWithoutMutatingCaller(t *testing baseline := registry.State{{ID: rawID, Kind: registry.EntryKind}} head, err := reg.Current() require.NoError(t, err) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(baseline), head)) + require.NoError(t, reg.LoadState(ctx, baseline, head)) stored, err := reg.GetEntry(canonicalID) require.NoError(t, err) diff --git a/system/registry/load_state_test.go b/system/registry/load_state_test.go index f7defa758..91da000e2 100644 --- a/system/registry/load_state_test.go +++ b/system/registry/load_state_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/wippyai/runtime/api/payload" "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" historymem "github.com/wippyai/runtime/system/registry/history/memory" "github.com/wippyai/runtime/system/registry/topology" "go.uber.org/zap" @@ -23,15 +22,6 @@ var history = struct { NewMemory: historymem.New, } -// hostProvenanced wraps a raw test state with host provenance records. -func hostProvenanced(s registry.State) registry.ProvenancedState { - prov := make(registry.ProvenanceMap, len(s)) - for _, e := range s { - prov[e.ID] = registry.EntryProvenance{} - } - return registry.ProvenancedState{Entries: s, Provenance: prov} -} - func TestRegistry_LoadState_V0(t *testing.T) { ctx := context.Background() logger := zap.NewNop() @@ -80,7 +70,7 @@ func TestRegistry_LoadState_V0(t *testing.T) { } require.Equal(t, uint(0), head.ID()) - err = reg.LoadState(ctx, hostProvenanced(baseline), head) + err = reg.LoadState(ctx, baseline, head) require.NoError(t, err) currentVer, err := reg.Current() @@ -107,10 +97,10 @@ func TestRegistry_LoadState_V0ExpandsBaselineDirectives(t *testing.T) { Data: payload.New("expanded"), } - expander := directiveFunc(func(_ context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { + expander := directiveFunc(func(_ context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) { require.Equal(t, registry.EntryUpdate, op.Kind) require.Equal(t, depEntry.ID, op.Entry.ID) - require.Contains(t, snapshot.Entries, depEntry) + require.Contains(t, snapshot, depEntry) return registry.DirectiveResult{ Applied: true, Additional: []registry.ScopedOperation{{ @@ -145,7 +135,7 @@ func TestRegistry_LoadState_V0ExpandsBaselineDirectives(t *testing.T) { head, err := reg.Current() require.NoError(t, err) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(registry.State{depEntry}), head)) + require.NoError(t, reg.LoadState(ctx, registry.State{depEntry}, head)) _, err = reg.GetEntry(depEntry.ID) require.NoError(t, err) @@ -173,7 +163,7 @@ func TestRegistry_LoadState_V0RejectsBaselineDirectiveExpansionFailure(t *testin resolver, logger, WithKindDirective(registry.NamespaceDependency, directiveFunc( - func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) { + func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{}, assert.AnError }, )), @@ -181,7 +171,7 @@ func TestRegistry_LoadState_V0RejectsBaselineDirectiveExpansionFailure(t *testin head, err := reg.Current() require.NoError(t, err) - err = reg.LoadState(ctx, hostProvenanced(registry.State{depEntry}), head) + err = reg.LoadState(ctx, registry.State{depEntry}, head) require.ErrorIs(t, err, assert.AnError) _, err = reg.GetEntry(depEntry.ID) require.Error(t, err, "a failed declaration must not be published as a partial root") @@ -226,7 +216,6 @@ func TestRegistry_LoadState_WithHistory(t *testing.T) { {ID: registry.NewID("test", "entry1"), Kind: "service", Data: payload.New("initial")}, {ID: registry.NewID("test", "entry2"), Kind: "service", Data: payload.New("initial")}, } - require.NoError(t, reg.LoadState(ctx, hostProvenanced(baseline), version.New(registry.RootVersion))) cs1 := registry.ChangeSet{ {Kind: registry.EntryUpdate, Entry: registry.Entry{ID: registry.NewID("test", "entry1"), Kind: "service", Data: payload.New("updated")}}, @@ -252,7 +241,7 @@ func TestRegistry_LoadState_WithHistory(t *testing.T) { require.NoError(t, err) assert.Equal(t, uint(1), head.ID()) - err = reg2.LoadState(ctx, hostProvenanced(baseline), head) + err = reg2.LoadState(ctx, baseline, head) require.NoError(t, err) currentVer, err := reg2.Current() @@ -307,7 +296,7 @@ func TestRegistry_LoadState_ReplaysHistoryThroughDirectives(t *testing.T) { Kind: "store.memory", Data: payload.New("expanded"), } - expander := directiveFunc(func(_ context.Context, op registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { + expander := directiveFunc(func(_ context.Context, op registry.Operation, _ registry.State) (registry.DirectiveResult, error) { if op.Entry.Kind != registry.NamespaceDependency { return registry.DirectiveResult{}, nil } @@ -353,7 +342,7 @@ func TestRegistry_LoadState_ReplaysHistoryThroughDirectives(t *testing.T) { head, err := hist.Head() require.NoError(t, err) - require.NoError(t, reg2.LoadState(ctx, hostProvenanced(nil), head)) + require.NoError(t, reg2.LoadState(ctx, nil, head)) _, err = reg2.GetEntry(depEntry.ID) require.NoError(t, err) @@ -399,7 +388,6 @@ func TestRegistry_LoadState_MultipleVersions(t *testing.T) { baseline := registry.State{ {ID: registry.NewID("test", "entry1"), Kind: "service", Data: payload.New("v0")}, } - require.NoError(t, reg.LoadState(ctx, hostProvenanced(baseline), version.New(registry.RootVersion))) cs1 := registry.ChangeSet{ {Kind: registry.EntryUpdate, Entry: registry.Entry{ID: registry.NewID("test", "entry1"), Kind: "service", Data: payload.New("v1")}}, @@ -430,7 +418,7 @@ func TestRegistry_LoadState_MultipleVersions(t *testing.T) { logger, ) - err = reg2.LoadState(ctx, hostProvenanced(baseline), v3) + err = reg2.LoadState(ctx, baseline, v3) require.NoError(t, err) currentVer, err := reg2.Current() @@ -524,7 +512,7 @@ func TestRegistry_LoadState_ThenApplyVersion(t *testing.T) { require.NoError(t, err) require.Equal(t, uint(3), head.ID()) - err = reg2.LoadState(ctx, hostProvenanced(baseline), head) + err = reg2.LoadState(ctx, baseline, head) require.NoError(t, err) currentVer, err := reg2.Current() diff --git a/system/registry/migration/metadata.go b/system/registry/migration/metadata.go new file mode 100644 index 000000000..75488263e --- /dev/null +++ b/system/registry/migration/metadata.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package migration contains one-time registry storage migrations. +package migration + +import ( + "context" + "errors" + "fmt" + + "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/system/registry/history/postgres" + "github.com/wippyai/runtime/system/registry/history/sqlite" +) + +// Apply normalizes durable operation rows before normal registry replay. The +// migration records its registry-history child transition (1.0 to 1.1) in the +// existing schema ledger; in-memory histories contain no persisted rows. +func Apply(ctx context.Context, history registry.History, baseline registry.State) error { + switch history.(type) { + case *sqlite.History, *postgres.History: + default: + return nil + } + + baseline = append(registry.State(nil), baseline...) + if resolutions, ok := history.(registry.ResolutionHistory); ok { + head, err := history.Head() + if err != nil { + return fmt.Errorf("read registry head for metadata migration: %w", err) + } + resolution, err := resolutions.GetDependencyResolution(head) + switch { + case err == nil: + baseline = markResolutionRoots(baseline, resolution) + case errors.Is(err, registry.ErrDependencyResolutionNotFound): + default: + return fmt.Errorf("read dependency resolution for metadata migration: %w", err) + } + } + switch history := history.(type) { + case *sqlite.History: + return sqlite.MigrateEntryMetadata(ctx, history, baseline) + case *postgres.History: + return postgres.MigrateEntryMetadata(ctx, history, baseline) + } + return nil +} + +func markResolutionRoots(baseline registry.State, resolution *registry.DependencyResolution) registry.State { + if resolution == nil { + return baseline + } + indexes := make(map[registry.ID]int, len(baseline)) + for i := range baseline { + indexes[baseline[i].ID.Canonical()] = i + } + for _, root := range resolution.Roots { + id := registry.ParseID(root.ID).Canonical() + if index, ok := indexes[id]; ok { + baseline[index].Registry.Root = true + continue + } + indexes[id] = len(baseline) + baseline = append(baseline, registry.Entry{ID: id, Registry: registry.EntryMetadata{Root: true}}) + } + return baseline +} diff --git a/system/registry/migration/metadata_test.go b/system/registry/migration/metadata_test.go new file mode 100644 index 000000000..e750fca57 --- /dev/null +++ b/system/registry/migration/metadata_test.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 + +package migration_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + regapi "github.com/wippyai/runtime/api/registry" + "github.com/wippyai/runtime/internal/version" + "github.com/wippyai/runtime/system/registry/history/memory" + "github.com/wippyai/runtime/system/registry/history/sqlite" + registrymigration "github.com/wippyai/runtime/system/registry/migration" + "go.uber.org/zap" +) + +func TestApplyIgnoresUnpersistedHistory(t *testing.T) { + history := memory.New() + require.NoError(t, registrymigration.Apply(t.Context(), history, nil)) +} + +func TestApplyMarksStoredResolutionRootsOutsideBaseline(t *testing.T) { + history, err := sqlite.NewSQLite(filepath.Join(t.TempDir(), "registry.db"), zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, history.Close()) }) + + declaredID := regapi.NewID("app.deps", "declared") + dynamicID := regapi.NewID("app.deps", "dynamic") + v1 := version.FromParent(version.New(0), 1) + resolution := (®api.DependencyResolution{ + InputDigest: "sha256:input", + Roots: []regapi.DependencyRoot{ + {ID: declaredID.String(), Component: "example/declared", Version: ">=1.0.0"}, + {ID: dynamicID.String(), Component: "example/dynamic", Version: ">=1.0.0"}, + }, + Modules: []regapi.ResolvedModule{ + {Name: "example/declared", Version: "1.0.0", Digest: "sha256:declared"}, + {Name: "example/dynamic", Version: "1.0.0", Digest: "sha256:dynamic"}, + }, + }).Canonical() + require.NoError(t, history.SaveWithDependencyResolution(v1, regapi.ChangeSet{ + {Kind: regapi.EntryCreate, Entry: regapi.Entry{ID: declaredID, Kind: regapi.NamespaceDependency}}, + {Kind: regapi.EntryCreate, Entry: regapi.Entry{ID: dynamicID, Kind: regapi.NamespaceDependency}}, + }, resolution, true)) + + baseline := regapi.State{{ + ID: declaredID, + Registry: regapi.EntryMetadata{Owner: "example/application"}, + }} + require.NoError(t, registrymigration.Apply(context.Background(), history, baseline)) + changes, err := history.Get(v1) + require.NoError(t, err) + require.Equal(t, regapi.EntryMetadata{Owner: "example/application", Root: true}, changes[0].Entry.Registry) + require.Equal(t, regapi.EntryMetadata{Root: true}, changes[1].Entry.Registry) +} diff --git a/system/registry/migration/storage/metadata.go b/system/registry/migration/storage/metadata.go new file mode 100644 index 000000000..4a1334b4d --- /dev/null +++ b/system/registry/migration/storage/metadata.go @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package storage implements registry storage migrations for durable backends. +package storage + +import ( + "bytes" + "context" + "database/sql" + "fmt" + "time" + + "github.com/hashicorp/go-msgpack/v2/codec" + "github.com/wippyai/runtime/api/attrs" + "github.com/wippyai/runtime/api/payload" + "github.com/wippyai/runtime/api/registry" +) + +const ( + entryMetadataLedgerName = "registry_history.entry_metadata" + entryMetadataLedgerVersion = "1.1" + entryMetadataLedgerBase = "1.0" + entryMetadataDescription = "move registry entry ownership into registry metadata" + // SHA-256 of the canonical {curr_version,min_compatible_version,description} + // migration manifest represented by these constants. + entryMetadataLedgerHash = "798c77a8e5212cd0662c6b56395ee03b9f3291c0bab12c3d5df48bae7936738f" +) + +// Tables names the already-managed history and schema-ledger tables for one +// durable backend. +type Tables struct { + ChangeSets string + SchemaVersion string + UpdateHistory string +} + +// RewriteEntryMetadata rewrites every persisted changeset in one transaction. +// table names are supplied by the history backend after it has validated and +// quoted them for its dialect. +func RewriteEntryMetadata( + ctx context.Context, + db *sql.DB, + handle *codec.MsgpackHandle, + tables Tables, + parameter func(int) string, + lock func(context.Context, *sql.Tx) error, + baseline registry.State, +) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin entry metadata migration: %w", err) + } + defer func() { _ = tx.Rollback() }() + if lock != nil { + if err := lock(ctx, tx); err != nil { + return fmt.Errorf("lock entry metadata migration: %w", err) + } + } + + current, exists, err := readVersion(ctx, tx, tables.SchemaVersion, parameter) + if err != nil { + return err + } + if exists { + switch current { + case entryMetadataLedgerVersion: + return nil + case entryMetadataLedgerBase: + default: + return fmt.Errorf("unsupported entry metadata migration version %q", current) + } + } + + baselineMetadata := make(map[registry.ID]registry.EntryMetadata, len(baseline)) + for _, entry := range baseline { + baselineMetadata[entry.ID.Canonical()] = entry.Registry + } + + // #nosec G202 -- table names are fixed by the backend constructor, not input. + rows, err := tx.QueryContext(ctx, "SELECT version_id, data FROM "+tables.ChangeSets+" ORDER BY version_id") + if err != nil { + return fmt.Errorf("read registry changesets: %w", err) + } + type row struct { + data []byte + versionID uint + } + var stored []row + for rows.Next() { + var item row + if err := rows.Scan(&item.versionID, &item.data); err != nil { + _ = rows.Close() + return fmt.Errorf("scan registry changeset: %w", err) + } + stored = append(stored, item) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("read registry changesets: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close registry changesets: %w", err) + } + + // #nosec G202 -- table names are fixed by the backend constructor, not input. + updateQuery := "UPDATE " + tables.ChangeSets + " SET data = " + parameter(1) + " WHERE version_id = " + parameter(2) + for _, item := range stored { + data, changed, err := rewriteChangeSet(item.data, handle, baselineMetadata) + if err != nil { + return fmt.Errorf("rewrite registry changeset %d: %w", item.versionID, err) + } + if !changed { + continue + } + if _, err := tx.ExecContext(ctx, updateQuery, data, item.versionID); err != nil { + return fmt.Errorf("store registry changeset %d: %w", item.versionID, err) + } + } + if err := writeVersion(ctx, tx, tables, parameter, exists, current); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit entry metadata migration: %w", err) + } + return nil +} + +func readVersion(ctx context.Context, tx *sql.Tx, table string, parameter func(int) string) (string, bool, error) { + var current string + err := tx.QueryRowContext(ctx, "SELECT curr_version FROM "+table+" WHERE name = "+parameter(1), entryMetadataLedgerName).Scan(¤t) + if err == sql.ErrNoRows { + return entryMetadataLedgerBase, false, nil + } + if err != nil { + return "", false, fmt.Errorf("read entry metadata migration version: %w", err) + } + return current, true, nil +} + +func writeVersion(ctx context.Context, tx *sql.Tx, tables Tables, parameter func(int) string, exists bool, current string) error { + old := entryMetadataLedgerBase + if exists { + old = current + } + now := time.Now().UTC() + // #nosec G202 -- table names are fixed by the backend constructor, not input. + versionQuery := "INSERT INTO " + tables.SchemaVersion + " (name, curr_version, min_compatible_version, updated_at) VALUES (" + parameter(1) + ", " + parameter(2) + ", " + parameter(3) + ", " + parameter(4) + ")" + if parameter(1) == "?" { + versionQuery += " ON CONFLICT(name) DO UPDATE SET curr_version = excluded.curr_version, min_compatible_version = excluded.min_compatible_version, updated_at = excluded.updated_at" + } else { + versionQuery += " ON CONFLICT(name) DO UPDATE SET curr_version = EXCLUDED.curr_version, min_compatible_version = EXCLUDED.min_compatible_version, updated_at = EXCLUDED.updated_at" + } + if _, err := tx.ExecContext(ctx, versionQuery, entryMetadataLedgerName, entryMetadataLedgerVersion, entryMetadataLedgerBase, now); err != nil { + return fmt.Errorf("write entry metadata migration version: %w", err) + } + // #nosec G202 -- table names are fixed by the backend constructor, not input. + auditQuery := "INSERT INTO " + tables.UpdateHistory + " (name, update_time, old_version, new_version, manifest_sha256, description) VALUES (" + parameter(1) + ", " + parameter(2) + ", " + parameter(3) + ", " + parameter(4) + ", " + parameter(5) + ", " + parameter(6) + ")" + if _, err := tx.ExecContext(ctx, auditQuery, entryMetadataLedgerName, now, old, entryMetadataLedgerVersion, entryMetadataLedgerHash, entryMetadataDescription); err != nil { + return fmt.Errorf("write entry metadata migration history: %w", err) + } + return nil +} + +type encodedPayload struct { + Data any + Format payload.Format +} + +// encodedEntry accepts every released storage shape. DependencyRoot and the +// three metadata stamps existed only in persisted rows; normal history codecs +// do not read or write them. +type encodedEntry struct { + Meta attrs.Bag + Data *encodedPayload + ID registry.ID + Kind string + Registry registry.EntryMetadata + DependencyRoot bool `codec:"DependencyRoot,omitempty"` +} + +type releasedOwnership struct { + Module string + Version string + Digest string + Root bool +} + +type encodedOperation struct { + OriginalEntry *encodedEntry `codec:"OriginalEntry"` + Current *releasedOwnership `codec:"prov,omitempty"` + Previous *releasedOwnership `codec:"oprov,omitempty"` + Kind string `codec:"Kind"` + Entry encodedEntry `codec:"Entry"` +} + +func rewriteChangeSet(data []byte, handle *codec.MsgpackHandle, baseline map[registry.ID]registry.EntryMetadata) ([]byte, bool, error) { + var operations []encodedOperation + decoder := codec.NewDecoder(bytes.NewReader(data), handle) + if err := decoder.Decode(&operations); err != nil { + return nil, false, err + } + + changed := false + for i := range operations { + op := &operations[i] + if err := validateOperationOwners(op); err != nil { + return nil, false, err + } + currentOwner := storedOwner(&op.Entry, op.Current) + previousOwner := "" + if op.OriginalEntry != nil { + previousOwner = storedOwner(op.OriginalEntry, op.Previous) + } + entryChanged, err := rewriteEntry(&op.Entry, op.Current, previousOwner, baseline) + if err != nil { + return nil, false, err + } + changed = entryChanged || changed + if op.OriginalEntry != nil { + originalChanged, err := rewriteEntry(op.OriginalEntry, op.Previous, currentOwner, baseline) + if err != nil { + return nil, false, err + } + changed = originalChanged || changed + } + if op.Current != nil || op.Previous != nil { + op.Current = nil + op.Previous = nil + changed = true + } + } + if !changed { + return data, false, nil + } + + var out bytes.Buffer + encoder := codec.NewEncoder(&out, handle) + if err := encoder.Encode(operations); err != nil { + return nil, false, err + } + return out.Bytes(), true, nil +} + +func validateOperationOwners(operation *encodedOperation) error { + if err := validateEntryOwner(&operation.Entry, operation.Current); err != nil { + return err + } + if operation.OriginalEntry == nil { + return nil + } + if err := validateEntryOwner(operation.OriginalEntry, operation.Previous); err != nil { + return err + } + current := storedOwner(&operation.Entry, operation.Current) + previous := storedOwner(operation.OriginalEntry, operation.Previous) + if current != "" && previous != "" && current != previous { + return fmt.Errorf("%s has conflicting owners %q and %q", operation.Entry.ID.Canonical(), previous, current) + } + return nil +} + +func validateEntryOwner(entry *encodedEntry, record *releasedOwnership) error { + if record == nil || record.Module == "" || entry.Registry.Owner == "" || record.Module == entry.Registry.Owner { + return nil + } + return fmt.Errorf("%s has conflicting owners %q and %q", entry.ID.Canonical(), entry.Registry.Owner, record.Module) +} + +func rewriteEntry(entry *encodedEntry, record *releasedOwnership, pairedOwner string, baseline map[registry.ID]registry.EntryMetadata) (bool, error) { + changed := false + id := entry.ID.Canonical() + if id != entry.ID { + entry.ID = id + changed = true + } + + metadata := entry.Registry + hadMetadata := metadata != (registry.EntryMetadata{}) + base, hasBaseline := baseline[id] + owner := storedOwner(entry, record) + if owner == "" { + owner = pairedOwner + } + if hasBaseline && base.Owner != "" { + if owner != "" && owner != base.Owner { + return false, fmt.Errorf("%s has conflicting owners %q and %q", id, owner, base.Owner) + } + owner = base.Owner + } + if metadata.Owner != owner { + metadata.Owner = owner + changed = true + } + + // A persisted ownership record or dependency-root bit is an explicit state + // change. Otherwise keep the active baseline selection for the entry. + if record != nil { + if metadata.Root != record.Root { + metadata.Root = record.Root + changed = true + } + } else if entry.DependencyRoot { + if !metadata.Root { + metadata.Root = true + changed = true + } + } else if !hadMetadata && hasBaseline && metadata.Root != base.Root { + metadata.Root = base.Root + changed = true + } + if entry.DependencyRoot { + entry.DependencyRoot = false + changed = true + } + if entry.Registry != metadata { + entry.Registry = metadata + changed = true + } + for _, key := range []string{"module", "module_version", "module_digest"} { + if _, ok := entry.Meta[key]; ok { + delete(entry.Meta, key) + changed = true + } + } + return changed, nil +} + +func storedOwner(entry *encodedEntry, record *releasedOwnership) string { + if entry.Registry.Owner != "" { + return entry.Registry.Owner + } + if record != nil && record.Module != "" { + return record.Module + } + owner, _ := entry.Meta["module"].(string) + return owner +} diff --git a/system/registry/options_test.go b/system/registry/options_test.go index 08e59ef98..c4f600cbb 100644 --- a/system/registry/options_test.go +++ b/system/registry/options_test.go @@ -16,7 +16,7 @@ import ( type mockDirective struct{} -func (m *mockDirective) Expand(_ context.Context, _ registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { +func (m *mockDirective) Expand(_ context.Context, _ registry.Operation, _ registry.State) (registry.DirectiveResult, error) { return registry.DirectiveResult{}, nil } diff --git a/system/registry/overlay.go b/system/registry/overlay.go index e6ae95fc1..f852adb1b 100644 --- a/system/registry/overlay.go +++ b/system/registry/overlay.go @@ -51,7 +51,6 @@ func (r *Reg) applyOverlayLocked(ctx context.Context, owner string, expectedGene r.mu.RLock() snapshot := append(registry.State(nil), r.state...) - liveProv := r.provenanceSnapshot() currentGeneration, activeOwner := r.overlayGeneration[owner] if !activeOwner { currentGeneration = r.overlayFloor @@ -100,7 +99,7 @@ func (r *Reg) applyOverlayLocked(ctx context.Context, owner string, expectedGene return 0, NewOverlayValidationError("unknown registry overlay operation", map[string]any{"operation": op.Kind}) } if op.Kind != registry.EntryDelete { - if err := validateOverlayEntryProvenance(op.Entry); err != nil { + if err := validateOverlayEntryMetadata(op.Entry); err != nil { return 0, err } if len(r.directivesByKind[op.Entry.Kind]) != 0 { @@ -133,18 +132,13 @@ func (r *Reg) applyOverlayLocked(ctx context.Context, owner string, expectedGene if err != nil { return 0, NewSortChangesError(err) } - nextProv, err := applyOpsToProvenance(liveProv, sorted) - if err != nil { - return 0, NewProvenanceInvariantError(err) - } - annotateChangeSet(sorted, liveProv, nextProv) r.mu.Lock() defer r.mu.Unlock() newState, err := r.runner.Transition(ctx, r.state, sorted) if err != nil { if newState != nil && ctx.Err() == nil { - if rollbackErr := r.rollback(ctx, newState, r.state, nextProv, nil); rollbackErr != nil { + if rollbackErr := r.rollback(ctx, newState, r.state); rollbackErr != nil { r.reconcileOverlayIndexesAfterFailedRollback(owner, owners, changes) return 0, NewApplyChangesError(err, rollbackErr) } @@ -157,7 +151,7 @@ func (r *Reg) applyOverlayLocked(ctx context.Context, owner string, expectedGene r.state = newState r.rebuildIndex() r.patchDepIndex(sorted) - r.publishProvenance(nextProv) + r.publishSnapshot() return nextGeneration, nil } @@ -209,9 +203,11 @@ func cloneOverlayEntry(entry registry.Entry) registry.Entry { return entry } -func validateOverlayEntryProvenance(entry registry.Entry) error { - if entry.DependencyRoot { - return NewOverlayValidationError("registry overlay entry cannot be a deployment root", map[string]any{"entry_id": entry.ID.String()}) +func validateOverlayEntryMetadata(entry registry.Entry) error { + if entry.Registry != (registry.EntryMetadata{}) { + return NewOverlayValidationError("registry overlay entry cannot set registry metadata", map[string]any{ + "entry_id": entry.ID.String(), + }) } return nil } @@ -242,20 +238,6 @@ func (r *Reg) validateOverlayComposition(effective registry.StateMap, owners map }) } -// mergeOverlayProvenance carries process-local entries across durable version -// selection. Overlay ownership remains in overlayOwners; its provenance record -// is the explicit host record used by the total live-state map. -func (r *Reg) mergeOverlayProvenance(target, live registry.ProvenanceMap) error { - for id := range r.overlayOwners { - p, ok := live[id] - if !ok { - return registry.NewMissingProvenanceError(id) - } - target[id] = p - } - return nil -} - func (r *Reg) validateDurableTransitionAgainstOverlays(allOps registry.ChangeSet) error { if len(r.overlayOwners) == 0 { return nil diff --git a/system/registry/overlay_bench_test.go b/system/registry/overlay_bench_test.go index 0d2f7b5ba..385f0a83f 100644 --- a/system/registry/overlay_bench_test.go +++ b/system/registry/overlay_bench_test.go @@ -35,7 +35,7 @@ func BenchmarkApplyOverlayChurn(b *testing.B) { Kind: regapi.EntryKind, } } - if err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, regapi.RootVersion)); err != nil { + if err := reg.LoadState(ctx, baseline, version.FromParent(nil, regapi.RootVersion)); err != nil { b.Fatal(err) } entry := regapi.Entry{ID: regapi.NewID("runtime", "source"), Kind: regapi.EntryKind} diff --git a/system/registry/overlay_test.go b/system/registry/overlay_test.go index 051d29fa3..8d8620acb 100644 --- a/system/registry/overlay_test.go +++ b/system/registry/overlay_test.go @@ -39,7 +39,7 @@ func newOverlayTestRegistryWithRunner(t *testing.T) (*Reg, *historymem.Storage, func TestOverlayDoesNotAdvanceHistory(t *testing.T) { ctx := context.Background() reg, history := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ID: regapi.NewID("runtime.db", "one"), Kind: "test.resource", Data: payload.New("live")} generation, err := reg.ApplyOverlay(ctx, "data-sources:one", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) @@ -58,7 +58,7 @@ func TestOverlayDoesNotAdvanceHistory(t *testing.T) { func TestOverlaySurvivesHistoryCommit(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) runtimeEntry := regapi.Entry{ID: regapi.NewID("runtime.db", "one"), Kind: "test.resource", Data: payload.New("live")} _, err := reg.ApplyOverlay(ctx, "data-sources:one", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: runtimeEntry}}) require.NoError(t, err) @@ -78,7 +78,7 @@ func TestOverlaySurvivesHistoryCommit(t *testing.T) { func TestOverlaySurvivesVersionSelection(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) durable := regapi.Entry{ID: regapi.NewID("app", "setting"), Kind: regapi.EntryKind, Data: payload.New("v1")} v1, err := reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: durable}}) require.NoError(t, err) @@ -101,7 +101,7 @@ func TestOverlayCannotShadowOrLeakIntoHistory(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) base := regapi.Entry{ID: regapi.NewID("app", "base"), Kind: regapi.EntryKind, Data: payload.New("base")} - require.NoError(t, reg.LoadState(ctx, hostProvenanced(regapi.State{base}), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, regapi.State{base}, version.FromParent(nil, regapi.RootVersion))) _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: base}}) require.Error(t, err) @@ -120,7 +120,7 @@ func TestOverlayCannotShadowOrLeakIntoHistory(t *testing.T) { func TestOverlayRejectsStaleGeneration(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ID: regapi.NewID("runtime", "one"), Kind: regapi.EntryKind, Data: payload.New("v1")} generation, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) require.NoError(t, err) @@ -136,7 +136,7 @@ func TestOverlayRejectsStaleGeneration(t *testing.T) { func TestOverlayBulkDeleteUsesReverseDependencyOrder(t *testing.T) { ctx := context.Background() reg, _, runner := newOverlayTestRegistryWithRunner(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) envID := regapi.NewID("runtime.env", "password") dbID := regapi.NewID("runtime.db", "source") envEntry := regapi.Entry{ID: envID, Kind: "env.variable"} @@ -164,7 +164,7 @@ func TestOverlayBulkDeleteUsesReverseDependencyOrder(t *testing.T) { func TestOverlayCannotDeleteDependencyWhileDependentSurvives(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) target := regapi.Entry{ID: regapi.NewID("runtime", "target"), Kind: regapi.EntryKind} consumer := regapi.Entry{ ID: regapi.NewID("runtime", "consumer"), Kind: regapi.EntryKind, @@ -184,7 +184,7 @@ func TestOverlayCannotDeleteDependencyWhileDependentSurvives(t *testing.T) { func TestOverlayRejectsCrossOwnerDependenciesInEitherCreationOrder(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) targetID := regapi.NewID("runtime", "target") consumer := regapi.Entry{ ID: regapi.NewID("runtime", "consumer"), Kind: regapi.EntryKind, @@ -198,7 +198,7 @@ func TestOverlayRejectsCrossOwnerDependenciesInEitherCreationOrder(t *testing.T) require.Error(t, err, "a later owner cannot claim an ID referenced by another overlay") reg, _ = newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) _, err = reg.ApplyOverlay(ctx, "owner:b", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: target}}) require.NoError(t, err) _, err = reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: consumer}}) @@ -210,7 +210,7 @@ func TestDurableHistoryCannotDependOnOrRemoveOverlayGraph(t *testing.T) { reg, _ := newOverlayTestRegistry(t) baseID := regapi.NewID("app", "base") base := regapi.Entry{ID: baseID, Kind: regapi.EntryKind} - require.NoError(t, reg.LoadState(ctx, hostProvenanced(regapi.State{base}), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, regapi.State{base}, version.FromParent(nil, regapi.RootVersion))) overlayID := regapi.NewID("runtime", "one") overlay := regapi.Entry{ ID: overlayID, Kind: regapi.EntryKind, @@ -237,7 +237,7 @@ func TestOverlayCannotClaimIDAlreadyReferencedByDurableState(t *testing.T) { ID: regapi.NewID("app", "consumer"), Kind: regapi.EntryKind, Meta: attrs.NewBagFrom(map[string]any{"depends_on": overlayID.String()}), } - require.NoError(t, reg.LoadState(ctx, hostProvenanced(regapi.State{durable}), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, regapi.State{durable}, version.FromParent(nil, regapi.RootVersion))) candidate := regapi.Entry{ID: overlayID, Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: candidate}}) require.Error(t, err) @@ -247,11 +247,11 @@ func TestLoadStateClearsProcessLocalOverlays(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) v0 := version.FromParent(nil, regapi.RootVersion) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) entry := regapi.Entry{ID: regapi.NewID("runtime", "one"), Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) require.NoError(t, err) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) entries, generation, err := reg.GetOverlay("owner:a") require.NoError(t, err) assert.Empty(t, entries) @@ -260,14 +260,31 @@ func TestLoadStateClearsProcessLocalOverlays(t *testing.T) { require.Error(t, err) } +func TestSnapshotPublishesCommittedOverlayState(t *testing.T) { + ctx := context.Background() + reg, _ := newOverlayTestRegistry(t) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) + + entry := regapi.Entry{ID: regapi.NewID("runtime", "snapshot"), Kind: regapi.EntryKind} + generation, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) + require.NoError(t, err) + snapshot := reg.Snapshot() + require.Len(t, snapshot.Entries, 1) + require.Equal(t, entry.ID, snapshot.Entries[0].ID) + + _, err = reg.ApplyOverlay(ctx, "owner:a", generation, regapi.ChangeSet{{Kind: regapi.EntryDelete, Entry: entry}}) + require.NoError(t, err) + require.Empty(t, reg.Snapshot().Entries) +} + func TestLoadStateInvalidatesAbsentOverlaySnapshot(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) v0 := version.FromParent(nil, regapi.RootVersion) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) _, staleGeneration, err := reg.GetOverlay("owner:a") require.NoError(t, err) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) entry := regapi.Entry{ID: regapi.NewID("runtime", "stale"), Kind: regapi.EntryKind} _, err = reg.ApplyOverlay(ctx, "owner:a", staleGeneration, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) @@ -280,7 +297,7 @@ func TestLoadStateInvalidatesAbsentOverlaySnapshot(t *testing.T) { func TestDeletedOverlayOwnerRetainsGenerationTombstone(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ID: regapi.NewID("runtime", "one"), Kind: regapi.EntryKind} generation, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) require.NoError(t, err) @@ -307,7 +324,7 @@ func TestDeletedOverlayOwnerRetainsGenerationTombstone(t *testing.T) { func TestOverlayGenerationDoesNotConflictAcrossOwners(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) _, ownerBGeneration, err := reg.GetOverlay("owner:b") require.NoError(t, err) @@ -339,33 +356,26 @@ func TestOverlayGenerationConflictIsStructuredAndRetryable(t *testing.T) { assert.Equal(t, uint64(0), actual) } -func TestOverlayRejectsIDAliasAndDeploymentRoot(t *testing.T) { +func TestOverlayRejectsIDAliasAndRegistryMetadata(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) base := regapi.Entry{ID: regapi.NewID("app", "base"), Kind: regapi.EntryKind} - require.NoError(t, reg.LoadState(ctx, hostProvenanced(regapi.State{base}), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, regapi.State{base}, version.FromParent(nil, regapi.RootVersion))) alias := regapi.Entry{ID: regapi.ID{Name: "app:base"}, Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: alias}}) require.Error(t, err) - - // Author meta is opaque to the runtime: a key named "module" is ordinary - // author data and passes through. - authorKeyed := regapi.Entry{ - ID: regapi.NewID("runtime", "authorkeyed"), Kind: regapi.EntryKind, - Meta: attrs.NewBagFrom(map[string]any{"module": "acme/app"}), + managed := regapi.Entry{ + ID: regapi.NewID("runtime", "managed"), Kind: regapi.EntryKind, + Registry: regapi.EntryMetadata{Owner: "acme/app"}, } - _, err = reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: authorKeyed}}) - require.NoError(t, err) - - root := regapi.Entry{ID: regapi.NewID("runtime", "rooted"), Kind: regapi.EntryKind, DependencyRoot: true} - _, err = reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: root}}) + _, err = reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: managed}}) require.Error(t, err) } func TestOverlayOwnershipDoesNotMutateEntryMetadata(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ ID: regapi.NewID("runtime", "owned"), Kind: regapi.EntryKind, @@ -390,7 +400,7 @@ func TestOverlayRejectsDirectiveOwnedKinds(t *testing.T) { builder := topology.NewStateBuilder(zap.NewNop(), resolver) reg := NewRegistry(history, NewTestRunner(), builder, resolver, zap.NewNop(), WithKindDirective("expanded.kind", overlayTestDirective{})) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ID: regapi.NewID("runtime", "declaration"), Kind: "expanded.kind"} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) @@ -404,14 +414,14 @@ func TestOverlayRejectsDirectiveOwnedKinds(t *testing.T) { type overlayTestDirective struct{} -func (overlayTestDirective) Expand(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { +func (overlayTestDirective) Expand(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{}, nil } func TestVersionSelectionRejectsHistoricalDurableDependencyOnOverlay(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) overlayID := regapi.NewID("runtime", "dep") consumer := regapi.Entry{ ID: regapi.NewID("app", "consumer"), Kind: regapi.EntryKind, @@ -435,7 +445,7 @@ func TestVersionSelectionRejectsHistoricalDurableDependencyOnOverlay(t *testing. func TestVersionSelectionCannotRemoveDurableOverlayDependency(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) base := regapi.Entry{ID: regapi.NewID("app", "base"), Kind: regapi.EntryKind} v1, err := reg.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: base}}) require.NoError(t, err) @@ -468,7 +478,7 @@ func TestOverlayDependencyValidationUsesTopologySemantics(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) target := regapi.Entry{ID: regapi.NewID("runtime", "target"), Kind: regapi.EntryKind, Meta: tt.targetMeta} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: target}}) require.NoError(t, err) @@ -487,7 +497,7 @@ func TestOverlayExplicitDependenciesWorkWithoutResolver(t *testing.T) { history := historymem.New() builder := topology.NewStateBuilder(zap.NewNop(), nil) reg := NewRegistry(history, NewTestRunner(), builder, nil, zap.NewNop()) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) target := regapi.Entry{ID: regapi.NewID("runtime", "target"), Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: target}}) require.NoError(t, err) @@ -502,7 +512,7 @@ func TestOverlayExplicitDependenciesWorkWithoutResolver(t *testing.T) { func TestOverlayAllowsDanglingBarePlaceholder(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ ID: regapi.NewID("runtime", "source"), Kind: regapi.EntryKind, Data: payload.New(map[string]any{"token": "${API_KEY}"}), @@ -514,7 +524,7 @@ func TestOverlayAllowsDanglingBarePlaceholder(t *testing.T) { func TestOverlayRejectsDuplicateOperations(t *testing.T) { ctx := context.Background() reg, _ := newOverlayTestRegistry(t) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) entry := regapi.Entry{ID: regapi.NewID("runtime", "duplicate"), Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{ {Kind: regapi.EntryCreate, Entry: entry}, @@ -551,7 +561,7 @@ func TestOverlayReconcilesIndexesAfterFailedCompensation(t *testing.T) { builder := topology.NewStateBuilder(zap.NewNop(), resolver) runner := &failedCompensationRunner{} reg := NewRegistry(history, runner, builder, resolver, zap.NewNop()) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), version.FromParent(nil, regapi.RootVersion))) + require.NoError(t, reg.LoadState(ctx, nil, version.FromParent(nil, regapi.RootVersion))) runner.armed = true entry := regapi.Entry{ID: regapi.NewID("runtime", "partial"), Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) @@ -574,12 +584,12 @@ func TestLoadStateReconcilesOverlaysAfterFailedCompensation(t *testing.T) { runner := &failedCompensationRunner{} reg := NewRegistry(history, runner, builder, resolver, zap.NewNop()) v0 := version.FromParent(nil, regapi.RootVersion) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) entry := regapi.Entry{ID: regapi.NewID("runtime", "partial"), Kind: regapi.EntryKind} _, err := reg.ApplyOverlay(ctx, "owner:a", 0, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: entry}}) require.NoError(t, err) runner.armed = true - regErr := reg.LoadState(ctx, hostProvenanced(nil), v0) + regErr := reg.LoadState(ctx, nil, v0) require.Error(t, regErr) _, err = reg.GetEntry(entry.ID) require.Error(t, err) diff --git a/system/registry/provenance.go b/system/registry/provenance.go deleted file mode 100644 index 0ec01c8d3..000000000 --- a/system/registry/provenance.go +++ /dev/null @@ -1,289 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "context" - "errors" - "fmt" - "sort" - - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/system/registry/topology" -) - -var _ registry.ProvenancedSnapshotReader = (*Reg)(nil) - -// applyOpsToProvenance folds a changeset into a provenance map and returns a -// new map; the input is not mutated. Rules: a create takes the operation's -// provenance, or an explicit host record when it carries none; an update with -// provenance replaces the record and one without preserves it; a delete clears -// it. An update without an existing record is an invariant violation: legacy -// state is normalized at the load boundary, never during a live transition. -func applyOpsToProvenance(prov registry.ProvenanceMap, ops registry.ChangeSet) (registry.ProvenanceMap, error) { - return foldOpsToProvenance(prov, ops, false) -} - -// applyHistoryOpsToProvenance folds durable operations and upgrades the root -// statement used before registry-owned provenance. New history rows carry -// provenance and never encode DependencyRoot, so the compatibility path is -// limited to provenance-less dependency operations read from history. -func applyHistoryOpsToProvenance(prov registry.ProvenanceMap, ops registry.ChangeSet) (registry.ProvenanceMap, error) { - return foldOpsToProvenance(prov, ops, true) -} - -func foldOpsToProvenance(prov registry.ProvenanceMap, ops registry.ChangeSet, legacyHistory bool) (registry.ProvenanceMap, error) { - out := prov.Clone() - if out == nil { - out = make(registry.ProvenanceMap, len(ops)) - } - for _, op := range ops { - id := op.Entry.ID - legacyRoot := legacyHistory && op.Provenance == nil && - op.Entry.Kind == registry.NamespaceDependency && op.Entry.DependencyRoot - switch op.Kind { - case registry.EntryCreate: - if op.Provenance != nil { - out[id] = *op.Provenance - } else { - out[id] = registry.EntryProvenance{Root: legacyRoot} - } - case registry.EntryUpdate: - if op.Provenance != nil { - out[id] = *op.Provenance - } else if existing, ok := out[id]; !ok { - return nil, registry.NewMissingProvenanceError(id) - } else if legacyRoot && !existing.Root { - existing.Root = true - out[id] = existing - } - case registry.EntryDelete: - delete(out, id) - } - } - return out, nil -} - -// annotateChangeSet fills the provenance pair on operations that lack it, from -// the maps of the states the changeset transitions between. Operations the -// dependency directive emitted keep the provenance they carry; synthesized -// deltas (rollback, version transitions) are annotated entirely from the maps. -func annotateChangeSet(ops registry.ChangeSet, from, to registry.ProvenanceMap) { - for i := range ops { - op := &ops[i] - if op.OriginalProvenance == nil { - if p, ok := from[op.Entry.ID]; ok { - cp := p - op.OriginalProvenance = &cp - } - } - if op.Provenance != nil { - continue - } - switch op.Kind { - case registry.EntryCreate, registry.EntryUpdate: - if p, ok := to[op.Entry.ID]; ok { - cp := p - op.Provenance = &cp - } - case registry.EntryDelete: - // The effective provenance of a delete is the record being removed. - if p, ok := from[op.Entry.ID]; ok { - cp := p - op.Provenance = &cp - } - } - } -} - -// provenanceForState builds a total map for an arbitrary state from the best -// available sources: the previous map for entries that survived, operation -// provenance for entries a changeset introduced, and the host record -// otherwise. Missing ownership is never interpreted as host ownership. -func provenanceForState(state registry.State, prev registry.ProvenanceMap, ops registry.ChangeSet) (registry.ProvenanceMap, error) { - prev = canonicalProvClone(prev) - byOp := make(map[registry.ID]registry.EntryProvenance, len(ops)) - for i := range ops { - id := canonicalEntryID(ops[i].Entry.ID) - if ops[i].Provenance != nil { - byOp[id] = *ops[i].Provenance - } else if ops[i].Kind == registry.EntryCreate { - byOp[id] = registry.EntryProvenance{} - } - } - out := make(registry.ProvenanceMap, len(state)) - for _, entry := range state { - id := canonicalEntryID(entry.ID) - if p, ok := prev[id]; ok { - out[id] = p - continue - } - if p, ok := byOp[id]; ok { - out[id] = p - continue - } - return nil, registry.NewMissingProvenanceError(id) - } - return out, nil -} - -// --- ProvenanceReader --- - -// EntryProvenance returns the provenance record for one entry of the current -// state. Reads are served from the atomically swapped snapshot and never take -// the registry lock. -func (r *Reg) EntryProvenance(id registry.ID) (registry.EntryProvenance, bool) { - snap := r.prov.Load() - if snap == nil { - return registry.EntryProvenance{}, false - } - p, ok := (*snap)[canonicalEntryID(id)] - return p, ok -} - -// ResidentModules folds the current provenance into module name to resident -// artifact identity. -func (r *Reg) ResidentModules() (map[string]registry.EntryProvenance, error) { - snap := r.prov.Load() - if snap == nil { - return nil, nil - } - out := make(map[string]registry.EntryProvenance) - conflict := "" - for _, p := range *snap { - if p.Module == "" { - continue - } - if resident, ok := out[p.Module]; ok && - (resident.Version != p.Version || resident.Digest != p.Digest) { - if conflict == "" || p.Module < conflict { - conflict = p.Module - } - } - out[p.Module] = registry.EntryProvenance{Module: p.Module, Version: p.Version, Digest: p.Digest} - } - if conflict != "" { - return nil, registry.NewConflictingModuleProvenanceError(conflict) - } - return out, nil -} - -// DependencyRoots returns the IDs of the current deployment roots. -func (r *Reg) DependencyRoots() []registry.ID { - snap := r.prov.Load() - if snap == nil { - return nil - } - var roots []registry.ID - for id, p := range *snap { - if p.Root { - roots = append(roots, id) - } - } - sort.Slice(roots, func(i, j int) bool { - return roots[i].String() < roots[j].String() - }) - return roots -} - -// provenanceSnapshot returns the live immutable map for registry-internal reads. -// Values crossing a component boundary must clone it. -func (r *Reg) provenanceSnapshot() registry.ProvenanceMap { - snap := r.prov.Load() - if snap == nil { - return nil - } - return *snap -} - -// publishProvenance swaps the provenance snapshot. Called with r.mu held at -// the same points the state is swapped, so state and provenance move together. -func (r *Reg) publishProvenance(prov registry.ProvenanceMap) { - owned := canonicalProvClone(prov) - r.prov.Store(&owned) -} - -// SnapshotState returns the current version, entries, and provenance as one -// consistent view: all three are read under the same lock, so a concurrent -// apply cannot produce a mismatched snapshot. -func (r *Reg) SnapshotState() (registry.Version, registry.ProvenancedState, error) { - r.mu.RLock() - defer r.mu.RUnlock() - entries := make(registry.State, len(r.state)) - copy(entries, r.state) - return r.currentVersion, registry.ProvenancedState{Entries: entries, Provenance: r.provenanceSnapshot().Clone()}, nil -} - -// SetDependencyRoot flips the deployment-root selection of one ns.dependency -// entry as a provenance-carrying operation, atomically against concurrent -// applies: the current entry and record are read under the apply serialization -// the operation commits with, so a concurrent module update cannot interleave. -func (r *Reg) SetDependencyRoot(ctx context.Context, id registry.ID, root bool) (registry.Version, error) { - id = canonicalEntryID(id) - - // The read and the apply are conditioned on the same version: an apply - // that lands in between fails the condition and the mutation re-reads, - // so a stale entry or tuple can never overwrite a concurrent update. - for attempt := 0; attempt < setRootRetries; attempt++ { - r.mu.RLock() - base := r.currentVersion - r.mu.RUnlock() - - entry, getErr := r.GetEntry(id) - if getErr != nil { - return nil, getErr - } - if entry.Kind != registry.NamespaceDependency { - return nil, topology.NewInvalidOperationError(errNotDependencyEntry) - } - current, ok := r.EntryProvenance(id) - if !ok { - return nil, registry.NewMissingProvenanceError(id) - } - if current.Root == root { - return base, nil - } - next := current - next.Root = root - op := registry.Operation{ - Kind: registry.EntryUpdate, - Entry: entry, - Provenance: &next, - OriginalProvenance: ¤t, - } - - version, applyErr := r.applyFrom(ctx, registry.ChangeSet{op}, base) - if applyErr == nil { - return version, nil - } - if !errors.Is(applyErr, ErrConcurrentApply) { - return nil, applyErr - } - } - return nil, NewConcurrentApplyError(0, 0) -} - -const setRootRetries = 4 - -var errNotDependencyEntry = fmt.Errorf("only ns.dependency entries select deployment roots") - -// ProvenancedStateAtVersion reconstructs the declarative state and its -// provenance at one stored version: the deployment baseline plus the authored -// history, with provenance folded the same way live replay folds it. Module -// worlds reconciled from stored resolutions materialize only through -// ApplyVersion, which alone may stage their effects; a snapshot answers for -// the declarative layer. The baseline is copied under the lock and history -// replays without it, so a long history never blocks writers. -func (r *Reg) ProvenancedStateAtVersion(ctx context.Context, v registry.Version) (registry.ProvenancedState, error) { - r.mu.RLock() - baseline := make(registry.State, len(r.baseline)) - copy(baseline, r.baseline) - baselineProv := r.baselineProv.Clone() - r.mu.RUnlock() - - entries, prov, err := replayVersionState(ctx, r.history, baseline, baselineProv, v) - if err != nil { - return registry.ProvenancedState{}, err - } - return registry.ProvenancedState{Entries: entries, Provenance: prov}, nil -} diff --git a/system/registry/provenance_test.go b/system/registry/provenance_test.go deleted file mode 100644 index 490e4d1c1..000000000 --- a/system/registry/provenance_test.go +++ /dev/null @@ -1,403 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/payload" - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" - historymem "github.com/wippyai/runtime/system/registry/history/memory" - "github.com/wippyai/runtime/system/registry/topology" - "go.uber.org/zap" -) - -func newTestRegistry(t *testing.T) (*Reg, *MockRunner) { - t.Helper() - hist := historymem.New() - _ = hist.Save(version.New(registry.RootVersion), registry.ChangeSet{}, true) - runner := NewMockRunner() - builder := topology.NewStateBuilder(zap.NewNop(), nil) - return NewRegistry(hist, runner, builder, topology.NewResolver(), zap.NewNop()), runner -} - -func payloadNew(v any) payload.Payload { - return payload.New(v) -} - -func provOp(kind string, id registry.ID, p *registry.EntryProvenance) registry.Operation { - return registry.Operation{ - Kind: kind, - Entry: registry.Entry{ID: id, Kind: "test"}, - Provenance: p, - } -} - -func TestApplyOpsToProvenance(t *testing.T) { - a := registry.NewID("ns", "a") - b := registry.NewID("ns", "b") - owned := ®istry.EntryProvenance{Module: "org/mod", Version: "1.0.0", Digest: "sha256:x"} - - t.Run("create with provenance records it", func(t *testing.T) { - out, err := applyOpsToProvenance(nil, registry.ChangeSet{provOp(registry.EntryCreate, a, owned)}) - require.NoError(t, err) - assert.Equal(t, *owned, out[a]) - }) - - t.Run("create without provenance records the host entry", func(t *testing.T) { - out, err := applyOpsToProvenance(nil, registry.ChangeSet{provOp(registry.EntryCreate, a, nil)}) - require.NoError(t, err) - p, ok := out[a] - require.True(t, ok, "total map: every created entry has a record") - assert.True(t, p.HostAuthored()) - }) - - t.Run("update without provenance preserves the record", func(t *testing.T) { - prov := registry.ProvenanceMap{a: *owned} - out, err := applyOpsToProvenance(prov, registry.ChangeSet{provOp(registry.EntryUpdate, a, nil)}) - require.NoError(t, err) - assert.Equal(t, *owned, out[a]) - }) - - t.Run("update with provenance replaces the record", func(t *testing.T) { - prov := registry.ProvenanceMap{a: *owned} - next := ®istry.EntryProvenance{Module: "org/mod", Version: "2.0.0", Digest: "sha256:y"} - out, err := applyOpsToProvenance(prov, registry.ChangeSet{provOp(registry.EntryUpdate, a, next)}) - require.NoError(t, err) - assert.Equal(t, *next, out[a]) - }) - - t.Run("update of an unknown entry fails closed", func(t *testing.T) { - _, err := applyOpsToProvenance(nil, registry.ChangeSet{provOp(registry.EntryUpdate, a, nil)}) - require.ErrorIs(t, err, registry.ErrMissingProvenance) - }) - - t.Run("delete clears the record", func(t *testing.T) { - prov := registry.ProvenanceMap{a: *owned, b: {}} - out, err := applyOpsToProvenance(prov, registry.ChangeSet{provOp(registry.EntryDelete, a, nil)}) - require.NoError(t, err) - _, ok := out[a] - assert.False(t, ok) - _, ok = out[b] - assert.True(t, ok) - }) - - t.Run("live create ignores the legacy root flag", func(t *testing.T) { - op := registry.Operation{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - } - out, err := applyOpsToProvenance(nil, registry.ChangeSet{op}) - require.NoError(t, err) - p, ok := out[a] - require.True(t, ok) - assert.False(t, p.Root, "live root selection is registry-owned") - assert.True(t, p.HostAuthored()) - }) - - t.Run("live update ignores the legacy root flag", func(t *testing.T) { - prov := registry.ProvenanceMap{a: {Module: "org/mod", Version: "1.0.0", Digest: "sha256:x"}} - op := registry.Operation{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - } - out, err := applyOpsToProvenance(prov, registry.ChangeSet{op}) - require.NoError(t, err) - assert.False(t, out[a].Root) - assert.Equal(t, prov[a].Module, out[a].Module) - }) - - t.Run("a flagless update never demotes a root", func(t *testing.T) { - // A modern user edit reaches this fold with neither provenance nor the - // legacy flag; root-ness is registry state and only set_root moves it. - prov := registry.ProvenanceMap{a: {Root: true}} - op := registry.Operation{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: a, Kind: "ns.dependency"}, - } - out, err := applyOpsToProvenance(prov, registry.ChangeSet{op}) - require.NoError(t, err) - assert.True(t, out[a].Root) - }) - - t.Run("history create promotes the legacy dependency root", func(t *testing.T) { - op := registry.Operation{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - } - out, err := applyHistoryOpsToProvenance(nil, registry.ChangeSet{op}) - require.NoError(t, err) - assert.True(t, out[a].Root) - }) - - t.Run("history update promotes without replacing ownership", func(t *testing.T) { - prov := registry.ProvenanceMap{a: {Module: "org/mod", Version: "1.0.0", Digest: "sha256:x"}} - op := registry.Operation{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - } - out, err := applyHistoryOpsToProvenance(prov, registry.ChangeSet{op}) - require.NoError(t, err) - assert.True(t, out[a].Root) - assert.Equal(t, prov[a].Module, out[a].Module) - }) - - t.Run("history ignores the dead flag on non-dependencies", func(t *testing.T) { - op := registry.Operation{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: a, Kind: "service", DependencyRoot: true}, - } - out, err := applyHistoryOpsToProvenance(nil, registry.ChangeSet{op}) - require.NoError(t, err) - assert.False(t, out[a].Root) - }) - - t.Run("history provenance overrides the legacy flag", func(t *testing.T) { - op := registry.Operation{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - Provenance: ®istry.EntryProvenance{Module: "org/mod", Root: false}, - } - out, err := applyHistoryOpsToProvenance(nil, registry.ChangeSet{op}) - require.NoError(t, err) - assert.False(t, out[a].Root) - assert.Equal(t, "org/mod", out[a].Module) - }) - - t.Run("history promotion does not cross delete and recreate", func(t *testing.T) { - prov := registry.ProvenanceMap{a: {Module: "org/old", Root: false}} - ops := registry.ChangeSet{ - { - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency, DependencyRoot: true}, - }, - {Kind: registry.EntryDelete, Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency}}, - {Kind: registry.EntryCreate, Entry: registry.Entry{ID: a, Kind: registry.NamespaceDependency}}, - } - out, err := applyHistoryOpsToProvenance(prov, ops) - require.NoError(t, err) - assert.False(t, out[a].Root) - assert.True(t, out[a].HostAuthored()) - }) - - t.Run("input map is not mutated", func(t *testing.T) { - prov := registry.ProvenanceMap{a: *owned} - _, err := applyOpsToProvenance(prov, registry.ChangeSet{provOp(registry.EntryDelete, a, nil)}) - require.NoError(t, err) - assert.Contains(t, prov, a) - }) - - t.Run("fold is deterministic over composition", func(t *testing.T) { - ops1 := registry.ChangeSet{provOp(registry.EntryCreate, a, owned)} - ops2 := registry.ChangeSet{provOp(registry.EntryDelete, a, nil), provOp(registry.EntryCreate, b, nil)} - intermediate, err := applyOpsToProvenance(nil, ops1) - require.NoError(t, err) - once, err := applyOpsToProvenance(intermediate, ops2) - require.NoError(t, err) - all, err := applyOpsToProvenance(nil, append(append(registry.ChangeSet{}, ops1...), ops2...)) - require.NoError(t, err) - assert.Equal(t, all, once) - }) -} - -func TestAnnotateChangeSet(t *testing.T) { - a := registry.NewID("ns", "a") - from := registry.ProvenanceMap{a: {Module: "org/mod", Version: "1.0.0"}} - to := registry.ProvenanceMap{a: {Module: "org/mod", Version: "2.0.0"}} - - t.Run("update is annotated from both maps", func(t *testing.T) { - ops := registry.ChangeSet{provOp(registry.EntryUpdate, a, nil)} - annotateChangeSet(ops, from, to) - require.NotNil(t, ops[0].Provenance) - require.NotNil(t, ops[0].OriginalProvenance) - assert.Equal(t, "2.0.0", ops[0].Provenance.Version) - assert.Equal(t, "1.0.0", ops[0].OriginalProvenance.Version) - }) - - t.Run("delete takes its effective provenance from the source map", func(t *testing.T) { - ops := registry.ChangeSet{provOp(registry.EntryDelete, a, nil)} - annotateChangeSet(ops, from, registry.ProvenanceMap{}) - require.NotNil(t, ops[0].Provenance) - assert.Equal(t, "1.0.0", ops[0].Provenance.Version) - }) - - t.Run("directive-supplied provenance is kept", func(t *testing.T) { - supplied := ®istry.EntryProvenance{Module: "org/other"} - ops := registry.ChangeSet{provOp(registry.EntryUpdate, a, supplied)} - annotateChangeSet(ops, from, to) - assert.Same(t, supplied, ops[0].Provenance) - require.NotNil(t, ops[0].OriginalProvenance) - }) -} - -func TestProvenanceForState(t *testing.T) { - a := registry.NewID("ns", "a") - b := registry.NewID("ns", "b") - state := registry.State{{ID: a, Kind: "test"}, {ID: b, Kind: "test"}} - prev := registry.ProvenanceMap{a: {Module: "org/mod"}} - ops := registry.ChangeSet{provOp(registry.EntryCreate, b, ®istry.EntryProvenance{Module: "org/new"})} - - out, err := provenanceForState(state, prev, ops) - require.NoError(t, err) - assert.Equal(t, "org/mod", out[a].Module) - assert.Equal(t, "org/new", out[b].Module) - assert.Len(t, out, 2) -} - -// TestApplyPublishesProvenanceWithState pins that the reader surface serves the -// records the applied changeset established, atomically with the state. -func TestApplyPublishesProvenanceWithState(t *testing.T) { - reg, runner := newTestRegistry(t) - id := registry.NewID("ns", "svc") - entry := registry.Entry{ID: id, Kind: "test", Data: payloadNew("v1")} - runner.RunFunc = func(_ registry.State, cs registry.ChangeSet) (registry.State, error) { - out := registry.State{} - for _, op := range cs { - out = append(out, op.Entry) - } - return out, nil - } - - _, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: entry, - Provenance: ®istry.EntryProvenance{Module: "org/mod", Version: "1.0.0", Digest: "sha256:x", Root: false}, - }}) - require.NoError(t, err) - - p, ok := reg.EntryProvenance(id) - require.True(t, ok) - assert.Equal(t, "org/mod", p.Module) - assert.Equal(t, "1.0.0", p.Version) - - resident, err := reg.ResidentModules() - require.NoError(t, err) - require.Contains(t, resident, "org/mod") - assert.Equal(t, "sha256:x", resident["org/mod"].Digest) -} - -func TestApplyIgnoresLegacyDependencyRootFlag(t *testing.T) { - history := historymem.New() - require.NoError(t, history.Save(version.New(registry.RootVersion), nil, true)) - newRegistry := func() *Reg { - resolver := topology.NewResolver() - return NewRegistry( - history, - NewTestRunner(), - topology.NewStateBuilder(zap.NewNop(), resolver), - resolver, - zap.NewNop(), - ) - } - reg := newRegistry() - id := registry.NewID("app.requirements", "module") - - v1, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ - ID: id, - Kind: registry.NamespaceDependency, - DependencyRoot: true, - }, - }}) - require.NoError(t, err) - require.Empty(t, reg.DependencyRoots(), "live operations must use SetDependencyRoot") - record, ok := reg.EntryProvenance(id) - require.True(t, ok) - assert.False(t, record.Root) - - restarted := newRegistry() - require.NoError(t, restarted.LoadState(t.Context(), registry.ProvenancedState{}, v1)) - require.Empty(t, restarted.DependencyRoots(), "a current persisted operation is not legacy on replay") -} - -// TestUserUpdatePreservesProvenance pins the echo-back rule: a user operation -// carrying no provenance leaves ownership untouched. -func TestUserUpdatePreservesProvenance(t *testing.T) { - reg, runner := newTestRegistry(t) - id := registry.NewID("ns", "svc") - runner.RunFunc = func(state registry.State, cs registry.ChangeSet) (registry.State, error) { - m := map[registry.ID]registry.Entry{} - for _, e := range state { - m[e.ID] = e - } - for _, op := range cs { - switch op.Kind { - case registry.EntryCreate, registry.EntryUpdate: - m[op.Entry.ID] = op.Entry - case registry.EntryDelete: - delete(m, op.Entry.ID) - } - } - out := registry.State{} - for _, e := range m { - out = append(out, e) - } - return out, nil - } - - _, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: id, Kind: "test", Data: payloadNew("v1")}, - Provenance: ®istry.EntryProvenance{Module: "org/mod", Version: "1.0.0"}, - }}) - require.NoError(t, err) - - _, err = reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: id, Kind: "test", Data: payloadNew("v2")}, - }}) - require.NoError(t, err) - - p, ok := reg.EntryProvenance(id) - require.True(t, ok) - assert.Equal(t, "org/mod", p.Module, "user echo-back must not strip ownership") - - _, err = reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryDelete, - Entry: registry.Entry{ID: id, Kind: "test"}, - }}) - require.NoError(t, err) - _, ok = reg.EntryProvenance(id) - assert.False(t, ok, "delete clears the record") -} - -func TestSnapshotStateDoesNotExposeLiveProvenance(t *testing.T) { - reg, _ := newTestRegistry(t) - id := registry.NewID("ns", "svc") - reg.publishProvenance(registry.ProvenanceMap{id: {Module: "org/mod", Version: "1.0.0"}}) - - _, snapshot, err := reg.SnapshotState() - require.NoError(t, err) - snapshot.Provenance[id] = registry.EntryProvenance{} - - p, ok := reg.EntryProvenance(id) - require.True(t, ok) - assert.Equal(t, "org/mod", p.Module) -} - -func TestPublishProvenanceTakesOwnership(t *testing.T) { - reg, _ := newTestRegistry(t) - id := registry.NewID("ns", "svc") - input := registry.ProvenanceMap{id: {Module: "org/mod", Version: "1.0.0"}} - reg.publishProvenance(input) - input[id] = registry.EntryProvenance{} - - p, ok := reg.EntryProvenance(id) - require.True(t, ok) - assert.Equal(t, "org/mod", p.Module) -} - -func TestResidentModulesRejectsConflictingIdentity(t *testing.T) { - reg, _ := newTestRegistry(t) - reg.publishProvenance(registry.ProvenanceMap{ - registry.NewID("ns", "a"): {Module: "org/mod", Version: "1.0.0", Digest: "sha256:a"}, - registry.NewID("ns", "b"): {Module: "org/mod", Version: "2.0.0", Digest: "sha256:b"}, - }) - - _, err := reg.ResidentModules() - require.ErrorIs(t, err, registry.ErrConflictingModuleProvenance) -} diff --git a/system/registry/registry.go b/system/registry/registry.go index 7269a6470..0002ca7a2 100644 --- a/system/registry/registry.go +++ b/system/registry/registry.go @@ -31,15 +31,14 @@ type Reg struct { resolver registry.DependencyResolver history registry.History overlays map[string]registry.State - baselineProv registry.ProvenanceMap + currentResolution *registry.DependencyResolution stateIndex map[registry.ID]int directivesByKind map[registry.Kind][]registry.Directive log *zap.Logger depIndex *topology.DepIndex overlayOwners map[registry.ID]string overlayGeneration map[string]uint64 - prov atomic.Pointer[registry.ProvenanceMap] - currentResolution *registry.DependencyResolution + snapshot atomic.Pointer[registry.Snapshot] state registry.State baseline registry.State overlayEpoch uint64 @@ -77,6 +76,7 @@ func NewRegistry( } reg.versionNum.Store(0) + reg.publishSnapshot() for _, opt := range opts { if opt != nil { @@ -150,28 +150,36 @@ func (r *Reg) GetEntry(path registry.ID) (registry.Entry, error) { return registry.Entry{}, NewEntryNotFoundError(path) } -// --- StateWriter Interface Implementation --- +func (r *Reg) Snapshot() registry.Snapshot { + current := r.snapshot.Load() + if current == nil { + return registry.Snapshot{} + } + entries := append(registry.State(nil), current.Entries...) + return registry.Snapshot{ + Version: current.Version, + Entries: entries, + Registry: registry.StateMetadata{Resolution: current.Registry.Resolution.Canonical()}, + } +} -func (r *Reg) Apply(ctx context.Context, changes registry.ChangeSet) (registry.Version, error) { - return r.applyFrom(ctx, changes, nil) +// publishSnapshot atomically exposes the state, version, and registry-owned +// metadata selected by one completed transition. Callers either hold r.mu for +// writes or are constructing an unpublished registry. +func (r *Reg) publishSnapshot() { + entries := append(registry.State(nil), r.state...) + r.snapshot.Store(®istry.Snapshot{ + Version: r.currentVersion, + Entries: entries, + Registry: registry.StateMetadata{Resolution: r.currentResolution}, + }) } -// applyFrom applies a changeset conditioned on a base version: a non-nil -// expected version that no longer matches the current one fails with the -// concurrent-apply conflict before anything is planned, giving optimistic -// callers an exact read-check-write boundary. -func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expected registry.Version) (registry.Version, error) { +// --- StateWriter Interface Implementation --- + +func (r *Reg) Apply(ctx context.Context, changes registry.ChangeSet) (registry.Version, error) { r.applyMu.Lock() defer r.applyMu.Unlock() - - if expected != nil { - r.mu.RLock() - currentID := r.currentVersion.ID() - r.mu.RUnlock() - if currentID != expected.ID() { - return nil, NewConcurrentApplyError(expected.ID(), currentID) - } - } changes = append(registry.ChangeSet(nil), changes...) canonicalizeChangeSetIDs(changes) @@ -186,7 +194,6 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte baseVersion registry.Version resolution *registry.DependencyResolution resolutionChanged bool - planProvenance registry.ProvenanceMap ) r.mu.RLock() @@ -194,13 +201,13 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte copy(snapshot, r.state) baseVersion = r.currentVersion resolution = r.currentResolution - liveProv := r.provenanceSnapshot().Clone() r.mu.RUnlock() + changes = normalizeRegistryMetadata(changes, snapshot) if len(r.directivesByKind) > 0 { planner = regexp.NewPlanner(r.directivesByKind, r.resolver, r.log.Named("expansion")) - plan, err := planner.Expand(ctx, changes, registry.ProvenancedState{Entries: snapshot, Provenance: liveProv}) + plan, err := planner.Expand(ctx, changes, snapshot) if err != nil { return nil, NewExpandChangesError(err) } @@ -212,13 +219,9 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte } allOps, historyOps = plan.SplitScopes() - planProvenance = plan.Provenance if plan.Resolution != nil { - candidate := plan.Resolution.Canonical() - if resolution == nil || candidate.Digest != resolution.Digest { - resolution = candidate - resolutionChanged = true - } + resolution = plan.Resolution.Canonical() + resolutionChanged = true } preparedEff, err = planner.PrepareEffects(ctx, plan.Effects) @@ -255,21 +258,6 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte return nil, err } - newProv, err := applyOpsToProvenance(liveProv, allOps) - if err != nil { - if planner != nil { - planner.RollbackEffects(ctx, preparedEff) - } - return nil, NewProvenanceInvariantError(err) - } - for id, record := range planProvenance { - newProv[canonicalEntryID(id)] = record - } - annotateChangeSet(allOps, liveProv, newProv) - - runnerOutcome := ®istry.RollbackOutcome{} - ctx = registry.WithRollbackOutcome(ctx, runnerOutcome) - r.mu.Lock() defer r.mu.Unlock() @@ -280,9 +268,8 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte return nil, NewConcurrentApplyError(baseVersion.ID(), r.currentVersion.ID()) } - provenanceAdvanced := len(planProvenance) > 0 var newVersion registry.Version - if len(historyOps) > 0 || resolutionChanged || provenanceAdvanced { + if len(historyOps) > 0 { newVersion = version.FromParent(r.currentVersion, r.nextVersionID(r.currentVersion)) } @@ -291,7 +278,7 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte if err != nil { r.log.Error("failed to apply changes", zap.Error(err)) if newState != nil && ctx.Err() == nil { - if rerr := r.rollback(ctx, newState, r.state, newProv, runnerOutcome); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { if planner != nil { planner.RollbackEffects(ctx, preparedEff) } @@ -307,7 +294,7 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte if planner != nil { if err := planner.CommitEffects(ctx, preparedEff); err != nil { r.log.Error("failed to commit effects", zap.Error(err)) - if rerr := r.rollback(ctx, newState, r.state, newProv, runnerOutcome); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { planner.RollbackEffects(ctx, preparedEff) return nil, NewCommitEffectsError(err, rerr) } @@ -316,7 +303,7 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte } } - if len(historyOps) > 0 || resolutionChanged || provenanceAdvanced { + if len(historyOps) > 0 { r.log.Debug("saving new version", zap.Any("new_version", newVersion)) enrichedChanges := r.enrichChangeset(historyOps) @@ -333,7 +320,7 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte } if saveErr != nil { r.log.Error("failed to save new version", zap.Error(saveErr)) - if rerr := r.rollback(ctx, newState, r.state, newProv, runnerOutcome); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { if planner != nil { planner.RollbackEffects(ctx, preparedEff) } @@ -353,16 +340,16 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte r.state = newState r.rebuildIndex() r.patchDepIndex(allOps) - r.publishProvenance(newProv) r.currentVersion = newVersion r.currentResolution = resolution + r.publishSnapshot() return newVersion, nil } r.state = newState r.rebuildIndex() r.patchDepIndex(allOps) - r.publishProvenance(newProv) + r.publishSnapshot() if planner != nil { if finalizeErr := planner.FinalizeEffects(ctx, preparedEff); finalizeErr != nil { r.log.Warn("failed to finalize effects after baseline transition", zap.Error(finalizeErr)) @@ -371,6 +358,39 @@ func (r *Reg) applyFrom(ctx context.Context, changes registry.ChangeSet, expecte return r.currentVersion, nil } +// normalizeRegistryMetadata prevents entry-authored changes from assigning +// ownership. Ownership comes from the deployment source; root selection may +// change independently while the entry remains resident. +func normalizeRegistryMetadata(changes registry.ChangeSet, snapshot registry.State) registry.ChangeSet { + state := make(registry.StateMap, len(snapshot)+len(changes)) + for _, entry := range snapshot { + state[entry.ID] = entry + } + for i := range changes { + op := &changes[i] + switch op.Kind { + case registry.EntryCreate: + op.Entry.Registry = registry.EntryMetadata{Root: op.Entry.Registry.Root} + state[op.Entry.ID] = op.Entry + case registry.EntryUpdate: + existing, ok := state[op.Entry.ID] + if ok { + op.Entry.Registry = existing.Registry + } + state[op.Entry.ID] = op.Entry + case registry.EntryDelete: + if existing, ok := state[op.Entry.ID]; ok { + op.Entry = existing + } + delete(state, op.Entry.ID) + } + } + return changes +} + +// patchDepIndex folds committed ops back into the inverse-dependency index so +// the next Apply can keep using O(1) source-side lookups. No-op when the +// builder doesn't expose the indexed sort. func (r *Reg) patchDepIndex(ops registry.ChangeSet) { if r.depIndex == nil { return @@ -391,7 +411,6 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { snapshot = make(registry.State, len(r.state)) copy(snapshot, r.state) baseVersion = r.currentVersion - liveProv := r.provenanceSnapshot().Clone() r.mu.RUnlock() if baseVersion != nil && baseVersion.ID() == v.ID() { @@ -412,7 +431,7 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { if err != nil { return err } - targetState, targetProv, err := r.stateAtVersion(ctx, targetVersion) + targetState, err := r.stateAtVersion(ctx, targetVersion) if err != nil { return err } @@ -444,10 +463,7 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { stateMap := topology.NewStateMap(targetState) reconciled := false for _, directive := range r.directivesByKind[registry.NamespaceDependency] { - result, ok, reconcileErr := reconcileStoredResolution(ctx, directive, - registry.ProvenancedState{Entries: snapshot, Provenance: liveProv}, - registry.ProvenancedState{Entries: topology.StateMapToSlice(stateMap), Provenance: targetProv}, - targetResolution) + result, ok, reconcileErr := reconcileStoredResolution(ctx, directive, snapshot, topology.StateMapToSlice(stateMap), targetResolution) if !ok { continue } @@ -471,14 +487,6 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { additional = append(additional, operation.Operation) } applyStateOperations(stateMap, additional) - targetProv, reconcileErr = applyOpsToProvenance(targetProv, additional) - if reconcileErr != nil { - planner.RollbackEffects(ctx, preparedEff) - return NewProvenanceInvariantError(reconcileErr) - } - for id, record := range result.Provenance { - targetProv[canonicalEntryID(id)] = record - } } if !reconciled { planner.RollbackEffects(ctx, preparedEff) @@ -506,7 +514,7 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { continue } intermediate := topology.StateMapToSlice(stateMap) - plan, expandErr := planner.Expand(ctx, registry.ChangeSet{{Kind: registry.EntryUpdate, Entry: entry}}, registry.ProvenancedState{Entries: intermediate, Provenance: targetProv}) + plan, expandErr := planner.Expand(ctx, registry.ChangeSet{{Kind: registry.EntryUpdate, Entry: entry}}, intermediate) if expandErr != nil { planner.RollbackEffects(ctx, preparedEff) return NewExpandChangesError(expandErr) @@ -532,14 +540,6 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { targetResolution = plan.Resolution.Canonical() } applyStateOperations(stateMap, ops) - targetProv, expandErr = applyOpsToProvenance(targetProv, ops) - if expandErr != nil { - planner.RollbackEffects(ctx, preparedEff) - return NewProvenanceInvariantError(expandErr) - } - for id, record := range plan.Provenance { - targetProv[canonicalEntryID(id)] = record - } } if composeErr := r.composeOverlays(stateMap); composeErr != nil { planner.RollbackEffects(ctx, preparedEff) @@ -561,12 +561,6 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { } allOps = delta } - if err := r.mergeOverlayProvenance(targetProv, liveProv); err != nil { - if planner != nil { - planner.RollbackEffects(ctx, preparedEff) - } - return NewProvenanceInvariantError(err) - } resolutionHeadCAS, supportsAtomicResolutionHead := r.history.(registry.ResolutionHeadCASHistory) if targetResolution != nil && !supportsAtomicResolutionHead { @@ -605,12 +599,11 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { return NewConcurrentApplyError(baseVersion.ID(), r.currentVersion.ID()) } - annotateChangeSet(allOps, r.provenanceSnapshot(), targetProv) newState, err := r.runner.Transition(ctx, r.state, allOps) if err != nil { r.log.Error("failed to apply squashed changeset", zap.Error(err)) if newState != nil && ctx.Err() == nil { - if rerr := r.rollback(ctx, newState, r.state, targetProv, nil); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { if planner != nil { planner.RollbackEffects(ctx, preparedEff) } @@ -626,7 +619,7 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { if planner != nil { if err := planner.CommitEffects(ctx, preparedEff); err != nil { r.log.Error("failed to commit effects", zap.Error(err)) - if rerr := r.rollback(ctx, newState, r.state, targetProv, nil); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { planner.RollbackEffects(ctx, preparedEff) return NewCommitEffectsError(err, rerr) } @@ -644,7 +637,7 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { if headUpdateErr != nil { headErr := NewSetHeadError(targetVersion.ID(), headUpdateErr) var compensationErr error - if rollbackErr := r.rollback(ctx, newState, r.state, targetProv, nil); rollbackErr != nil { + if rollbackErr := r.rollback(ctx, newState, r.state); rollbackErr != nil { compensationErr = errors.Join(compensationErr, rollbackErr) } if planner != nil { @@ -661,16 +654,12 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { } } - publishedProv, err := provenanceForState(newState, targetProv, allOps) - if err != nil { - return NewProvenanceInvariantError(err) - } r.state = newState r.rebuildIndex() r.rebuildDepIndex() - r.publishProvenance(publishedProv) r.currentVersion = targetVersion r.currentResolution = targetResolution + r.publishSnapshot() r.log.Debug("version applied successfully", zap.Uint("version", targetVersion.ID())) return nil @@ -680,55 +669,36 @@ func (r *Reg) ApplyVersion(ctx context.Context, v registry.Version) error { // history at target. Live version selection must use the same authority model // as cold boot; reversing operations against the expanded resident state can // otherwise delete baseline entries hidden by an overlay. -func (r *Reg) stateAtVersion(ctx context.Context, target registry.Version) (registry.State, registry.ProvenanceMap, error) { - return replayVersionState(ctx, r.history, r.baseline, r.baselineProv, target) -} - -// replayVersionState composes a baseline and its provenance with the authored -// history up to target. It reads only its arguments and the history backend, -// so callers can replay without holding registry locks. -func replayVersionState( - ctx context.Context, - history registry.History, - baseline registry.State, - baselineProv registry.ProvenanceMap, - target registry.Version, -) (registry.State, registry.ProvenanceMap, error) { - stateMap := topology.NewStateMap(baseline) - prov := baselineProv.Clone() - if prov == nil { - prov = make(registry.ProvenanceMap) - } +func (r *Reg) stateAtVersion(ctx context.Context, target registry.Version) (registry.State, error) { + stateMap := topology.NewStateMap(r.baseline) if target == nil || target.ID() == registry.RootVersion { - return topology.StateMapToSlice(stateMap), prov, nil + return topology.StateMapToSlice(stateMap), nil } apply := func(changes registry.ChangeSet) error { canonicalizeChangeSetIDs(changes) applyStateOperations(stateMap, changes) - var err error - prov, err = applyHistoryOpsToProvenance(prov, changes) - return err + return nil } - if replayer, ok := history.(registry.ChangeSetReplayer); ok { + if replayer, ok := r.history.(registry.ChangeSetReplayer); ok { if err := replayer.ReplayChanges(ctx, target, apply); err != nil { - return nil, nil, NewGetChangesetError(target.ID(), err) + return nil, NewGetChangesetError(target.ID(), err) } - return topology.StateMapToSlice(stateMap), prov, nil + return topology.StateMapToSlice(stateMap), nil } var lineage []registry.Version for current := target; current != nil && current.ID() > registry.RootVersion; current = current.Previous() { lineage = append(lineage, current) } for i := len(lineage) - 1; i >= 0; i-- { - changes, err := history.Get(lineage[i]) + changes, err := r.history.Get(lineage[i]) if err != nil { - return nil, nil, NewGetChangesetError(lineage[i].ID(), err) + return nil, NewGetChangesetError(lineage[i].ID(), err) } if err := apply(changes); err != nil { - return nil, nil, err + return nil, err } } - return topology.StateMapToSlice(stateMap), prov, nil + return topology.StateMapToSlice(stateMap), nil } func compareAndSetHistoryHead(history registry.History, expected, target registry.Version) error { @@ -762,16 +732,51 @@ func (r *Reg) findStoredVersion(v registry.Version) (registry.Version, error) { return nil, NewVersionNotFoundError(v.ID()) } -// LoadState initializes registry state from baseline and history without -// creating new version records: the baseline applies directly and changesets -// v1..targetVersion replay on top. The baseline carries its provenance; -// replayed and reconciled operations fold theirs in, so the published -// provenance map matches the final state. -func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedState, targetVersion registry.Version) error { - baseline := baselineState.Entries - if err := baselineState.Validate(); err != nil { - return NewLoadStateError(err, nil) +func (r *Reg) collectBackwardChangesets(path []registry.Version, targetVersion registry.Version) (registry.ChangeSet, error) { + commonAncestorIdx := -1 + for i, ver := range path { + if ver.ID() <= r.currentVersion.ID() && ver.ID() <= targetVersion.ID() { + commonAncestorIdx = i + break + } + } + if commonAncestorIdx < 0 { + return nil, NewComputePathError(r.currentVersion.ID(), targetVersion.ID(), ErrNoCommonAncestor) + } + + var reversedChangesets []registry.ChangeSet + current := r.currentVersion + for current != nil && current.ID() > path[commonAncestorIdx].ID() { + cs, err := r.history.Get(current) + if err != nil { + return nil, NewGetChangesetError(current.ID(), err) + } + rev, err := r.builder.ReverseChangeset(cs) + if err != nil { + return nil, NewReverseChangesetError(err) + } + reversedChangesets = append(reversedChangesets, rev) + current = current.Previous() + } + + for i := commonAncestorIdx; i < len(path); i++ { + if path[i].ID() > path[commonAncestorIdx].ID() { + cs, err := r.history.Get(path[i]) + if err != nil { + return nil, NewGetChangesetError(path[i].ID(), err) + } + reversedChangesets = append(reversedChangesets, cs) + } } + + return r.builder.SquashChangesets(reversedChangesets), nil +} + +// LoadState initializes registry state from baseline and history without creating new version records. +// This is used during boot to restore state from lockfile + history replay. +// For v0 (empty history): applies baseline directly +// For v1+: replays changesets v1..targetVersion on top of baseline, then applies final state once +func (r *Reg) LoadState(ctx context.Context, baseline registry.State, targetVersion registry.Version) error { if registry.DependencyAccessFromContext(ctx) == registry.DependencyAccessUnspecified { ctx = registry.WithDependencyAccess(ctx, registry.DependencyAccessVerifiedOffline) } @@ -818,10 +823,6 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS } stateMap := topology.NewStateMap(baseline) - targetProv := make(registry.ProvenanceMap, len(baseline)) - for id, p := range baselineState.Provenance { - targetProv[canonicalEntryID(id)] = p - } var planner *regexp.Planner var preparedEff []registry.Effect var resolution *registry.DependencyResolution @@ -836,9 +837,7 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS applyChanges := func(cs registry.ChangeSet) error { canonicalizeChangeSetIDs(cs) applyStateOperations(stateMap, cs) - var err error - targetProv, err = applyHistoryOpsToProvenance(targetProv, cs) - return err + return nil } if replayer, ok := r.history.(registry.ChangeSetReplayer); ok { r.log.Debug("streaming history changesets on baseline", zap.Uint("target_version", targetVersion.ID())) @@ -883,10 +882,7 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS reconciled := false for _, directive := range r.directivesByKind[registry.NamespaceDependency] { snapshot := topology.StateMapToSlice(stateMap) - result, ok, err := reconcileStoredResolution(ctx, directive, - registry.ProvenancedState{Entries: baseline, Provenance: canonicalProvClone(baselineState.Provenance)}, - registry.ProvenancedState{Entries: snapshot, Provenance: targetProv}, - resolution) + result, ok, err := reconcileStoredResolution(ctx, directive, baseline, snapshot, resolution) if !ok { continue } @@ -917,14 +913,6 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS } preparedEff = append(preparedEff, prepared...) applyStateOperations(stateMap, ops) - targetProv, err = applyOpsToProvenance(targetProv, ops) - if err != nil { - planner.RollbackEffects(ctx, preparedEff) - return NewProvenanceInvariantError(err) - } - for id, record := range result.Provenance { - targetProv[canonicalEntryID(id)] = record - } } if !reconciled { planner.RollbackEffects(ctx, preparedEff) @@ -940,7 +928,7 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS continue } snapshot := topology.StateMapToSlice(stateMap) - plan, err := planner.Expand(ctx, registry.ChangeSet{{Kind: registry.EntryUpdate, Entry: entry}}, registry.ProvenancedState{Entries: snapshot, Provenance: targetProv}) + plan, err := planner.Expand(ctx, registry.ChangeSet{{Kind: registry.EntryUpdate, Entry: entry}}, snapshot) if err != nil { planner.RollbackEffects(ctx, preparedEff) return NewExpandChangesError(err) @@ -966,14 +954,6 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS resolution = plan.Resolution.Canonical() } applyStateOperations(stateMap, ops) - targetProv, err = applyOpsToProvenance(targetProv, ops) - if err != nil { - planner.RollbackEffects(ctx, preparedEff) - return NewProvenanceInvariantError(err) - } - for id, record := range plan.Provenance { - targetProv[canonicalEntryID(id)] = record - } } } @@ -987,11 +967,11 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS } finalState := topology.StateMapToSlice(stateMap) - newState, err := r.transitionState(ctx, r.state, finalState, r.provenanceSnapshot(), targetProv) + newState, err := r.transitionState(ctx, r.state, finalState) if err != nil { r.log.Error("failed to load state", zap.String("version", targetVersion.String()), zap.Error(err)) if newState != nil && ctx.Err() == nil { - if rerr := r.rollback(ctx, newState, r.state, targetProv, nil); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { if planner != nil { planner.RollbackEffects(ctx, preparedEff) } @@ -1007,7 +987,7 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS if planner != nil { if err := planner.CommitEffects(ctx, preparedEff); err != nil { r.log.Error("failed to commit load-state effects", zap.Error(err)) - if rerr := r.rollback(ctx, newState, r.state, targetProv, nil); rerr != nil { + if rerr := r.rollback(ctx, newState, r.state); rerr != nil { planner.RollbackEffects(ctx, preparedEff) return NewCommitEffectsError(err, rerr) } @@ -1025,7 +1005,7 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS } if headCheckErr != nil { var rollbackErr error - if transitionErr := r.rollback(ctx, newState, r.state, targetProv, nil); transitionErr != nil { + if transitionErr := r.rollback(ctx, newState, r.state); transitionErr != nil { rollbackErr = transitionErr } if planner != nil { @@ -1039,14 +1019,8 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS } } - publishedProv, err := provenanceForState(newState, targetProv, nil) - if err != nil { - return NewProvenanceInvariantError(err) - } r.state = newState r.baseline = append(registry.State(nil), baseline...) - r.baselineProv = canonicalProvClone(baselineState.Provenance) - r.publishProvenance(publishedProv) // LoadState is the cold/reinitialization boundary. Overlays are deliberately // process-local and their owning controllers reconcile them after boot. r.overlays = make(map[string]registry.State) @@ -1065,24 +1039,16 @@ func (r *Reg) LoadState(ctx context.Context, baselineState registry.ProvenancedS r.currentVersion = targetVersion r.currentResolution = resolution r.versionNum.Store(uint64(allocatorVersion)) + r.publishSnapshot() return nil } -// canonicalProvClone clones a provenance map under canonical IDs. -func canonicalProvClone(prov registry.ProvenanceMap) registry.ProvenanceMap { - out := make(registry.ProvenanceMap, len(prov)) - for id, p := range prov { - out[canonicalEntryID(id)] = p - } - return out -} - func reconcileStoredResolution( ctx context.Context, directive registry.Directive, - current registry.ProvenancedState, - target registry.ProvenancedState, + current registry.State, + target registry.State, resolution *registry.DependencyResolution, ) (registry.DirectiveResult, bool, error) { if reconciler, ok := directive.(registry.ResolutionTransitionDirective); ok { @@ -1124,68 +1090,25 @@ func canonicalEntryID(id registry.ID) registry.ID { return id.Canonical() } -// rollback state desync between actual state in system and state in history. -// The runner records what its compensation actually did into the context's -// RollbackOutcome. A surviving compensation is a FORWARD effect that remains -// in the state, so its entry takes the forward map's record; everything else -// keeps the pre-transition record. prior carries the first compensation -// attempt's outcome so neither attempt's failures are lost. -func (r *Reg) rollback(ctx context.Context, from, to registry.State, forwardProv registry.ProvenanceMap, prior *registry.RollbackOutcome) error { +// rollback state desync between actual state in system and state in history +func (r *Reg) rollback(ctx context.Context, from, to registry.State) error { r.log.Debug("attempting to rollback") - published := r.provenanceSnapshot() - outcome := ®istry.RollbackOutcome{} - ctx = registry.WithRollbackOutcome(ctx, outcome) - fromProv, provErr := provenanceForState(from, forwardProv, nil) - if provErr != nil { - return NewProvenanceInvariantError(provErr) - } - partial, err := r.transitionState(ctx, from, to, fromProv, published) + partial, err := r.transitionState(ctx, from, to) if err == nil { return nil // success } - err = errors.Join(err, outcome.Err()) - if prior != nil { - err = errors.Join(err, prior.Err()) - } r.state = partial // we remain in a desynced state r.rebuildIndex() r.rebuildDepIndex() - partialProv := published.Clone() - if partialProv == nil { - partialProv = make(registry.ProvenanceMap) - } - for _, op := range outcome.Surviving() { - id := canonicalEntryID(op.Entry.ID) - if record, ok := forwardProv[id]; ok { - partialProv[id] = record - } - } - // A runner that cannot report per-operation compensation still returns the - // actual partial state. Any surviving entry absent from the published map - // can only come from the forward state, whose record is already trusted. - for _, entry := range partial { - id := canonicalEntryID(entry.ID) - if _, ok := partialProv[id]; ok { - continue - } - if record, ok := forwardProv[id]; ok { - partialProv[id] = record - } - } - reconciledProv, provErr := provenanceForState(partial, partialProv, nil) - if provErr != nil { - err = errors.Join(err, NewProvenanceInvariantError(provErr)) - } else { - r.publishProvenance(reconciledProv) - } r.reconcileKnownOverlaysAfterFailedRollback() + r.publishSnapshot() return err } -func (r *Reg) transitionState(ctx context.Context, from, to registry.State, fromProv, toProv registry.ProvenanceMap) (registry.State, error) { +func (r *Reg) transitionState(ctx context.Context, from, to registry.State) (registry.State, error) { r.log.Debug("transitioning state") cs, terr := r.builder.BuildDelta(from, to) @@ -1197,7 +1120,6 @@ func (r *Reg) transitionState(ctx context.Context, from, to registry.State, from return from, nil } - annotateChangeSet(cs, fromProv, toProv) return r.runner.Transition(ctx, from, cs) } @@ -1244,9 +1166,6 @@ func (r *Reg) enrichChangeset(changes registry.ChangeSet) registry.ChangeSet { enriched := make(registry.ChangeSet, len(changes)) for i, op := range changes { enriched[i] = op - // DependencyRoot remains decodable for legacy replay but is not part of - // the current durable entry format; root state lives in provenance. - enriched[i].Entry.DependencyRoot = false switch op.Kind { case registry.EntryUpdate, registry.EntryDelete: if originalEntry, exists := stateMap[op.Entry.ID]; exists { @@ -1257,11 +1176,6 @@ func (r *Reg) enrichChangeset(changes registry.ChangeSet) registry.ChangeSet { zap.String("entry_id", op.Entry.ID.String())) } } - if enriched[i].OriginalEntry != nil { - originalEntry := *enriched[i].OriginalEntry - originalEntry.DependencyRoot = false - enriched[i].OriginalEntry = &originalEntry - } } return enriched diff --git a/system/registry/registry_hardening_test.go b/system/registry/registry_hardening_test.go index 1b2cb0344..3fa4d2bcc 100644 --- a/system/registry/registry_hardening_test.go +++ b/system/registry/registry_hardening_test.go @@ -116,25 +116,25 @@ func (e *hardeningEffect) Rollback(context.Context) error { } type hardeningDirective struct { - expand func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) - reconcile func(context.Context, regapi.ProvenancedState, regapi.ProvenancedState, *regapi.DependencyResolution) (regapi.DirectiveResult, error) + expand func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) + reconcile func(context.Context, regapi.State, regapi.State, *regapi.DependencyResolution) (regapi.DirectiveResult, error) } -func (d hardeningDirective) Expand(ctx context.Context, op regapi.Operation, state regapi.ProvenancedState) (regapi.DirectiveResult, error) { +func (d hardeningDirective) Expand(ctx context.Context, op regapi.Operation, state regapi.State) (regapi.DirectiveResult, error) { if d.expand == nil { return regapi.DirectiveResult{}, nil } return d.expand(ctx, op, state) } -func (d hardeningDirective) ReconcileResolution(ctx context.Context, state regapi.ProvenancedState, resolution *regapi.DependencyResolution) (regapi.DirectiveResult, error) { +func (d hardeningDirective) ReconcileResolution(ctx context.Context, state regapi.State, resolution *regapi.DependencyResolution) (regapi.DirectiveResult, error) { if d.reconcile == nil { return regapi.DirectiveResult{}, nil } return d.reconcile(ctx, state, state, resolution) } -func (d hardeningDirective) ReconcileResolutionTransition(ctx context.Context, current regapi.ProvenancedState, target regapi.ProvenancedState, resolution *regapi.DependencyResolution) (regapi.DirectiveResult, error) { +func (d hardeningDirective) ReconcileResolutionTransition(ctx context.Context, current regapi.State, target regapi.State, resolution *regapi.DependencyResolution) (regapi.DirectiveResult, error) { if d.reconcile == nil { return regapi.DirectiveResult{}, nil } @@ -163,14 +163,14 @@ func TestLoadStateDefaultsDependencyAccessToVerifiedOffline(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { seen := regapi.DependencyAccessUnspecified - directive := hardeningDirective{expand: func(ctx context.Context, _ regapi.Operation, _ regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(ctx context.Context, _ regapi.Operation, _ regapi.State) (regapi.DirectiveResult, error) { seen = regapi.DependencyAccessFromContext(ctx) return regapi.DirectiveResult{}, nil }} reg := NewRegistry(memory.New(), &hardeningRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) dep := regapi.Entry{ID: regapi.NewID("app.deps", "module"), Kind: regapi.NamespaceDependency} - require.NoError(t, reg.LoadState(test.ctx, hostProvenanced(regapi.State{dep}), version.New(0))) + require.NoError(t, reg.LoadState(test.ctx, regapi.State{dep}, version.New(0))) require.Equal(t, test.want, seen) }) } @@ -186,13 +186,13 @@ func TestApplyVersion_SetHeadFailureCompensatesRuntimeAndEffects(t *testing.T) { require.NoError(t, history.SetHead(v0)) effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} runner := &hardeningRunner{} reg := NewRegistry(history, runner, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) err := reg.ApplyVersion(ctx, v1) require.ErrorContains(t, err, "failed to set head version") @@ -237,13 +237,13 @@ func TestApplyVersion_LegacyResolutionCheckpointFailureCompensates(t *testing.T) require.NoError(t, history.SetHead(v0)) effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} runner := &hardeningRunner{} reg := NewRegistry(history, runner, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) err := reg.ApplyVersion(ctx, v1) require.ErrorContains(t, err, "injected checkpoint failure") @@ -264,12 +264,12 @@ func TestApplyVersion_LegacyResolutionIsCheckpointed(t *testing.T) { history := &hardeningHistory{Storage: memory.New()} require.NoError(t, history.Save(v1, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: dep}}, false)) require.NoError(t, history.SetHead(v0)) - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution()}, nil }} reg := NewRegistry(history, &hardeningRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) require.NoError(t, reg.ApplyVersion(ctx, v1)) stored, err := history.GetDependencyResolution(v1) require.NoError(t, err) @@ -286,13 +286,13 @@ func TestApplyVersionRejectsExactResolutionWithoutDurableHistorySupport(t *testi require.NoError(t, durable.SetHead(v0)) history := &historyWithoutResolution{History: durable} effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} runner := &hardeningRunner{} reg := NewRegistry(history, runner, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) err := reg.ApplyVersion(ctx, v1) require.ErrorContains(t, err, "history does not support durable dependency resolutions") @@ -316,13 +316,13 @@ func TestApplyVersion_LegacyUnrelatedTransitionResolvesFinalRoots(t *testing.T) require.NoError(t, history.Save(v1, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: unrelated}}, false)) require.NoError(t, history.SetHead(v0)) expansions := 0 - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { expansions++ return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution()}, nil }} reg := NewRegistry(history, &hardeningRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(regapi.State{dep}), v0)) + require.NoError(t, reg.LoadState(ctx, regapi.State{dep}, v0)) expansions = 0 require.NoError(t, reg.ApplyVersion(ctx, v1)) @@ -338,14 +338,14 @@ func TestLoadState_CheckpointFailureCompensatesRuntimeAndEffects(t *testing.T) { dep := regapi.Entry{ID: regapi.NewID("app.deps", "module"), Kind: regapi.NamespaceDependency} history := &hardeningHistory{Storage: memory.New(), failCheckpoint: true} effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} runner := &hardeningRunner{} reg := NewRegistry(history, runner, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - err := reg.LoadState(ctx, hostProvenanced(regapi.State{dep}), v0) + err := reg.LoadState(ctx, regapi.State{dep}, v0) require.ErrorContains(t, err, "injected checkpoint failure") entries, getErr := reg.GetAllEntries() require.NoError(t, getErr) @@ -361,14 +361,14 @@ func TestLoadStateRejectsExactResolutionWithoutDurableHistorySupport(t *testing. durable := memory.New() history := &historyWithoutResolution{History: durable} effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} runner := &hardeningRunner{} reg := NewRegistry(history, runner, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - err := reg.LoadState(ctx, hostProvenanced(regapi.State{dep}), version.New(0)) + err := reg.LoadState(ctx, regapi.State{dep}, version.New(0)) require.ErrorContains(t, err, "history does not support durable dependency resolutions") entries, getErr := reg.GetAllEntries() require.NoError(t, getErr) @@ -384,13 +384,13 @@ func TestLoadState_TransitionFailureDoesNotCheckpointLegacyResolution(t *testing dep := regapi.Entry{ID: regapi.NewID("app.deps", "module"), Kind: regapi.NamespaceDependency} history := &hardeningHistory{Storage: memory.New()} effect := &hardeningEffect{} - directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.ProvenancedState) (regapi.DirectiveResult, error) { + directive := hardeningDirective{expand: func(context.Context, regapi.Operation, regapi.State) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Resolution: hardeningResolution(), Effects: []regapi.Effect{effect}}, nil }} reg := NewRegistry(history, &hardeningRunner{failTransitionAt: 1}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, directive)) - err := reg.LoadState(ctx, hostProvenanced(regapi.State{dep}), v0) + err := reg.LoadState(ctx, regapi.State{dep}, v0) require.ErrorContains(t, err, "injected transition failure") _, resolutionErr := history.GetDependencyResolution(v0) require.ErrorIs(t, resolutionErr, regapi.ErrDependencyResolutionNotFound) @@ -406,15 +406,15 @@ func TestApplyVersion_ReconcilerErrorRollsBackPreviouslyPreparedEffects(t *testi history := memory.New() require.NoError(t, history.SaveWithDependencyResolution(v1, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: dep}}, hardeningResolution(), false)) effect := &hardeningEffect{} - first := hardeningDirective{reconcile: func(context.Context, regapi.ProvenancedState, regapi.ProvenancedState, *regapi.DependencyResolution) (regapi.DirectiveResult, error) { + first := hardeningDirective{reconcile: func(context.Context, regapi.State, regapi.State, *regapi.DependencyResolution) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{Applied: true, Effects: []regapi.Effect{effect}}, nil }} - second := hardeningDirective{reconcile: func(context.Context, regapi.ProvenancedState, regapi.ProvenancedState, *regapi.DependencyResolution) (regapi.DirectiveResult, error) { + second := hardeningDirective{reconcile: func(context.Context, regapi.State, regapi.State, *regapi.DependencyResolution) (regapi.DirectiveResult, error) { return regapi.DirectiveResult{}, errors.New("injected reconcile failure") }} reg := NewRegistry(history, &hardeningRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop(), WithKindDirective(regapi.NamespaceDependency, first), WithKindDirective(regapi.NamespaceDependency, second)) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(nil), v0)) + require.NoError(t, reg.LoadState(ctx, nil, v0)) err := reg.ApplyVersion(ctx, v1) require.ErrorContains(t, err, "injected reconcile failure") require.Equal(t, 1, effect.prepared) @@ -436,7 +436,7 @@ func TestLoadState_AllocatorUsesMaximumStoredVersionAfterRewind(t *testing.T) { require.NoError(t, reg.ApplyVersion(ctx, v1)) restored := NewRegistry(history, &hardeningRunner{}, topology.NewStateBuilder(zap.NewNop(), nil), nil, zap.NewNop()) - require.NoError(t, restored.LoadState(ctx, hostProvenanced(nil), v1)) + require.NoError(t, restored.LoadState(ctx, nil, v1)) v4, err := restored.Apply(ctx, regapi.ChangeSet{{Kind: regapi.EntryCreate, Entry: regapi.Entry{ID: regapi.NewID("app", "branch"), Kind: "service"}}}) require.NoError(t, err) require.Equal(t, uint(4), v4.ID()) diff --git a/system/registry/registry_test.go b/system/registry/registry_test.go index 1df0dc617..c03f5966d 100644 --- a/system/registry/registry_test.go +++ b/system/registry/registry_test.go @@ -33,44 +33,40 @@ import ( "github.com/wippyai/runtime/system/registry/topology" ) -// stripProvenance clones a changeset without its provenance annotations, for -// matchers that compare the declarative operations alone. -func stripProvenance(cs registry.ChangeSet) registry.ChangeSet { - out := make(registry.ChangeSet, len(cs)) - for i, op := range cs { - op.Provenance = nil - op.OriginalProvenance = nil - out[i] = op - } - return out -} - -func TestApplyPassesAuthorMetaThrough(t *testing.T) { - // A user update that omits keys present on the resident entry persists - // exactly what the author sent; the registry re-injects nothing. - id := registry.NewID("acme.app", "dep") +func TestNormalizeRegistryMetadata(t *testing.T) { + id := registry.NewID("app.deps", "core") snapshot := registry.State{{ ID: id, Kind: registry.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{ - "module": "acme/app", "title": "Old", - }), - Data: payload.New(map[string]any{"component": "acme/core", "version": "1.0.0"}), + Registry: registry.EntryMetadata{Owner: "acme/app", Root: true}, + Meta: attrs.NewBagFrom(map[string]any{"title": "Old"}), + Data: payload.New(map[string]any{"component": "acme/core", "version": "1.0.0"}), }} - _ = snapshot changes := registry.ChangeSet{{ Kind: registry.EntryUpdate, Entry: registry.Entry{ ID: id, Kind: registry.NamespaceDependency, - Meta: attrs.NewBagFrom(map[string]any{"title": "New"}), - Data: payload.New(map[string]any{"component": "acme/core", "version": "2.0.0"}), + Registry: registry.EntryMetadata{Owner: "untrusted"}, + Meta: attrs.NewBagFrom(map[string]any{"title": "New"}), + Data: payload.New(map[string]any{"component": "acme/core", "version": "2.0.0"}), + }, + }, { + Kind: registry.EntryCreate, + Entry: registry.Entry{ + ID: registry.NewID("app", "new"), + Kind: registry.EntryKind, + Registry: registry.EntryMetadata{Owner: "untrusted", Root: true}, }, }} - require.Len(t, changes, 1) - require.Equal(t, "New", changes[0].Entry.Meta.GetString("title", "")) - require.Equal(t, "", changes[0].Entry.Meta.GetString("module", ""), - "author payload carries exactly the keys the author sent") + + got := normalizeRegistryMetadata(changes, snapshot) + require.Len(t, got, 2) + require.Equal(t, "acme/app", got[0].Entry.Registry.Owner) + require.True(t, got[0].Entry.Registry.Root) + require.Equal(t, "New", got[0].Entry.Meta.GetString("title", "")) + require.Equal(t, registry.EntryMetadata{Root: true}, got[1].Entry.Registry) } +// MockRunner is a mock implementation of the registry.process interface for testing. type MockRunner struct { err error RunFunc func(state registry.State, changes registry.ChangeSet) (registry.State, error) @@ -397,7 +393,6 @@ func TestInMemoryRegistry_ApplyVersion(t *testing.T) { reg.state = registry.State{ {ID: registry.NewID("", "/foo"), Kind: "test", Data: payload.New("data2")}, } - reg.publishProvenance(hostProvenanced(reg.state).Provenance) // Mock the runner to return a new state - v1 state runner.newState = registry.State{ @@ -492,7 +487,6 @@ func TestInMemoryRegistry_Apply_HistorySaveError(t *testing.T) { { Kind: registry.EntryCreate, Entry: registry.Entry{ - ID: registry.NewID("", "/foo"), Kind: "test", Data: payload.New("data"), }, @@ -611,7 +605,6 @@ func TestInMemoryRegistry_Apply_Rollback_Success(t *testing.T) { {ID: registry.NewID("", "/initial"), Kind: "test", Data: payload.New("initial_data")}, } reg.state = initialState - reg.publishProvenance(hostProvenanced(initialState).Provenance) reg.currentVersion = v0 changes := registry.ChangeSet{ @@ -633,7 +626,7 @@ func TestInMemoryRegistry_Apply_Rollback_Success(t *testing.T) { // Mock the runner's Transition to apply the rollback changeset correctly runner.RunFunc = func(state registry.State, cs registry.ChangeSet) (registry.State, error) { // Handle initial application of changes - if reflect.DeepEqual(stripProvenance(cs), changes) { + if reflect.DeepEqual(cs, changes) { return newState, nil } @@ -688,7 +681,6 @@ func TestInMemoryRegistry_Apply_Rollback_Failure(t *testing.T) { {ID: registry.NewID("", "/initial"), Kind: "test", Data: payload.New("initial_data")}, } reg.state = initialState - reg.publishProvenance(hostProvenanced(initialState).Provenance) reg.currentVersion = v0 changes := registry.ChangeSet{ @@ -708,7 +700,7 @@ func TestInMemoryRegistry_Apply_Rollback_Failure(t *testing.T) { rollbackErr := errors.New("rollback failed") runner.RunFunc = func(state registry.State, cs registry.ChangeSet) (registry.State, error) { // Handle initial application of changes - if reflect.DeepEqual(stripProvenance(cs), changes) { + if reflect.DeepEqual(cs, changes) { return newState, nil } @@ -961,8 +953,7 @@ func TestInMemoryRegistry_Rollback(t *testing.T) { return toState, nil } - forwardProv := hostProvenanced(fromState).Provenance - err := reg.rollback(context.Background(), fromState, toState, forwardProv, nil) + err := reg.rollback(context.Background(), fromState, toState) if err != nil { t.Errorf("Unexpected error during rollback: %v", err) } @@ -972,7 +963,7 @@ func TestInMemoryRegistry_Rollback(t *testing.T) { return nil, errors.New("rollback failed") } - err = reg.rollback(context.Background(), fromState, toState, forwardProv, nil) + err = reg.rollback(context.Background(), fromState, toState) if err == nil { t.Fatal("Expected error during failed rollback") } @@ -1000,7 +991,7 @@ func TestInMemoryRegistry_TransitionState(t *testing.T) { return toState, nil } - newState, err := reg.transitionState(context.Background(), fromState, toState, nil, nil) + newState, err := reg.transitionState(context.Background(), fromState, toState) if err != nil { t.Errorf("Unexpected error during transition: %v", err) } @@ -1013,7 +1004,7 @@ func TestInMemoryRegistry_TransitionState(t *testing.T) { return fromState, nil } - newState, err = reg.transitionState(context.Background(), fromState, fromState, nil, nil) + newState, err = reg.transitionState(context.Background(), fromState, fromState) if err != nil { t.Errorf("Unexpected error during transition with no changes: %v", err) } @@ -1026,7 +1017,7 @@ func TestInMemoryRegistry_TransitionState(t *testing.T) { return nil, errors.New("transition failed") } - _, err = reg.transitionState(context.Background(), fromState, toState, nil, nil) + _, err = reg.transitionState(context.Background(), fromState, toState) if err == nil { t.Fatal("Expected error during failed transition") } @@ -1258,7 +1249,6 @@ func TestInMemoryRegistry_RollbackPartialState(t *testing.T) { {ID: registry.NewID("", "/initial"), Kind: "test", Data: payload.New("initial_data")}, } reg.state = initialState - reg.publishProvenance(hostProvenanced(initialState).Provenance) reg.currentVersion = v0 changes := registry.ChangeSet{ @@ -1274,7 +1264,10 @@ func TestInMemoryRegistry_RollbackPartialState(t *testing.T) { newState := initialState newState = append(newState, changes[0].Entry) - partialRollbackState := newState + partialRollbackState := registry.State{ + {ID: registry.NewID("", "/initial"), Kind: "test", Data: payload.New("initial_data")}, + {ID: registry.NewID("", "/partial"), Kind: "test", Data: payload.New("partial_data")}, + } callCount := 0 runner.RunFunc = func(state registry.State, _ registry.ChangeSet) (registry.State, error) { @@ -1308,7 +1301,7 @@ func TestInMemoryRegistry_RollbackPartialState(t *testing.T) { t.Errorf("Expected state to have 2 entries (partial rollback), got %d", len(reg.state)) } - if reg.state[1].ID.Name != "/new" { + if reg.state[1].ID.Name != "/partial" { t.Errorf("Expected partial state to be preserved, got: %v", reg.state) } } @@ -1532,6 +1525,87 @@ func TestEnrichChangeset(t *testing.T) { }) } +func TestCollectBackwardChangesets(t *testing.T) { + t.Run("No common ancestor returns error", func(t *testing.T) { + v0 := version.New(registry.RootVersion) + v1 := version.FromParent(v0, 1) + v2 := version.FromParent(v1, 2) + v3 := version.New(3) // Disconnected version + + hist := historymem.New() + _ = hist.Save(v0, registry.ChangeSet{}, true) + _ = hist.Save(v1, registry.ChangeSet{}, false) + _ = hist.Save(v2, registry.ChangeSet{}, false) + _ = hist.Save(v3, registry.ChangeSet{}, false) + + runner := NewMockRunner() + stateBuilder := topology.NewStateBuilder(zap.NewNop(), nil) + reg := NewRegistry(hist, runner, stateBuilder, topology.NewResolver(), zap.NewNop()) + reg.currentVersion = v2 + + // Path that doesn't include common ancestor + path := []registry.Version{v3} + _, err := reg.collectBackwardChangesets(path, v3) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoCommonAncestor) + }) + + t.Run("History Get error is propagated", func(t *testing.T) { + v0 := version.New(registry.RootVersion) + v1 := version.FromParent(v0, 1) + v2 := version.FromParent(v1, 2) + + hist := historymem.New() + _ = hist.Save(v0, registry.ChangeSet{}, true) + _ = hist.Save(v1, registry.ChangeSet{}, false) + // Don't save v2 - Get will fail for it + + runner := NewMockRunner() + stateBuilder := topology.NewStateBuilder(zap.NewNop(), nil) + reg := NewRegistry(hist, runner, stateBuilder, topology.NewResolver(), zap.NewNop()) + reg.currentVersion = v2 // current at v2, but v2 not in history + + path := []registry.Version{v0, v1} + _, err := reg.collectBackwardChangesets(path, v1) + + require.Error(t, err) + assert.Contains(t, err.Error(), "get changeset") + }) + + t.Run("Successful backward changeset collection", func(t *testing.T) { + v0 := version.New(registry.RootVersion) + v1 := version.FromParent(v0, 1) + v2 := version.FromParent(v1, 2) + + entry := registry.Entry{ + ID: registry.NewID("ns", "test"), + Kind: "test", + Data: payload.NewString("data"), + } + + hist := historymem.New() + _ = hist.Save(v0, registry.ChangeSet{}, true) + _ = hist.Save(v1, registry.ChangeSet{ + {Kind: registry.EntryCreate, Entry: entry}, + }, false) + _ = hist.Save(v2, registry.ChangeSet{ + {Kind: registry.EntryUpdate, Entry: entry, OriginalEntry: &entry}, + }, false) + + runner := NewMockRunner() + stateBuilder := topology.NewStateBuilder(zap.NewNop(), nil) + reg := NewRegistry(hist, runner, stateBuilder, topology.NewResolver(), zap.NewNop()) + reg.currentVersion = v2 + + path := []registry.Version{v0, v1} + cs, err := reg.collectBackwardChangesets(path, v1) + + assert.NoError(t, err) + assert.NotNil(t, cs) + }) +} + // depRecordingRunner is a Transition runner that refuses to delete an // entry from the incoming state while any other not-being-deleted entry in // the same ChangeSet still holds a dependency on it. It mirrors the @@ -1671,7 +1745,6 @@ func TestApplyVersion_Rollback_RespectsDependencyOrder(t *testing.T) { {ID: libID, Kind: "library", Data: payload.New("lib")}, {ID: testID, Kind: "test", Data: payload.New("test")}, } - reg.publishProvenance(hostProvenanced(reg.state).Provenance) err := reg.ApplyVersion(context.Background(), v0) require.NoError(t, err, diff --git a/system/registry/resolution_version_test.go b/system/registry/resolution_version_test.go deleted file mode 100644 index e13af1e00..000000000 --- a/system/registry/resolution_version_test.go +++ /dev/null @@ -1,252 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 - -package registry - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/wippyai/runtime/api/registry" - "github.com/wippyai/runtime/internal/version" - historymem "github.com/wippyai/runtime/system/registry/history/memory" - "github.com/wippyai/runtime/system/registry/topology" - "go.uber.org/zap" -) - -// resolutionStubDirective answers every expansion with a fixed dependency -// resolution and no additional operations. -type resolutionStubDirective struct { - resolution *registry.DependencyResolution -} - -func (d resolutionStubDirective) Expand(_ context.Context, op registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { - return registry.DirectiveResult{ - Applied: true, - Resolution: d.resolution, - Additional: []registry.ScopedOperation{{ - Operation: op, - Scope: registry.ScopeHistory, - }}, - }, nil -} - -func newResolutionTestRegistry(t *testing.T, res *registry.DependencyResolution) *Reg { - t.Helper() - hist := historymem.New() - require.NoError(t, hist.Save(version.New(registry.RootVersion), registry.ChangeSet{}, true)) - runner := NewMockRunner() - runner.RunFunc = func(state registry.State, cs registry.ChangeSet) (registry.State, error) { - m := map[registry.ID]registry.Entry{} - for _, e := range state { - m[e.ID] = e - } - for _, op := range cs { - switch op.Kind { - case registry.EntryCreate, registry.EntryUpdate: - m[op.Entry.ID] = op.Entry - case registry.EntryDelete: - delete(m, op.Entry.ID) - } - } - out := registry.State{} - for _, e := range m { - out = append(out, e) - } - return out, nil - } - builder := topology.NewStateBuilder(zap.NewNop(), nil) - return NewRegistry(hist, runner, builder, topology.NewResolver(), zap.NewNop(), - WithKindDirective("dep.kind", resolutionStubDirective{resolution: res})) -} - -// TestApplyRepeatedResolutionAllocatesNoExtraVersion pins the versioning rule: -// a plan returning the resolution already in effect is not a change, so a -// second identical apply advances only by its own history operations and an -// unchanged canonical digest never forces a version by itself. -func TestApplyRepeatedResolutionAllocatesNoExtraVersion(t *testing.T) { - res := (®istry.DependencyResolution{ - InputDigest: "sha256:input", - Roots: []registry.DependencyRoot{{ID: "app:dep", Component: "acme/mod", Version: "1.0.0"}}, - Modules: []registry.ResolvedModule{{Name: "acme/mod", Version: "1.0.0", Digest: "sha256:a"}}, - }).Canonical() - reg := newResolutionTestRegistry(t, res) - - depID := registry.NewID("app", "dep") - v1, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v1")}, - }}) - require.NoError(t, err) - require.NotNil(t, v1) - - require.NotNil(t, reg.currentResolution) - assert.Equal(t, res.Digest, reg.currentResolution.Digest) - - // The same declarative content again: the directive re-returns the same - // canonical resolution. resolutionChanged must be false — the version - // advances only because the update itself is a history operation. - v2, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v2")}, - }}) - require.NoError(t, err) - assert.Equal(t, v1.ID()+1, v2.ID()) - assert.Equal(t, res.Digest, reg.currentResolution.Digest) -} - -// TestResolutionChangeIsDigestInequality pins that a NEW canonical resolution -// digest marks the transition as resolution-changed and lands in -// currentResolution. -func TestResolutionChangeIsDigestInequality(t *testing.T) { - first := (®istry.DependencyResolution{ - InputDigest: "sha256:one", - Roots: []registry.DependencyRoot{{ID: "app:dep", Component: "acme/mod", Version: "1.0.0"}}, - Modules: []registry.ResolvedModule{{Name: "acme/mod", Version: "1.0.0", Digest: "sha256:a"}}, - }).Canonical() - second := (®istry.DependencyResolution{ - InputDigest: "sha256:two", - Roots: []registry.DependencyRoot{{ID: "app:dep", Component: "acme/mod", Version: "2.0.0"}}, - Modules: []registry.ResolvedModule{{Name: "acme/mod", Version: "2.0.0", Digest: "sha256:b"}}, - }).Canonical() - require.NotEqual(t, first.Digest, second.Digest) - - reg := newResolutionTestRegistry(t, first) - depID := registry.NewID("app", "dep") - _, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v1")}, - }}) - require.NoError(t, err) - require.Equal(t, first.Digest, reg.currentResolution.Digest) - - reg.directivesByKind["dep.kind"] = []registry.Directive{resolutionStubDirective{resolution: second}} - _, err = reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v2")}, - }}) - require.NoError(t, err) - assert.Equal(t, second.Digest, reg.currentResolution.Digest) -} - -// resolutionOnlyDirective returns a changed resolution and resident-record -// updates with no module operations — the shape of a module version bump -// whose entries are byte-identical. -type resolutionOnlyDirective struct { - resolution *registry.DependencyResolution - resident registry.ProvenanceMap -} - -func (d resolutionOnlyDirective) Expand(_ context.Context, op registry.Operation, _ registry.ProvenancedState) (registry.DirectiveResult, error) { - return registry.DirectiveResult{ - Applied: true, - Resolution: d.resolution, - Provenance: d.resident, - Additional: []registry.ScopedOperation{{Operation: op, Scope: registry.ScopeHistory}}, - }, nil -} - -// TestIdenticalContentVersionBumpTouchesNoResidentEntry pins the incident this -// change exists for: a module version bump whose entries are byte-identical -// updates the dependency declaration and the resolution, while the module's -// resident entries receive no operation — no manager hears anything about -// them and no instance is recreated. -func TestIdenticalContentVersionBumpTouchesNoResidentEntry(t *testing.T) { - first := (®istry.DependencyResolution{ - InputDigest: "sha256:one", - Roots: []registry.DependencyRoot{{ID: "app:dep", Component: "acme/mod", Version: "1.0.0"}}, - Modules: []registry.ResolvedModule{{Name: "acme/mod", Version: "1.0.0", Digest: "sha256:a"}}, - }).Canonical() - second := (®istry.DependencyResolution{ - InputDigest: "sha256:two", - Roots: []registry.DependencyRoot{{ID: "app:dep", Component: "acme/mod", Version: "1.0.1"}}, - Modules: []registry.ResolvedModule{{Name: "acme/mod", Version: "1.0.1", Digest: "sha256:b"}}, - }).Canonical() - - reg := newResolutionTestRegistry(t, first) - depID := registry.NewID("app", "dep") - storeID := registry.NewID("acme.mod", "cache") - - // Install: the declaration plus one module-owned resident entry. - _, err := reg.Apply(t.Context(), registry.ChangeSet{ - { - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v: 1.0.0")}, - }, - { - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: storeID, Kind: "store.kind", Data: payloadNew("cfg")}, - Provenance: ®istry.EntryProvenance{Module: "acme/mod", Version: "1.0.0", Digest: "sha256:a"}, - }, - }) - require.NoError(t, err) - v1, err := reg.Current() - require.NoError(t, err) - - storeProvBefore, ok := reg.EntryProvenance(storeID) - require.True(t, ok) - - // The bump: the declaration changes, the module's entries are identical, - // so the directive emits the declaration update, the new resolution, and - // the resident-record advance — no operation for the store entry. - bumped := registry.EntryProvenance{Module: "acme/mod", Version: "1.0.1", Digest: "sha256:b"} - reg.directivesByKind["dep.kind"] = []registry.Directive{resolutionOnlyDirective{ - resolution: second, - resident: registry.ProvenanceMap{storeID: bumped}, - }} - runner := reg.runner.(*MockRunner) - var dispatched registry.ChangeSet - prevRun := runner.RunFunc - runner.RunFunc = func(state registry.State, cs registry.ChangeSet) (registry.State, error) { - dispatched = append(dispatched, cs...) - return prevRun(state, cs) - } - - v2, err := reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: depID, Kind: "dep.kind", Data: payloadNew("v: 1.0.1")}, - }}) - require.NoError(t, err) - - assert.Equal(t, v1.ID()+1, v2.ID()) - assert.Equal(t, second.Digest, reg.currentResolution.Digest) - for _, op := range dispatched { - assert.NotEqual(t, storeID, op.Entry.ID.Canonical(), - "the module's resident entry must receive no operation for identical content") - } - storeProvAfter, ok := reg.EntryProvenance(storeID) - require.True(t, ok) - assert.NotEqual(t, storeProvBefore, storeProvAfter, "the resident record advances with the bump") - assert.Equal(t, bumped, storeProvAfter) -} - -// TestApplyFromRejectsStaleBase pins the optimistic boundary SetDependencyRoot -// relies on: an apply conditioned on a version that is no longer current fails -// with the concurrent-apply conflict before planning anything. -func TestApplyFromRejectsStaleBase(t *testing.T) { - reg, runner := newTestRegistry(t) - runner.RunFunc = func(_ registry.State, cs registry.ChangeSet) (registry.State, error) { - out := registry.State{} - for _, op := range cs { - out = append(out, op.Entry) - } - return out, nil - } - id := registry.NewID("ns", "svc") - - base, err := reg.Current() - require.NoError(t, err) - - _, err = reg.Apply(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryCreate, - Entry: registry.Entry{ID: id, Kind: "test", Data: payloadNew("v1")}, - }}) - require.NoError(t, err) - - _, err = reg.applyFrom(t.Context(), registry.ChangeSet{{ - Kind: registry.EntryUpdate, - Entry: registry.Entry{ID: id, Kind: "test", Data: payloadNew("stale")}, - }}, base) - require.ErrorIs(t, err, ErrConcurrentApply) -} diff --git a/system/registry/rollback_v0_test.go b/system/registry/rollback_v0_test.go index d2c7406da..97b716bd7 100644 --- a/system/registry/rollback_v0_test.go +++ b/system/registry/rollback_v0_test.go @@ -55,7 +55,7 @@ func TestRollbackToV0(t *testing.T) { }, } - err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) entryID := registry.NewID("test", "entry1") diff --git a/system/registry/runner/bus_runner.go b/system/registry/runner/bus_runner.go index 3a004fc0f..89577794a 100644 --- a/system/registry/runner/bus_runner.go +++ b/system/registry/runner/bus_runner.go @@ -107,6 +107,7 @@ func (br *BusRunner) Transition( cs registry.ChangeSet, ) (registry.State, error) { currentState := newStateMap(initialState) + originalState := newStateMap(initialState) // Keep a copy of the original state for rollbacks txPath := br.nextTransactionPath() txParticipants, err := br.registryTransactionParticipants() @@ -119,7 +120,6 @@ func (br *BusRunner) Transition( remaining := append(registry.ChangeSet(nil), cs...) pass := 0 - var journal []acceptedOperation for len(remaining) > 0 { pass++ @@ -127,23 +127,18 @@ func (br *BusRunner) Transition( deferred registry.ChangeSet lastDeferErr error fatalErr error + fatalState registry.StateMap progressed bool retriedCount int deferredFirstP = pass == 1 ) for _, op := range remaining { - var pre *registry.Entry - if existing, ok := currentState[op.Entry.ID]; ok { - cp := existing - pre = &cp - } newState, opErr := br.applyOperation(ctx, currentState, op) if opErr == nil { - journal = append(journal, acceptedOperation{op: op, pre: pre}) currentState = newState if ctxErr := ctx.Err(); ctxErr != nil { - rolled := br.cancelTransition(ctx, txParticipants, txPath, journal, currentState, ctxErr) + rolled := br.cancelTransition(ctx, txParticipants, txPath, originalState, currentState, ctxErr) return stateMapToSlice(rolled), ctxErr } progressed = true @@ -157,7 +152,7 @@ func (br *BusRunner) Transition( continue } if ctx.Err() != nil { - rolled := br.cancelTransition(ctx, txParticipants, txPath, journal, currentState, opErr) + rolled := br.cancelTransition(ctx, txParticipants, txPath, originalState, currentState, opErr) return stateMapToSlice(rolled), opErr } if isDeferrable(opErr) { @@ -171,12 +166,13 @@ func (br *BusRunner) Transition( continue } fatalErr = opErr + fatalState = newState break } if fatalErr != nil { br.log.Error("operation failed, initiating rollback", zap.Error(fatalErr)) - rolled := br.rollback(ctx, journal, currentState) + rolled := br.rollback(ctx, originalState, fatalState) if discardErr := br.dispatchTransaction(ctx, txParticipants, registry.TxDiscard, txPath, fatalErr); discardErr != nil { br.log.Error("failed to discard transaction", zap.Error(discardErr)) } @@ -202,7 +198,7 @@ func (br *BusRunner) Transition( zap.Int("passes", pass), zap.Strings("unresolved", idStrings(unresolved)), zap.Error(finalErr)) - rolled := br.rollback(ctx, journal, currentState) + rolled := br.rollback(ctx, originalState, currentState) if discardErr := br.dispatchTransaction(ctx, txParticipants, registry.TxDiscard, txPath, finalErr); discardErr != nil { br.log.Error("failed to discard transaction", zap.Error(discardErr)) } @@ -213,16 +209,16 @@ func (br *BusRunner) Transition( } if ctxErr := ctx.Err(); ctxErr != nil { - rolled := br.cancelTransition(ctx, txParticipants, txPath, journal, currentState, ctxErr) + rolled := br.cancelTransition(ctx, txParticipants, txPath, originalState, currentState, ctxErr) return stateMapToSlice(rolled), ctxErr } if err := br.dispatchTransaction(ctx, txParticipants, registry.TxCommit, txPath, nil); err != nil { br.log.Error("transaction commit failed, initiating rollback", zap.Error(err)) if ctx.Err() != nil { - newState := br.cancelTransition(ctx, txParticipants, txPath, journal, currentState, err) + newState := br.cancelTransition(ctx, txParticipants, txPath, originalState, currentState, err) return stateMapToSlice(newState), err } - newState := br.rollback(ctx, journal, currentState) + newState := br.rollback(ctx, originalState, currentState) if discardErr := br.dispatchTransaction(ctx, txParticipants, registry.TxDiscard, txPath, err); discardErr != nil { br.log.Error("failed to discard transaction after commit failure", zap.Error(discardErr)) } @@ -236,14 +232,13 @@ func (br *BusRunner) cancelTransition( ctx context.Context, participants []string, txPath event.Path, - journal []acceptedOperation, - currentState registry.StateMap, + originalState, currentState registry.StateMap, cause error, ) registry.StateMap { cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), br.waitTimeout) defer cancel() - rolled := br.rollback(cleanupCtx, journal, currentState) + rolled := br.rollback(cleanupCtx, originalState, currentState) if err := br.dispatchTransaction(cleanupCtx, participants, registry.TxDiscard, txPath, cause); err != nil { br.log.Error("failed to discard canceled transaction", zap.Error(err)) } @@ -441,15 +436,6 @@ func (br *BusRunner) applyOperation( return newState, nil } - // The operation's provenance pair rides the event envelope; the listener - // adapters re-inject it into the context they hand each listener, so a - // listener resolving during Add/Update/Delete sees the identity of the - // entry it is handling without reading any global index. - opProv := registry.OpProvenance{ - Effective: op.Provenance, - Original: op.OriginalProvenance, - } - waiter, err := br.prepareWaiter(ctx, registry.EntryResult, op.Entry.ID.String()) if err != nil { return state, err @@ -462,7 +448,6 @@ func (br *BusRunner) applyOperation( Kind: op.Kind, Path: op.Entry.ID.String(), Data: op.Entry, - Aux: opProv, }) result := waiter.Wait() @@ -499,91 +484,43 @@ func (br *BusRunner) applyOperation( func (br *BusRunner) rollback( ctx context.Context, - journal []acceptedOperation, - currentState registry.StateMap, + originalState, currentState registry.StateMap, ) registry.StateMap { - br.log.Debug("starting rollback", zap.Int("accepted_ops", len(journal))) + br.log.Debug("starting rollback") - outcome := registry.RollbackOutcomeFromContext(ctx) + // Convert states to registry.State format for BuildDelta + fromState := stateMapToSlice(currentState) + toState := stateMapToSlice(originalState) - // Accepted operations are compensated in reverse acceptance order, each by - // its kind-specific inverse. The inverse carries the operation's own - // provenance pair swapped into the compensating direction, so listeners - // resolving during compensation see the identity of what is being restored. - for i := len(journal) - 1; i >= 0; i-- { - inv, ok := inverseOperation(journal[i]) - if !ok { - err := NewNoInverseError(journal[i].op.Kind, journal[i].op.Entry.ID) - br.log.Error("no inverse for accepted operation", zap.Error(err)) - if outcome != nil { - outcome.Record(journal[i].op, false, err) - } - continue - } + // Use BuildDelta to generate ordered operations + delta, err := br.builder.BuildDelta(fromState, toState) + if err != nil { + br.log.Error("failed to build rollback delta", zap.Error(err)) + return currentState + } - newState, err := br.applyOperation(ctx, currentState, inv) + br.log.Debug("rollback delta calculated", zap.Any("delta", delta)) + + // Apply rollback operations + for _, op := range delta { + br.log.Debug("applying rollback operation", + zap.String("kind", op.Kind), + zap.String("id", op.Entry.ID.String()), + zap.Any("meta", op.Entry.Meta)) + + newState, err := br.applyOperation(ctx, currentState, op) if err != nil { br.log.Error("failed to apply rollback operation", - zap.Any("operation", inv), + zap.Any("operation", op), zap.Error(err)) - if outcome != nil { - outcome.Record(journal[i].op, false, err) - } // Continue trying other operations instead of returning continue } - if outcome != nil { - outcome.Record(journal[i].op, true, nil) - } currentState = newState } return currentState } -// acceptedOperation records one operation a transition applied, with the entry -// it replaced, so the transition can be compensated exactly. -type acceptedOperation struct { - pre *registry.Entry - op registry.Operation -} - -// inverseOperation builds the compensating operation for one accepted -// operation. -func inverseOperation(a acceptedOperation) (registry.Operation, bool) { - op := a.op - switch op.Kind { - case registry.EntryCreate: - return registry.Operation{ - Kind: registry.EntryDelete, - Entry: op.Entry, - Provenance: op.Provenance, - }, true - case registry.EntryUpdate: - if a.pre == nil { - return registry.Operation{}, false - } - applied := op.Entry - return registry.Operation{ - Kind: registry.EntryUpdate, - Entry: *a.pre, - OriginalEntry: &applied, - Provenance: op.OriginalProvenance, - OriginalProvenance: op.Provenance, - }, true - case registry.EntryDelete: - if a.pre == nil { - return registry.Operation{}, false - } - return registry.Operation{ - Kind: registry.EntryCreate, - Entry: *a.pre, - Provenance: op.OriginalProvenance, - }, true - default: - return registry.Operation{}, false - } -} - // newStateMap creates a StateMap from a State slice func newStateMap(state registry.State) registry.StateMap { m := make(registry.StateMap) diff --git a/system/registry/runner/errors.go b/system/registry/runner/errors.go index 520804226..9ff581e72 100644 --- a/system/registry/runner/errors.go +++ b/system/registry/runner/errors.go @@ -131,11 +131,3 @@ func NewUnresolvedDependenciesError(unresolved []registry.ID, cause error) apier } return e } - -// NewNoInverseError reports an accepted operation the rollback cannot -// compensate. -func NewNoInverseError(kind string, id registry.ID) apierror.Error { - return apierror.New(apierror.Internal, "no inverse for accepted operation"). - WithRetryable(apierror.False). - WithDetails(attrs.NewBagFrom(map[string]any{"kind": kind, "id": id.String()})) -} diff --git a/system/registry/topology/state_builder.go b/system/registry/topology/state_builder.go index 9b4f3206f..8765ad98d 100644 --- a/system/registry/topology/state_builder.go +++ b/system/registry/topology/state_builder.go @@ -107,9 +107,7 @@ func (b *StateBuilder) ApplyOperation(state StateMap, op registry.Operation) (St func (b *StateBuilder) GetInverseOperation(op registry.Operation) (registry.Operation, error) { switch op.Kind { case registry.EntryCreate: - // The created provenance stays the effective provenance of the entry - // being deleted by the inverse. - return registry.Operation{Kind: registry.EntryDelete, Entry: op.Entry, Provenance: op.Provenance}, nil + return registry.Operation{Kind: registry.EntryDelete, Entry: op.Entry}, nil case registry.EntryUpdate: if op.OriginalEntry == nil { @@ -118,14 +116,7 @@ func (b *StateBuilder) GetInverseOperation(op registry.Operation) (registry.Oper zap.String("name", op.Entry.ID.Name)) return registry.Operation{}, NewOriginalEntryNotFoundError(op.Entry.ID.NS, op.Entry.ID.Name) } - applied := op.Entry - return registry.Operation{ - Kind: registry.EntryUpdate, - Entry: *op.OriginalEntry, - OriginalEntry: &applied, - Provenance: op.OriginalProvenance, - OriginalProvenance: op.Provenance, - }, nil + return registry.Operation{Kind: registry.EntryUpdate, Entry: *op.OriginalEntry}, nil case registry.EntryDelete: if op.OriginalEntry == nil { @@ -134,7 +125,7 @@ func (b *StateBuilder) GetInverseOperation(op registry.Operation) (registry.Oper zap.String("name", op.Entry.ID.Name)) return registry.Operation{}, NewOriginalEntryNotFoundError(op.Entry.ID.NS, op.Entry.ID.Name) } - return registry.Operation{Kind: registry.EntryCreate, Entry: *op.OriginalEntry, Provenance: op.OriginalProvenance}, nil + return registry.Operation{Kind: registry.EntryCreate, Entry: *op.OriginalEntry}, nil default: return registry.Operation{}, NewUnknownOperationKindError(op.Kind) diff --git a/system/registry/version_transition_test.go b/system/registry/version_transition_test.go index 976d008d7..d205ce8dd 100644 --- a/system/registry/version_transition_test.go +++ b/system/registry/version_transition_test.go @@ -70,9 +70,9 @@ func (m *TestRunner) TransitionCount() int { return len(m.transitions) } -type directiveFunc func(context.Context, registry.Operation, registry.ProvenancedState) (registry.DirectiveResult, error) +type directiveFunc func(context.Context, registry.Operation, registry.State) (registry.DirectiveResult, error) -func (f directiveFunc) Expand(ctx context.Context, op registry.Operation, snap registry.ProvenancedState) (registry.DirectiveResult, error) { +func (f directiveFunc) Expand(ctx context.Context, op registry.Operation, snap registry.State) (registry.DirectiveResult, error) { return f(ctx, op, snap) } @@ -80,11 +80,11 @@ type baselineOverlayDirective struct { policyID registry.ID } -func (d baselineOverlayDirective) Expand(_ context.Context, op registry.Operation, snapshot registry.ProvenancedState) (registry.DirectiveResult, error) { +func (d baselineOverlayDirective) Expand(_ context.Context, op registry.Operation, snapshot registry.State) (registry.DirectiveResult, error) { resolution := (®istry.DependencyResolution{InputDigest: "sha256:test"}).Canonical() result := registry.DirectiveResult{Applied: true, Resolution: resolution} if op.Kind != registry.EntryDelete { - for _, entry := range snapshot.Entries { + for _, entry := range snapshot { if entry.ID == d.policyID { result.Additional = append(result.Additional, registry.ScopedOperation{ Operation: registry.Operation{Kind: registry.EntryDelete, Entry: entry}, @@ -99,16 +99,16 @@ func (d baselineOverlayDirective) Expand(_ context.Context, op registry.Operatio func (d baselineOverlayDirective) ReconcileResolutionTransition( _ context.Context, - _ registry.ProvenancedState, - target registry.ProvenancedState, + _ registry.State, + target registry.State, resolution *registry.DependencyResolution, ) (registry.DirectiveResult, error) { result := registry.DirectiveResult{Applied: true, Resolution: resolution} - for _, entry := range target.Entries { + for _, entry := range target { if entry.Kind != registry.NamespaceDependency { continue } - for _, candidate := range target.Entries { + for _, candidate := range target { if candidate.ID == d.policyID { result.Additional = append(result.Additional, registry.ScopedOperation{ Operation: registry.Operation{Kind: registry.EntryDelete, Entry: candidate}, @@ -137,7 +137,7 @@ func TestApplyVersion_ComposesTargetHistoryOverImmutableBaseline(t *testing.T) { ) baseline := registry.State{{ID: policyID, Kind: "security.policy", Data: payload.New(true)}} v0 := version.FromParent(nil, registry.RootVersion) - require.NoError(t, reg.LoadState(ctx, hostProvenanced(baseline), v0)) + require.NoError(t, reg.LoadState(ctx, baseline, v0)) overlay := registry.Entry{ ID: registry.NewID("workspace.packages", "application"), Kind: registry.NamespaceDependency, @@ -181,7 +181,7 @@ func TestApplyVersion_ForwardWithSquashing(t *testing.T) { } // Load baseline at v0 - err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) // Create test entry that will be modified multiple times @@ -277,7 +277,7 @@ func TestApplyVersion_BackwardWithSquashing(t *testing.T) { } // Load baseline at v0 - err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) // Create entries across versions @@ -376,7 +376,7 @@ func TestApplyVersion_BackwardDeletesDependentsBeforeDependencies(t *testing.T) }, } v0 := version.FromParent(nil, 0) - err := reg.LoadState(ctx, hostProvenanced(baseline), v0) + err := reg.LoadState(ctx, baseline, v0) require.NoError(t, err) repoID := registry.NewID("app", "repo") @@ -438,7 +438,7 @@ func TestApplyVersion_CrossBranchWithSquashing(t *testing.T) { } // Load baseline at v0 - err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) entryID := registry.NewID("test", "shared") @@ -531,7 +531,7 @@ func TestApplyVersion_PreservesBaseline(t *testing.T) { } // Load baseline at v0 - err := reg.LoadState(ctx, hostProvenanced(baseline), version.FromParent(nil, 0)) + err := reg.LoadState(ctx, baseline, version.FromParent(nil, 0)) require.NoError(t, err) // v1: Add one new entry @@ -595,7 +595,7 @@ func TestApplyVersion_RunsDirectives(t *testing.T) { depID := registry.NewID("app", "dep") modID := registry.NewID("mod", "svc") - expander := directiveFunc(func(_ context.Context, op registry.Operation, state registry.ProvenancedState) (registry.DirectiveResult, error) { + expander := directiveFunc(func(_ context.Context, op registry.Operation, _ registry.State) (registry.DirectiveResult, error) { if op.Entry.Kind != registry.NamespaceDependency { return registry.DirectiveResult{}, nil } @@ -607,12 +607,9 @@ func TestApplyVersion_RunsDirectives(t *testing.T) { } } } - kind := registry.EntryCreate - for _, entry := range state.Entries { - if entry.ID.Equal(modID) { - kind = registry.EntryUpdate - break - } + kind := registry.EntryUpdate + if op.Kind == registry.EntryCreate { + kind = registry.EntryCreate } modEntry := registry.Entry{ ID: modID, diff --git a/tests/app/src/_index.yaml b/tests/app/src/_index.yaml index 2f41f6370..af38d4ba1 100644 --- a/tests/app/src/_index.yaml +++ b/tests/app/src/_index.yaml @@ -88,11 +88,6 @@ entries: name: test short: Run application tests use_case: test - security: - actor: - id: app:test_runner - policies: - - app.test.security:allow_all source: file://runner.lua method: main modules: diff --git a/tests/app/src/runner.lua b/tests/app/src/runner.lua index c93d87863..29cb6aba5 100644 --- a/tests/app/src/runner.lua +++ b/tests/app/src/runner.lua @@ -265,8 +265,8 @@ local function run_tests() end if not entries or #entries == 0 then - io.print(red(" No tests found")) - return 1 + io.print(yellow(" No tests found")) + return 0 end -- Filter tests if patterns provided diff --git a/tests/app/src/test/registry/_index.yaml b/tests/app/src/test/registry/_index.yaml index 3a89b8f28..949df2e8a 100644 --- a/tests/app/src/test/registry/_index.yaml +++ b/tests/app/src/test/registry/_index.yaml @@ -109,19 +109,6 @@ entries: modules: - registry - - name: overlay_state - kind: function.lua - meta: - type: test - suite: registry - description: overlay snapshots fail closed when provenance is unavailable - source: file://overlay_state.lua - method: main - imports: - assert2: app.lib:assert - modules: - - registry - - name: version_navigation kind: function.lua meta: @@ -178,14 +165,14 @@ entries: - registry - funcs - - name: dependency_root_field + - name: registry_metadata_state kind: function.lua meta: type: test suite: registry order: 82 - description: root survives a Lua write and a read-modify-write - source: file://dependency_root_field.lua + description: ordinary entry writes cannot alter registry metadata + source: file://registry_metadata_state.lua method: main imports: assert2: app.lib:assert diff --git a/tests/app/src/test/registry/dependency_install.lua b/tests/app/src/test/registry/dependency_install.lua index 32b93813a..18f2500e7 100644 --- a/tests/app/src/test/registry/dependency_install.lua +++ b/tests/app/src/test/registry/dependency_install.lua @@ -20,20 +20,38 @@ local function err_contains(err, substr) return tostring(msg):find(substr, 1, true) ~= nil end -local function find_module_entries() - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, "registry.snapshot no error") - local state, state_err = snap:state() +local function state() + local snap, err = registry.snapshot() + assert.is_nil(err, "snapshot no error") + assert.not_nil(snap, "snapshot available") + + local result, state_err = snap:state() assert.is_nil(state_err, "snapshot state no error") + assert.not_nil(result, "snapshot state available") + return result +end + +local function find_module_entries() local entries = {} - for _, entry in ipairs(state.entries) do - if state.provenance[entry.id].module == module_name then - entries[#entries + 1] = entry + for _, entry in ipairs(state().entries) do + if entry.registry.owner == module_name then + table.insert(entries, entry) end end return entries end +local function selected_module() + local resolution = state().resolution + assert.not_nil(resolution, "dependency resolution available") + for _, module in ipairs(resolution.modules) do + if module.name == module_name then + return module + end + end + return nil +end + local function apply_changes(apply_fn) local snap, err = registry.snapshot() assert.is_nil(err, "snapshot no error") @@ -101,6 +119,10 @@ local function main() local entries = find_module_entries() assert.ok(#entries > 0, "module entries present") assert.not_nil(entries[1].id, "module entry has id") + assert.eq(entries[1].registry.owner, module_name, "state identifies the installed module") + local ordinary = registry.get(tostring(entries[1].id)) as any + assert.is_nil(ordinary.registry, "ordinary entry reads do not expose registry metadata") + assert.not_nil(selected_module(), "selected module is in the atomic state") local workspace, fs_err = fs.get("app:workspace") if workspace ~= nil then diff --git a/tests/app/src/test/registry/dependency_requirements.lua b/tests/app/src/test/registry/dependency_requirements.lua index 6f48738f4..5a56a7fb1 100644 --- a/tests/app/src/test/registry/dependency_requirements.lua +++ b/tests/app/src/test/registry/dependency_requirements.lua @@ -13,21 +13,36 @@ local param_name = "router" local router_primary = "app:api.public" local router_secondary = "app:api.ws" -local function find_module_entries(kind) - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, "registry.snapshot no error") +local function state_entries(kind: string?) + local snap, err = registry.snapshot() + assert.is_nil(err, "snapshot no error") + assert.not_nil(snap, "snapshot available") + local state, state_err = snap:state() assert.is_nil(state_err, "snapshot state no error") + assert.not_nil(state, "snapshot state available") + local entries = {} for _, entry in ipairs(state.entries) do - local owned = state.provenance[entry.id].module == module_name - if owned and (kind == nil or entry.kind == kind) then - entries[#entries + 1] = entry + if entry.registry.owner == module_name and (kind == nil or entry.kind == kind) then + table.insert(entries, entry) end end return entries end +local function find_module_entries() + return state_entries(nil) +end + +local function find_requirements() + return state_entries("ns.requirement") +end + +local function find_endpoints() + return state_entries("http.endpoint") +end + local function apply_changes(apply_fn) local snap, err = registry.snapshot() assert.is_nil(err, "snapshot no error") @@ -136,10 +151,10 @@ local function main() local module_count = #module_entries assert.ok(module_count > 0, "module entries counted") - local reqs = find_module_entries("ns.requirement") + local reqs = find_requirements() assert.ok(#reqs > 0, "module requirements present") - local endpoints = find_module_entries("http.endpoint") + local endpoints = find_endpoints() assert.ok(#endpoints > 0, "module endpoints present") for i = 1, #endpoints do local endpoint = endpoints[i] @@ -199,7 +214,7 @@ local function main() }) end) - local updated_endpoints = find_module_entries("http.endpoint") + local updated_endpoints = find_endpoints() assert.ok(#updated_endpoints > 0, "endpoints present after update") for i = 1, #updated_endpoints do local endpoint = updated_endpoints[i] @@ -214,7 +229,7 @@ local function main() assert.is_nil(rollback_err, "rollback no error") assert.ok(rollback_ok, "rollback succeeded") - local rolled_back_endpoints = find_module_entries("http.endpoint") + local rolled_back_endpoints = find_endpoints() assert.ok(#rolled_back_endpoints > 0, "endpoints present after rollback") for i = 1, #rolled_back_endpoints do local endpoint = rolled_back_endpoints[i] @@ -226,7 +241,7 @@ local function main() assert.is_nil(forward_err, "forward apply no error") assert.ok(forward_ok, "forward apply succeeded") - local forwarded_endpoints = find_module_entries("http.endpoint") + local forwarded_endpoints = find_endpoints() assert.ok(#forwarded_endpoints > 0, "endpoints present after forward apply") for i = 1, #forwarded_endpoints do local endpoint = forwarded_endpoints[i] @@ -239,7 +254,7 @@ local function main() end) assert.eq(#find_module_entries(), 0, "module entries removed after uninstall") - assert.eq(#find_module_entries("http.endpoint"), 0, "module endpoints removed after uninstall") + assert.eq(#find_endpoints(), 0, "module endpoints removed after uninstall") return true end diff --git a/tests/app/src/test/registry/dependency_root_field.lua b/tests/app/src/test/registry/dependency_root_field.lua deleted file mode 100644 index 4dc948260..000000000 --- a/tests/app/src/test/registry/dependency_root_field.lua +++ /dev/null @@ -1,78 +0,0 @@ --- SPDX-License-Identifier: MPL-2.0 - -local assert = require("assert2") -local registry = require("registry") - -local function assert_snapshot_root(id, expected, message) - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, message .. ": snapshot") - local state, state_err = snap:state() - assert.is_nil(state_err, message .. ": state") - assert.not_nil(state.provenance[id], message .. ": provenance exists") - assert.eq(state.provenance[id].root, expected, message) -end - --- Root status is registry-owned provenance. Entry authors neither stamp it in --- meta nor write it through the entry transport; set_root is the dedicated --- mutation boundary and ordinary entry rewrites must preserve it. -local function main() - local original_version, version_err = registry.current_version() - assert.is_nil(version_err, "current version no error") - assert.not_nil(original_version, "have original version") - - local root_id = "app.test.registry:dependency_root_transport" - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, "snapshot no error") - local changes = snap:changes() - changes:create({ - id = root_id, - kind = "ns.dependency", - data = { - component = "wippy/terminal", - version = ">=0.0.0", - }, - }) - local installed_version, install_err = changes:apply() - assert.is_nil(install_err, "install dependency") - assert.not_nil(installed_version, "install version returned") - - local dependency, dependency_err = registry.get(root_id) - assert.is_nil(dependency_err, "read dependency") - local original_root = dependency.root == true - local changed_root = not original_root - - local changed, root_err = registry.set_root(root_id, changed_root) - assert.is_nil(root_err, "change dependency root") - assert.ok(changed, "root status changed") - - local marked, marked_err = registry.get(root_id) - assert.is_nil(marked_err, "read marked entry") - assert.not_nil(marked, "marked entry exists") - assert.eq(marked.root, changed_root, "root survives the write and the read") - assert_snapshot_root(root_id, changed_root, "atomic snapshot carries changed root") - - -- A read-modify-write is the path keeper takes on every dependency update. - local snap2, snap2_err = registry.snapshot() - assert.is_nil(snap2_err, "second snapshot no error") - local rewrite = snap2:changes() - marked.root = nil - marked.meta = marked.meta or {} - marked.meta.comment = "rewritten" - rewrite:update(marked) - local rewritten_version, rewrite_err = rewrite:apply() - assert.is_nil(rewrite_err, "apply rewrite") - assert.not_nil(rewritten_version, "rewritten version returned") - - local after, after_err = registry.get(root_id) - assert.is_nil(after_err, "read rewritten entry") - assert.eq(after.root, changed_root, "round trip through Lua does not change root state") - assert_snapshot_root(root_id, changed_root, "atomic snapshot preserves root through rewrite") - - local restored, restore_err = registry.apply_version(original_version) - assert.is_nil(restore_err, "restore original version") - assert.ok(restored, "restore original succeeded") - - return true -end - -return { main = main } diff --git a/tests/app/src/test/registry/dependency_update.lua b/tests/app/src/test/registry/dependency_update.lua index 0c40a82bd..2f5c0ba8a 100644 --- a/tests/app/src/test/registry/dependency_update.lua +++ b/tests/app/src/test/registry/dependency_update.lua @@ -9,18 +9,25 @@ local module_name = "wippy/terminal" local dep_id = "app.test.registry:terminal_dependency_update" local hub_timeout = "20s" -local function find_module_entries() - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, "registry.snapshot no error") - local state, state_err = snap:state() +local function state() + local snap, err = registry.snapshot() + assert.is_nil(err, "snapshot no error") + assert.not_nil(snap, "snapshot available") + + local result, state_err = snap:state() assert.is_nil(state_err, "snapshot state no error") + assert.not_nil(result, "snapshot state available") + return result +end + +local function find_module_entries() local entries = {} - for _, entry in ipairs(state.entries) do - if state.provenance[entry.id].module == module_name then - entries[#entries + 1] = entry + for _, entry in ipairs(state().entries) do + if entry.registry.owner == module_name then + table.insert(entries, entry) end end - return entries, state.provenance + return entries end local function module_versions() @@ -78,11 +85,12 @@ local function ensure_dependency_removed() end end -local function first_module_version(entries, provenance) - for i = 1, #entries do - local record = provenance[tostring(entries[i].id)] - if record ~= nil and record.version ~= "" then - return record.version +local function selected_module_version() + local resolution = state().resolution + assert.not_nil(resolution, "dependency resolution available") + for _, module in ipairs(resolution.modules) do + if module.name == module_name then + return module.version end end return nil @@ -104,9 +112,10 @@ local function main() }) end) - local entries_a, provenance_a = find_module_entries() + local entries_a = find_module_entries() assert.ok(#entries_a > 0, "module entries installed") - local installed_version = first_module_version(entries_a, provenance_a) + assert.eq(entries_a[1].registry.owner, module_name, "state identifies installed entry ownership") + local installed_version = selected_module_version() assert.eq(installed_version, version_a, "installed version matches first constraint") apply_changes(function(changes) @@ -119,9 +128,10 @@ local function main() }, }) end) - local entries_b, provenance_b = find_module_entries() + local entries_b = find_module_entries() assert.ok(#entries_b > 0, "module entries updated") - assert.eq(first_module_version(entries_b, provenance_b), version_b, "updated version matches second constraint") + assert.eq(entries_b[1].registry.owner, module_name, "state retains installed entry ownership after update") + assert.eq(selected_module_version(), version_b, "updated version matches second constraint") apply_changes(function(changes) changes:delete(dep_id) diff --git a/tests/app/src/test/registry/dependency_version_jump.lua b/tests/app/src/test/registry/dependency_version_jump.lua index a625a585c..fcaeecfa0 100644 --- a/tests/app/src/test/registry/dependency_version_jump.lua +++ b/tests/app/src/test/registry/dependency_version_jump.lua @@ -9,25 +9,33 @@ local module_name = "wippy/terminal" local dep_id = "app.test.registry:terminal_dependency_version_jump" local hub_timeout = "20s" -local function find_module_entries() - local snap, snap_err = registry.snapshot() - assert.is_nil(snap_err, "registry.snapshot no error") - local state, state_err = snap:state() +local function state() + local snap, err = registry.snapshot() + assert.is_nil(err, "snapshot no error") + assert.not_nil(snap, "snapshot available") + + local result, state_err = snap:state() assert.is_nil(state_err, "snapshot state no error") + assert.not_nil(result, "snapshot state available") + return result +end + +local function find_module_entries() local entries = {} - for _, entry in ipairs(state.entries) do - if state.provenance[entry.id].module == module_name then - entries[#entries + 1] = entry + for _, entry in ipairs(state().entries) do + if entry.registry.owner == module_name then + table.insert(entries, entry) end end - return entries, state.provenance + return entries end -local function first_module_version(entries, provenance) - for i = 1, #entries do - local record = provenance[tostring(entries[i].id)] - if record ~= nil and record.version ~= "" then - return record.version +local function selected_module_version() + local resolution = state().resolution + assert.not_nil(resolution, "dependency resolution available") + for _, module in ipairs(resolution.modules) do + if module.name == module_name then + return module.version end end return nil @@ -104,9 +112,10 @@ local function main() }) end) - local entries_a, provenance_a = find_module_entries() + local entries_a = find_module_entries() assert.ok(#entries_a > 0, "module entries installed") - assert.eq(first_module_version(entries_a, provenance_a), version_a, "installed version matches A") + assert.eq(entries_a[1].registry.owner, module_name, "state identifies installed entry ownership") + assert.eq(selected_module_version(), version_a, "installed version matches A") local v2 = apply_changes(function(changes) changes:update({ @@ -119,25 +128,26 @@ local function main() }) end) - local entries_b, provenance_b = find_module_entries() + local entries_b = find_module_entries() assert.ok(#entries_b > 0, "module entries updated") - assert.eq(first_module_version(entries_b, provenance_b), version_b, "installed version matches B") + assert.eq(entries_b[1].registry.owner, module_name, "state retains installed entry ownership after update") + assert.eq(selected_module_version(), version_b, "updated version matches B") local ok, err = registry.apply_version(v1) assert.is_nil(err, "rollback no error") assert.ok(ok, "rollback ok") - local entries_rollback, provenance_rollback = find_module_entries() + local entries_rollback = find_module_entries() assert.ok(#entries_rollback > 0, "module entries after rollback") - assert.eq(first_module_version(entries_rollback, provenance_rollback), version_a, "version restored after rollback") + assert.eq(selected_module_version(), version_a, "version restored after rollback") local ok2, err2 = registry.apply_version(v2) assert.is_nil(err2, "forward apply no error") assert.ok(ok2, "forward apply ok") - local entries_forward, provenance_forward = find_module_entries() + local entries_forward = find_module_entries() assert.ok(#entries_forward > 0, "module entries after forward apply") - assert.eq(first_module_version(entries_forward, provenance_forward), version_b, "version restored after forward apply") + assert.eq(selected_module_version(), version_b, "version restored after forward apply") apply_changes(function(changes) changes:delete(dep_id) diff --git a/tests/app/src/test/registry/overlay_state.lua b/tests/app/src/test/registry/overlay_state.lua deleted file mode 100644 index 74b8331c2..000000000 --- a/tests/app/src/test/registry/overlay_state.lua +++ /dev/null @@ -1,26 +0,0 @@ --- SPDX-License-Identifier: MPL-2.0 - -local assert = require("assert2") -local errors = require("errors") -local registry = require("registry") - -local function main() - local snap, overlay_err = registry.overlay("app.test.registry:state_overlay") - assert.is_nil(overlay_err, "open overlay") - assert.not_nil(snap, "overlay snapshot returned") - - local state, state_err = snap:state() - assert.is_nil(state, "overlay state is unavailable without provenance") - assert.not_nil(state_err, "overlay state fails closed") - assert.eq(state_err:kind(), errors.UNAVAILABLE, "overlay state reports unavailable provenance") - - -- Existing overlay operations remain available. Only the paired provenance - -- projection is refused because overlay storage does not own that evidence. - local entries, entries_err = snap:entries() - assert.is_nil(entries_err, "overlay entries remain readable") - assert.not_nil(entries, "overlay entries returned") - - return true -end - -return { main = main } diff --git a/tests/app/src/test/registry/registry_metadata_state.lua b/tests/app/src/test/registry/registry_metadata_state.lua new file mode 100644 index 000000000..fef413b42 --- /dev/null +++ b/tests/app/src/test/registry/registry_metadata_state.lua @@ -0,0 +1,93 @@ +-- SPDX-License-Identifier: MPL-2.0 + +local assert = require("assert2") +local registry = require("registry") + +local entry_id = "app.test.registry:registry_metadata_surface" + +local function state_entry(id) + local snap, snap_err = registry.snapshot() + assert.is_nil(snap_err, "snapshot no error") + assert.not_nil(snap, "snapshot available") + + local state, state_err = snap:state() + assert.is_nil(state_err, "snapshot state no error") + assert.not_nil(state, "snapshot state available") + + for _, entry in ipairs(state.entries) do + if entry.id == id then + return entry + end + end + return nil +end + +local function apply(change) + local snap, snap_err = registry.snapshot() + assert.is_nil(snap_err, "snapshot no error") + local changes = snap:changes() + change(changes) + local version, apply_err = changes:apply() + assert.is_nil(apply_err, "apply changes") + assert.not_nil(version, "apply returns version") +end + +local function main() + local original_version, version_err = registry.current_version() + assert.is_nil(version_err, "current version no error") + assert.not_nil(original_version, "have original version") + + -- Registry ownership is not entry input. These fields deliberately resemble + -- registry metadata and must neither be accepted nor appear on ordinary reads. + apply(function(changes) + changes:create({ + id = entry_id, + kind = "function.lua", + root = true, + owner = "untrusted/module", + registry = { owner = "untrusted/module", root = true }, + meta = { comment = "registry metadata surface regression" }, + data = { + source = "return { main = function() return true end }", + method = "main", + }, + }) + end) + + local ordinary, get_err = registry.get(entry_id) + assert.is_nil(get_err, "ordinary read no error") + assert.not_nil(ordinary, "ordinary entry exists") + local author_entry = ordinary as any + assert.is_nil(author_entry.root, "ordinary reads do not expose root") + assert.is_nil(author_entry.owner, "ordinary reads do not expose owner") + assert.is_nil(author_entry.registry, "ordinary reads do not expose registry metadata") + + local first_state = state_entry(entry_id) + assert.not_nil(first_state, "state contains created entry") + assert.not_nil(first_state.registry, "state carries registry metadata") + assert.eq(first_state.registry.owner, "", "entry input cannot assign ownership") + assert.eq(first_state.registry.root, false, "entry input cannot select a deployment root") + + -- A normal read-modify-write must leave registry-owned metadata intact. + ordinary.meta.comment = "rewritten" + author_entry.root = true + author_entry.owner = "another/untrusted-module" + author_entry.registry = { owner = "another/untrusted-module", root = true } + apply(function(changes) + changes:update(ordinary) + end) + + local after_state = state_entry(entry_id) + assert.not_nil(after_state, "state contains rewritten entry") + assert.eq(after_state.registry.owner, "", "rewrite cannot change ownership") + assert.eq(after_state.registry.root, false, "rewrite cannot change deployment-root state") + assert.eq(after_state.meta.comment, "rewritten", "author data remains writable") + + local restored, restore_err = registry.apply_version(original_version) + assert.is_nil(restore_err, "restore original version") + assert.ok(restored, "restore original succeeded") + + return true +end + +return { main = main } diff --git a/tests/app/src/test/registry/snapshot.lua b/tests/app/src/test/registry/snapshot.lua index f8229ffea..ee7e5da51 100644 --- a/tests/app/src/test/registry/snapshot.lua +++ b/tests/app/src/test/registry/snapshot.lua @@ -18,31 +18,6 @@ local function main() assert.not_nil(entries, "snapshot has entries") assert.eq(type(entries), "table", "entries is table") - -- State pairs the author entries with the registry-owned provenance captured - -- at the same version. The provenance map is total over visible entries and - -- is returned by value, so consumers can index it without N live lookups. - local state, state_err = snap:state() - assert.is_nil(state_err, "snapshot state no error") - assert.not_nil(state, "snapshot state returned") - assert.eq(#state.entries, #entries, "state and entries expose the same visible set") - local provenance_count = 0 - for _, entry in ipairs(state.entries) do - assert.not_nil(state.provenance[entry.id], "every visible entry has provenance: " .. entry.id) - end - for _ in pairs(state.provenance) do provenance_count = provenance_count + 1 end - assert.eq(provenance_count, #state.entries, "snapshot state exposes no orphaned provenance") - - if #state.entries > 0 then - local first_id = state.entries[1].id - state.provenance[first_id].module = "forged/module" - state.entries[1].meta = state.entries[1].meta or {} - state.entries[1].meta.detached_probe = true - local fresh, fresh_err = snap:state() - assert.is_nil(fresh_err, "repeat snapshot state no error") - assert.ok(fresh.provenance[first_id].module ~= "forged/module", "provenance result is detached") - assert.ok(fresh.entries[1].meta == nil or fresh.entries[1].meta.detached_probe ~= true, "entry result is detached") - end - -- namespace filter local ns_entries = snap:namespace("app") assert.not_nil(ns_entries, "namespace filter works") diff --git a/tests/app/src/test/registry/snapshot_at.lua b/tests/app/src/test/registry/snapshot_at.lua index 00f985f7a..9296a8109 100644 --- a/tests/app/src/test/registry/snapshot_at.lua +++ b/tests/app/src/test/registry/snapshot_at.lua @@ -42,12 +42,6 @@ local function main() local entries = snap:entries() assert.not_nil(entries, "snapshot has entries") - local state, state_err = snap:state() - assert.is_nil(state_err, "historical snapshot state no error") - assert.eq(#state.entries, #entries, "historical state pairs every entry") - for _, entry in ipairs(state.entries) do - assert.not_nil(state.provenance[entry.id], "historical entry has provenance: " .. entry.id) - end end -- test snapshot_at with invalid version (0) @@ -77,9 +71,6 @@ local function main() local hist_snap_version = hist_snap:version() assert.not_nil(hist_snap_version, "history snapshot has version") assert.eq(hist_snap_version:id(), version_id, "history snapshot version matches") - local hist_state, hist_state_err = hist_snap:state() - assert.is_nil(hist_state_err, "history snapshot exposes provenance") - assert.not_nil(hist_state.provenance, "history snapshot provenance returned") return true end diff --git a/tests/app/src/test/security/can.lua b/tests/app/src/test/security/can.lua index 87bd591be..10be340a3 100644 --- a/tests/app/src/test/security/can.lua +++ b/tests/app/src/test/security/can.lua @@ -34,10 +34,11 @@ local function main() assert.eq(result.can_write, false, "empty scope: can_write should be false") assert.eq(result.can_delete, false, "empty scope: can_delete should be false") - -- Direct checks use the test command's inherited allow-all scope. + -- Test can() directly (without security context) local allowed = security.can("read", "resource") assert.eq(type(allowed), "boolean", "can should return boolean") - assert.eq(allowed, true, "inherited test command scope should allow access") + -- Without injected context, can() returns false + assert.eq(allowed, false, "can should return false without security context") return true end diff --git a/tests/app/src/test/security/module.lua b/tests/app/src/test/security/module.lua index d674d2b03..7d6f15ef2 100644 --- a/tests/app/src/test/security/module.lua +++ b/tests/app/src/test/security/module.lua @@ -17,18 +17,18 @@ local function main() assert.eq(type(security.new_actor), "function", "new_actor function should exist") assert.eq(type(security.token_store), "function", "token_store function should exist") - -- The test command declares its execution identity and scope. Functions - -- called by the runner inherit that frame unless they explicitly replace it. + -- Without security context, actor() returns nil local actor = security.actor() - assert.not_nil(actor, "test command actor should be inherited") - assert.eq(actor:id(), "app:test_runner", "test command actor identity") + assert.eq(actor, nil, "actor should be nil without security context") + -- Without security context, scope() returns nil local scope = security.scope() - assert.not_nil(scope, "test command scope should be inherited") + assert.eq(scope, nil, "scope should be nil without security context") + -- Without security context, can() returns false local allowed = security.can("read", "resource") assert.eq(type(allowed), "boolean", "can should return boolean") - assert.eq(allowed, true, "test command policy should allow access") + assert.eq(allowed, false, "can should return false without security context") -- policy() returns policy when found, error when not local pol, err = security.policy("app.test.security:allow_all") diff --git a/tests/app/test b/tests/app/test index cf490dac6..6335089ba 100755 --- a/tests/app/test +++ b/tests/app/test @@ -5,7 +5,7 @@ set -euo pipefail cd "$(dirname "$0")" mkdir -p /tmp/wippy-gocache /tmp/wippy-gotmp -GOCACHE=/tmp/wippy-gocache GOTMPDIR=/tmp/wippy-gotmp OTEL_SDK_DISABLED=true SKIP_TEMPORAL_TESTS=1 SKIP_CLOUDSTORAGE_TESTS=1 SKIP_SQS_TESTS=1 \ +GOCACHE=/tmp/wippy-gocache GOTMPDIR=/tmp/wippy-gotmp OTEL_SDK_DISABLED=true SKIP_TEMPORAL_TESTS=1 SKIP_CLOUDSTORAGE_TESTS=1 SKIP_SQS_TESTS=1 GOEXPERIMENT=jsonv2 \ go run -tags "fts5 sqlite_vec treesitter" ../../cmd/wippy test -c -s go test ../../service/temporal/peer diff --git a/tests/app/test.sh b/tests/app/test.sh index b47d5ada3..ac8f26307 100755 --- a/tests/app/test.sh +++ b/tests/app/test.sh @@ -50,11 +50,10 @@ if ! docker image inspect alpine:latest >/dev/null 2>&1; then docker pull alpine:latest fi -GOCACHE=/tmp/wippy-gocache GOTMPDIR=/tmp/wippy-gotmp OTEL_SDK_DISABLED=true SKIP_TEMPORAL_TESTS=1 SKIP_CLOUDSTORAGE_TESTS=1 \ +GOCACHE=/tmp/wippy-gocache GOTMPDIR=/tmp/wippy-gotmp OTEL_SDK_DISABLED=true SKIP_TEMPORAL_TESTS=1 SKIP_CLOUDSTORAGE_TESTS=1 GOEXPERIMENT=jsonv2 \ go run -tags treesitter ../../cmd/wippy test -c -s | tee "$test_log" -# Keep a shell-level guard so structured runner regressions cannot produce a -# false-green application suite. +# The test runner currently prints failures but exits 0; enforce failure here. clean_log="$(mktemp /tmp/wippy-app-tests-clean.XXXXXX.log)" sed -E 's/\x1B\[[0-9;?]*[ -\/]*[@-~]//g' "$test_log" > "$clean_log" if grep -qE "(^|[[:space:]])[1-9][0-9]* failed([[:space:]]|$)|(^|[[:space:]])FAILED([[:space:]]|$)" "$clean_log"; then