diff --git a/pluginkit/CHANGELOG.md b/pluginkit/CHANGELOG.md index 783e20d..4f66526 100644 --- a/pluginkit/CHANGELOG.md +++ b/pluginkit/CHANGELOG.md @@ -3,6 +3,13 @@ Releases of this module are tagged `pluginkit/vX.Y.Z`. Releases up to 0.5.1 were tagged `vX.Y.Z` in `github.com/gopherium/pluginkit`. +## Unreleased + +- `PluginStore`, with `Records`, `Activate` and `Pause`, is an application's record of each plugin's state: not installed, inactive, active or paused, with the text of the failure that paused it. +- `NewBoot`, `Register`, `Boot.Pause` and `Boot.Started` carry one boot from the records to the start: a plugin seen for the first time is recorded active, inactive and not installed plugins are never registered, a plugin that fails to register, migrate or start is paused, and a paused plugin that starts again is active with no error. +- A boot whose context ends pauses nothing, and a store read or write that fails is returned to the application. +- `pluginstoretest.Run` checks any `PluginStore` against the contract, and `pluginstoretest.NewMemory` is a store in memory that keeps it. + ## 0.12.0 - 2026-11-07 - `RegisterEach` calls each `Registration` with the dependencies `depsFor` builds for its id, each inside its own recover, goes on past an error or a panic, and answers a `RegisterOutcome` naming each failure at the new `StepRegister`. diff --git a/pluginkit/boot.go b/pluginkit/boot.go new file mode 100644 index 0000000..abe971d --- /dev/null +++ b/pluginkit/boot.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import ( + "context" + "fmt" +) + +// Boot is one start of the application's plugins, from their records to their start. +type Boot struct { + store PluginStore + records map[string]PluginRecord +} + +// NewBoot reads every record of store once. +func NewBoot(ctx context.Context, store PluginStore) (*Boot, error) { + var records map[string]PluginRecord + err := guarded(ctx, "read the plugin records", func(ctx context.Context) error { + var readErr error + records, readErr = store.Records(ctx) + return readErr + }) + if err != nil { + return nil, err + } + return &Boot{store: store, records: records}, nil +} + +// Register records each new entry active, registers the active and paused ones, and pauses each that fails. +func Register[D, P any](ctx context.Context, boot *Boot, entries []Registration[D, P], + depsFor func(id string) D) (RegisterOutcome[P], error) { + wanted, err := admitted(ctx, boot, entries) + if err != nil { + return RegisterOutcome[P]{}, err + } + outcome := RegisterEach(wanted, depsFor) + return outcome, boot.pauseEach(ctx, outcome.Failed) +} + +// admitted returns the entries boot registers, in order, recording each new one active. +func admitted[D, P any](ctx context.Context, boot *Boot, entries []Registration[D, P]) ([]Registration[D, P], error) { + var wanted []Registration[D, P] + for _, entry := range entries { + registers, err := boot.admit(ctx, entry.ID) + if err != nil { + return nil, err + } + if registers { + wanted = append(wanted, entry) + } + } + return wanted, nil +} + +// pauseEach pauses each failed plugin but one whose migration record failed, and none once ctx ended. +func (b *Boot) pauseEach(ctx context.Context, failures []Failure) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("pluginkit: the boot's context ended: %w", err) + } + for _, failure := range failures { + if failure.Step == StepRecord { + continue + } + if err := b.Pause(ctx, failure.ID, failure.Err); err != nil { + return err + } + } + return nil +} + +// admit records the plugin id active when boot has never seen it, and reports whether a boot registers it. +func (b *Boot) admit(ctx context.Context, id string) (bool, error) { + if record, known := b.records[id]; known { + return record.State == PluginActive || record.State == PluginPaused, nil + } + return true, b.activate(ctx, id) +} + +// Pause pauses the plugin id for failure, a step that failed or a reason of the application's own. +func (b *Boot) Pause(ctx context.Context, id string, failure error) error { + return guarded(ctx, "pause "+id, func(ctx context.Context) error { + return b.store.Pause(ctx, id, failure.Error()) + }) +} + +// activate marks the plugin id active and clears its error. +func (b *Boot) activate(ctx context.Context, id string) error { + return guarded(ctx, "activate "+id, func(ctx context.Context) error { return b.store.Activate(ctx, id) }) +} + +// Started pauses each plugin that failed to migrate or start, and activates each paused plugin that started. +func (b *Boot) Started(ctx context.Context, outcome StartOutcome) error { + if err := b.pauseEach(ctx, outcome.Failed); err != nil { + return err + } + for _, started := range outcome.Started { + if b.records[started.ID].State != PluginPaused { + continue + } + if err := b.activate(ctx, started.ID); err != nil { + return err + } + } + return nil +} diff --git a/pluginkit/boot_test.go b/pluginkit/boot_test.go new file mode 100644 index 0000000..fe703c3 --- /dev/null +++ b/pluginkit/boot_test.go @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit_test + +import ( + "context" + "errors" + "testing" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +// countingStore is a plugin store in memory that notes each read and write, and fails them when told. +type countingStore struct { + inner pluginkit.PluginStore + reads int + writes []string + readErr error + readPanic any + writeErr error +} + +// Records notes the read and answers the records, or the failure it was told. +func (s *countingStore) Records(ctx context.Context) (map[string]pluginkit.PluginRecord, error) { + s.reads++ + if s.readPanic != nil { + panic(s.readPanic) + } + if s.readErr != nil { + return nil, s.readErr + } + return s.inner.Records(ctx) +} + +// Activate notes the write and activates id, or answers the failure it was told. +func (s *countingStore) Activate(ctx context.Context, id string) error { + s.writes = append(s.writes, "activate "+id) + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Activate(ctx, id) +} + +// Pause notes the write and pauses id, or answers the failure it was told. +func (s *countingStore) Pause(ctx context.Context, id, failure string) error { + s.writes = append(s.writes, "pause "+id+": "+failure) + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Pause(ctx, id, failure) +} + +// assertErrorFree stops t when err is set. +func assertErrorFree(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("error = %v, want nil", err) + } +} + +// assertErrorText checks that err has exactly the text want. +func assertErrorText(t *testing.T, err error, want string) { + t.Helper() + if err == nil || err.Error() != want { + t.Errorf("error = %v, want %q", err, want) + } +} + +// storeOf returns a counting store holding records. +func storeOf(records ...pluginkit.PluginRecord) *countingStore { + return &countingStore{inner: pluginstoretest.NewMemory(records...)} +} + +// record returns the record of id in state, with no error. +func record(id string, state pluginkit.PluginState) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: state} +} + +// newBoot reads store into a new boot, stopping t when it cannot. +func newBoot(t *testing.T, store pluginkit.PluginStore) *pluginkit.Boot { + t.Helper() + boot, err := pluginkit.NewBoot(t.Context(), store) + if err != nil { + t.Fatalf("NewBoot() error = %v, want nil", err) + } + return boot +} + +// held returns every record store holds. +func held(t *testing.T, store *countingStore) map[string]pluginkit.PluginRecord { + t.Helper() + records, err := store.inner.Records(t.Context()) + if err != nil { + t.Fatalf("Records() error = %v, want nil", err) + } + return records +} + +// registerAll registers entries on boot, stopping t when the boot fails. +func registerAll(t *testing.T, boot *pluginkit.Boot, + entries ...pluginkit.Registration[listedDeps, *listedPlugin]) pluginkit.RegisterOutcome[*listedPlugin] { + t.Helper() + var calls []string + outcome, err := pluginkit.Register(t.Context(), boot, entries, depsOf(&calls)) + if err != nil { + t.Fatalf("Register() error = %v, want nil", err) + } + return outcome +} + +func TestNewBootReadsTheRecordsOnceForAWholeBoot(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive)) + boot := newBoot(t, store) + registerAll(t, boot, entry("events", &calls, succeeds)) + + err := boot.Started(t.Context(), pluginkit.StartOutcome{Started: []pluginkit.StartedPlugin{{ID: "events"}}}) + + assertErrorFree(t, err) + wantEqual(t, "reads", store.reads, 1) +} + +func TestNewBootNamesAStoreThatCannotRead(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + want string + }{ + "an error": {&countingStore{readErr: errors.New("connection dropped")}, + "pluginkit: read the plugin records: connection dropped"}, + "a panic": {&countingStore{readPanic: "boom"}, "pluginkit: read the plugin records panicked: boom"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + boot, err := pluginkit.NewBoot(t.Context(), tc.store) + + assertErrorText(t, err, tc.want) + if boot != nil { + t.Errorf("NewBoot() = %v, want no boot", boot) + } + }) + } +} + +func TestRegisterRecordsAPluginItHasNeverSeenAsActive(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf() + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "feed"}) + wantEqual(t, "writes", store.writes, []string{"activate events", "activate feed"}) + wantEqual(t, "records", held(t, store), map[string]pluginkit.PluginRecord{ + "events": record("events", pluginkit.PluginActive), "feed": record("feed", pluginkit.PluginActive), + }) +} + +func TestRegisterLeavesOutInactiveAndNotInstalledPlugins(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginInactive), + record("gallery", pluginkit.PluginNotInstalled)) + + outcome := registerAll(t, newBoot(t, store), + entry("events", &calls, succeeds), entry("feed", &calls, succeeds), entry("gallery", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events"}) + wantEqual(t, "calls", calls, []string{"register events"}) + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestRegisterTriesAPausedPluginAgainWithoutWriting(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), + pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}) + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events", "feed"}) + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestRegisterPausesEachPluginThatFailsToRegister(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginActive), + record("gallery", pluginkit.PluginActive)) + + outcome := registerAll(t, newBoot(t, store), entry("events", &calls, succeeds), + entry("feed", &calls, answers(errors.New("the title is too long"))), entry("gallery", &calls, panicsWith("boom"))) + + wantEqual(t, "registered", registeredIDs(outcome), []string{"events"}) + wantEqual(t, "writes", store.writes, []string{ + "pause feed: pluginkit: feed register: the title is too long", + "pause gallery: pluginkit: gallery register panicked: boom", + }) +} + +func TestRegisterNamesAStoreThatCannotWrite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + answer func() error + want string + calling []string + }{ + "recording a new plugin": {storeOf(), succeeds, "pluginkit: activate feed: connection dropped", nil}, + "pausing a failed plugin": {storeOf(record("feed", pluginkit.PluginActive)), + answers(errors.New("the title is too long")), "pluginkit: pause feed: connection dropped", + []string{"deps feed", "register feed"}}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + var calls []string + boot := newBoot(t, tc.store) + tc.store.writeErr = errors.New("connection dropped") + + _, err := pluginkit.Register(t.Context(), boot, + []pluginkit.Registration[listedDeps, *listedPlugin]{entry("feed", &calls, tc.answer)}, depsOf(&calls)) + + assertErrorText(t, err, tc.want) + wantEqual(t, "calls", calls, tc.calling) + }) + } +} + +func TestRegisterPausesNothingWhenTheContextEnded(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + outcome, err := pluginkit.Register(ctx, boot, []pluginkit.Registration[listedDeps, *listedPlugin]{ + entry("feed", &calls, answers(errors.New("the boot is shutting down"))), + }, depsOf(&calls)) + + assertErrorText(t, err, "pluginkit: the boot's context ended: context canceled") + if !errors.Is(err, context.Canceled) { + t.Errorf("Register() error = %v, want it to wrap the context's error", err) + } + wantEqual(t, "failed", len(outcome.Failed), 1) + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestBootPausePausesAPluginForTheApplicationsReason(t *testing.T) { + t.Parallel() + + store := storeOf(record("forum", pluginkit.PluginActive)) + + err := newBoot(t, store).Pause(t.Context(), "forum", errors.New("forum declares a role it cannot accept")) + + assertErrorFree(t, err) + wantEqual(t, "records", held(t, store), map[string]pluginkit.PluginRecord{"forum": { + ID: "forum", State: pluginkit.PluginPaused, LastError: "forum declares a role it cannot accept", + }}) +} + +func TestStartedPausesEachPluginThatFailedToMigrateOrStart(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive), record("gallery", pluginkit.PluginActive)) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepMigrate, Err: errors.New("pluginkit: feed migrate: schema exploded")}, + {ID: "gallery", Step: pluginkit.StepStart, Err: errors.New("pluginkit: gallery start: no route table")}, + }}) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{ + "pause feed: pluginkit: feed migrate: schema exploded", + "pause gallery: pluginkit: gallery start: no route table", + }) +} + +func TestStartedLeavesAFailedMigrationRecordAlone(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive), record("gallery", pluginkit.PluginActive)) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "feed"}}, + Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepRecord, Err: errors.New("pluginkit: feed record: ledger locked")}, + {ID: "gallery", Step: pluginkit.StepStart, Err: errors.New("pluginkit: gallery start: no route table")}, + }, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{"pause gallery: pluginkit: gallery start: no route table"}) +} + +func TestStartedActivatesAPausedPluginThatStarted(t *testing.T) { + t.Parallel() + + store := storeOf(record("events", pluginkit.PluginActive), + pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}) + + err := newBoot(t, store).Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "events"}, {ID: "feed"}}, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string{"activate feed"}) + wantEqual(t, "feed", held(t, store)["feed"], record("feed", pluginkit.PluginActive)) +} + +func TestStartedWritesNothingWhenTheContextEnded(t *testing.T) { + t.Parallel() + + store := storeOf(record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err := boot.Started(ctx, pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepMigrate, Err: ctx.Err()}, + }}) + + assertErrorText(t, err, "pluginkit: the boot's context ended: context canceled") + if !errors.Is(err, context.Canceled) { + t.Errorf("Started() error = %v, want it to wrap the context's error", err) + } + wantEqual(t, "writes", store.writes, []string(nil)) +} + +func TestStartedNamesAStoreThatCannotWrite(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + store *countingStore + outcome pluginkit.StartOutcome + want string + }{ + "a pause": {storeOf(record("feed", pluginkit.PluginActive)), pluginkit.StartOutcome{Failed: []pluginkit.Failure{ + {ID: "feed", Step: pluginkit.StepStart, Err: errors.New("pluginkit: feed start: no route table")}, + }}, "pluginkit: pause feed: connection dropped"}, + "an activation": {storeOf(pluginkit.PluginRecord{ID: "feed", State: pluginkit.PluginPaused}), + pluginkit.StartOutcome{Started: []pluginkit.StartedPlugin{{ID: "feed"}}}, + "pluginkit: activate feed: connection dropped"}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + boot := newBoot(t, tc.store) + tc.store.writeErr = errors.New("connection dropped") + + err := boot.Started(t.Context(), tc.outcome) + + assertErrorText(t, err, tc.want) + }) + } +} + +func TestABootWhereNothingChangesWritesNothing(t *testing.T) { + t.Parallel() + + var calls []string + store := storeOf(record("events", pluginkit.PluginActive), record("feed", pluginkit.PluginActive)) + boot := newBoot(t, store) + registerAll(t, boot, entry("events", &calls, succeeds), entry("feed", &calls, succeeds)) + + err := boot.Started(t.Context(), pluginkit.StartOutcome{ + Started: []pluginkit.StartedPlugin{{ID: "events"}, {ID: "feed"}}, + }) + + assertErrorFree(t, err) + wantEqual(t, "writes", store.writes, []string(nil)) +} diff --git a/pluginkit/features/boot.feature b/pluginkit/features/boot.feature new file mode 100644 index 0000000..f27a9e7 --- /dev/null +++ b/pluginkit/features/boot.feature @@ -0,0 +1,134 @@ +Feature: Booting plugins and pausing the one that fails + An application keeps the state of each compiled plugin between boots: + not installed, inactive, active or paused. Inactive means turned off on + purpose. Paused means the boot stopped the plugin after it failed, as + WordPress's recovery mode does. Each boot reads the records once, + registers the active and paused plugins, lets the application run its + own steps, then starts them. A plugin that fails is paused with its + error, and the others still start. A paused plugin is tried again at + every boot. + + Scenario: The first boot records each compiled plugin as active + Given the compiled plugins "events" and "feed" + And the store holds no record + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "events" as active with no error + And the store records "feed" as active with no error + + Scenario: A boot where nothing changes writes nothing to the store + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store was not written + + Scenario: A plugin whose Register fails is paused and the others start + Given the compiled plugins "events", "feed" and "gallery" + And the Register of "feed" fails with "the title is too long" + When the application boots its plugins + Then "events" and "gallery" started, in that order + And the store records "feed" as paused with "pluginkit: feed register: the title is too long" + + Scenario: A plugin whose Register panics is paused + Given the compiled plugins "events" and "feed" + And the Register of "feed" panics with "boom" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed register panicked: boom" + + Scenario: A plugin whose migration fails is paused and never starts + Given the compiled plugins "events" and "feed" + And the migration of "feed" fails with "schema exploded" + When the application boots its plugins + Then "events" started + And "feed" never started + And the store records "feed" as paused with "pluginkit: feed migrate: schema exploded" + + Scenario: A plugin whose Start fails is paused + Given the compiled plugins "events" and "feed" + And the Start of "feed" fails with "no route table" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed start: no route table" + + Scenario: A plugin whose migration record fails still starts and stays active + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the migration record of "feed" fails with "ledger locked" + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "feed" as active with no error + And the store was not written + + Scenario Outline: A plugin the store keeps is never registered + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as + When the application boots its plugins + Then "events" started + And the Register of "feed" never ran + And the store records "feed" as with no error + + Examples: + | state | + | inactive | + | not installed | + + Scenario: A paused plugin that now starts is active again with no error + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as paused with "a setting is missing" + When the application boots its plugins + Then "events" and "feed" started, in that order + And the store records "feed" as active with no error + + Scenario: A paused plugin that fails again stays paused with its new error + Given the compiled plugins "events" and "feed" + And the store records "events" as active + And the store records "feed" as paused with "a setting is missing" + And the Start of "feed" fails with "no route table" + When the application boots its plugins + Then "events" started + And the store records "feed" as paused with "pluginkit: feed start: no route table" + + Scenario: The application pauses a plugin for a reason of its own + Given the compiled plugins "events" and "forum" + And the store records "events" and "forum" as active + And the application finds that "forum" declares a role it cannot accept + When the application boots its plugins + Then "events" started + And "forum" never started + And the store records "forum" as paused with "forum declares a role it cannot accept" + + Scenario: A boot whose context ends pauses nothing + Given the compiled plugins "events", "feed" and "gallery" + And the store records "events", "feed" and "gallery" as active + And the context of the boot ends while "feed" starts + When the application boots its plugins + Then the boot reports that its context ended + And the store was not written + + Scenario: A boot whose context ends while a plugin registers pauses nothing + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the context of the boot ends while "feed" registers + And the Register of "feed" fails with "the boot is shutting down" + When the application boots its plugins + Then the boot reports that its context ended + And the store was not written + + Scenario: A store that cannot read its records stops the boot + Given the compiled plugins "events" and "feed" + And the store fails to read with "connection dropped" + When the application boots its plugins + Then the boot fails with: pluginkit: read the plugin records: connection dropped + And the Register of "events" never ran + + Scenario: A store that cannot write a pause stops the boot + Given the compiled plugins "events" and "feed" + And the store records "events" and "feed" as active + And the Register of "feed" fails with "the title is too long" + And the store fails to write with "connection dropped" + When the application boots its plugins + Then the boot fails with: pluginkit: pause feed: connection dropped diff --git a/pluginkit/features/features_test.go b/pluginkit/features/features_test.go index 15a8a55..705cf88 100644 --- a/pluginkit/features/features_test.go +++ b/pluginkit/features/features_test.go @@ -45,3 +45,7 @@ func TestScreens(t *testing.T) { func TestRegistration(t *testing.T) { runFeature(t, "registration.feature", initializeRegistration(t)) } + +func TestBoot(t *testing.T) { + runFeature(t, "boot.feature", initializeBoot(t)) +} diff --git a/pluginkit/features/steps_boot_test.go b/pluginkit/features/steps_boot_test.go new file mode 100644 index 0000000..96c7400 --- /dev/null +++ b/pluginkit/features/steps_boot_test.go @@ -0,0 +1,381 @@ +// SPDX-License-Identifier: Apache-2.0 + +package features_test + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + + "github.com/cucumber/godog" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +// bootPlugin is a compiled plugin of a boot scenario, failing where the scenario says. +type bootPlugin struct { + id string + world *bootWorld +} + +// ID returns the plugin's id. +func (p *bootPlugin) ID() string { + return p.id +} + +// Migrate applies the plugin's schema, or fails as the scenario says. +func (p *bootPlugin) Migrate(context.Context) error { + return p.world.failure(p.world.migrateFails, p.id) +} + +// MigrationVersion reports the one version every scenario plugin ships. +func (p *bootPlugin) MigrationVersion() (string, error) { + return "1", nil +} + +// Start notes the call, ends the boot's context or fails as the scenario says. +func (p *bootPlugin) Start(context.Context) error { + p.world.startCalls = append(p.world.startCalls, p.id) + if p.world.endsOn["starts"] == p.id { + p.world.cancel() + } + return p.world.failure(p.world.startFails, p.id) +} + +// Stop releases nothing. +func (p *bootPlugin) Stop(context.Context) error { + return nil +} + +// bootStore is the scenario's plugin store, noting each write and failing as the scenario says. +type bootStore struct { + inner pluginkit.PluginStore + readErr error + writeErr error + writes int +} + +// Records answers the records, or the failure the scenario set. +func (s *bootStore) Records(ctx context.Context) (map[string]pluginkit.PluginRecord, error) { + if s.readErr != nil { + return nil, s.readErr + } + return s.inner.Records(ctx) +} + +// Activate notes the write and activates id, or answers the failure the scenario set. +func (s *bootStore) Activate(ctx context.Context, id string) error { + s.writes++ + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Activate(ctx, id) +} + +// Pause notes the write and pauses id, or answers the failure the scenario set. +func (s *bootStore) Pause(ctx context.Context, id, failure string) error { + s.writes++ + if s.writeErr != nil { + return s.writeErr + } + return s.inner.Pause(ctx, id, failure) +} + +// bootMigrations is the scenario's migration store, failing the record of the plugins the scenario names. +type bootMigrations struct { + world *bootWorld +} + +// Versions answers that no plugin has recorded a version yet. +func (m bootMigrations) Versions(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} + +// Record keeps the version, or fails as the scenario says. +func (m bootMigrations) Record(_ context.Context, id, _ string) error { + return m.world.failure(m.world.recordFails, id) +} + +// bootWorld is what one boot scenario builds and finds. +type bootWorld struct { + t *testing.T + compiled []string + seeds map[string]pluginkit.PluginRecord + registerFail map[string]string + registerBoom map[string]string + migrateFails map[string]string + startFails map[string]string + recordFails map[string]string + rejected map[string]bool + endsOn map[string]string + readErr error + writeErr error + cancel context.CancelFunc + store *bootStore + booted bool + registered []string + startCalls []string + started []string + err error +} + +// failure returns the error a scenario set for id in failures, or nil. +func (w *bootWorld) failure(failures map[string]string, id string) error { + if text, ok := failures[id]; ok { + return errors.New(text) + } + return nil +} + +// compile lists the plugins of list as compiled. +func (w *bootWorld) compile(list string) { + w.compiled = names(list) +} + +// noRecord leaves the store empty. +func (w *bootWorld) noRecord() {} + +// seed records each plugin of list in state before the boot. +func (w *bootWorld) seed(list, state string) { + for _, id := range names(list) { + w.seeds[id] = pluginkit.PluginRecord{ID: id, State: pluginkit.PluginState(state)} + } +} + +// pausedWith seeds the plugin id as paused with text before the boot, and checks that record after it. +func (w *bootWorld) pausedWith(id, text string) error { + want := pluginkit.PluginRecord{ID: id, State: pluginkit.PluginPaused, LastError: text} + if !w.booted { + w.seeds[id] = want + return nil + } + return w.holds(want) +} + +// set stores text under id in failures. +func set(failures map[string]string, id, text string) { + failures[id] = text +} + +// rejectRole makes the application pause the plugin id for a role it cannot accept. +func (w *bootWorld) rejectRole(id string) { + w.rejected[id] = true +} + +// readFails makes the store fail every read with text. +func (w *bootWorld) readFails(text string) { + w.readErr = errors.New(text) +} + +// writeFails makes the store fail every write with text. +func (w *bootWorld) writeFails(text string) { + w.writeErr = errors.New(text) +} + +// entries returns the registration of each compiled plugin. +func (w *bootWorld) entries() []pluginkit.Registration[string, *bootPlugin] { + entries := make([]pluginkit.Registration[string, *bootPlugin], 0, len(w.compiled)) + for _, id := range w.compiled { + entries = append(entries, pluginkit.Registration[string, *bootPlugin]{ID: id, Register: w.registerOf(id)}) + } + return entries +} + +// registerOf returns the Register of the plugin id, noting the call and failing as the scenario says. +func (w *bootWorld) registerOf(id string) func(string) (*bootPlugin, error) { + return func(string) (*bootPlugin, error) { + w.registered = append(w.registered, id) + if w.endsOn["registers"] == id { + w.cancel() + } + if value, ok := w.registerBoom[id]; ok { + panic(value) + } + if err := w.failure(w.registerFail, id); err != nil { + return nil, err + } + return &bootPlugin{id: id, world: w}, nil + } +} + +// accept pauses each registered plugin the application rejects, and answers the ones it keeps. +func (w *bootWorld) accept(ctx context.Context, boot *pluginkit.Boot, + registered []*bootPlugin) ([]pluginkit.Plugin, error) { + var kept []pluginkit.Plugin + for _, plugin := range registered { + if !w.rejected[plugin.id] { + kept = append(kept, plugin) + continue + } + if err := boot.Pause(ctx, plugin.id, fmt.Errorf("%s declares a role it cannot accept", plugin.id)); err != nil { + return nil, err + } + } + return kept, nil +} + +// bootPlugins boots the compiled plugins as an application does, noting what started and how the boot ended. +func (w *bootWorld) bootPlugins() error { + var seeds []pluginkit.PluginRecord + for _, record := range w.seeds { + seeds = append(seeds, record) + } + w.store = &bootStore{inner: pluginstoretest.NewMemory(seeds...), readErr: w.readErr, writeErr: w.writeErr} + w.booted = true + ctx, cancel := context.WithCancel(w.t.Context()) + defer cancel() + w.cancel = cancel + w.err = w.run(ctx) + return nil +} + +// run registers, accepts and starts the plugins, answering the error that stopped the boot. +func (w *bootWorld) run(ctx context.Context) error { + boot, err := pluginkit.NewBoot(ctx, w.store) + if err != nil { + return err + } + outcome, err := pluginkit.Register(ctx, boot, w.entries(), func(id string) string { return id }) + if err != nil { + return err + } + plugins, err := w.accept(ctx, boot, outcome.Registered) + if err != nil { + return err + } + host, err := pluginkit.NewHostWithMigrationStore(bootMigrations{world: w}, nil, plugins...) + if err != nil { + return err + } + started, err := host.StartEach(ctx) + if err != nil { + return err + } + for _, plugin := range started.Started { + w.started = append(w.started, plugin.ID) + } + return boot.Started(ctx, started) +} + +// settled answers the error that stopped the boot, if one did. +func (w *bootWorld) settled() error { + if w.err != nil { + return fmt.Errorf("the boot failed: %w", w.err) + } + return nil +} + +// startedInOrder checks that exactly the plugins of list started, in order. +func (w *bootWorld) startedInOrder(list string) error { + if err := w.settled(); err != nil { + return err + } + if want := names(list); !slices.Equal(w.started, want) { + return fmt.Errorf("started %q, want %q", w.started, want) + } + return nil +} + +// neverStarted checks that the Start of the plugin id was not called. +func (w *bootWorld) neverStarted(id string) error { + if slices.Contains(w.startCalls, id) { + return fmt.Errorf("the Start of %s ran", id) + } + return nil +} + +// holds checks that the store holds want. +func (w *bootWorld) holds(want pluginkit.PluginRecord) error { + records, err := w.store.inner.Records(w.t.Context()) + if err != nil { + return err + } + if got := records[want.ID]; got != want { + return fmt.Errorf("the store holds %+v, want %+v", got, want) + } + return nil +} + +// recordsClean checks that the store holds the plugin id in state with no error. +func (w *bootWorld) recordsClean(id, state string) error { + return w.holds(pluginkit.PluginRecord{ID: id, State: pluginkit.PluginState(state)}) +} + +// notWritten checks that the boot wrote nothing to the store. +func (w *bootWorld) notWritten() error { + if w.store.writes > 0 { + return fmt.Errorf("the store was written %d times, want none", w.store.writes) + } + return nil +} + +// neverRegistered checks that the Register of the plugin id was not called. +func (w *bootWorld) neverRegistered(id string) error { + if slices.Contains(w.registered, id) { + return fmt.Errorf("the Register of %s ran", id) + } + return nil +} + +// contextEnded checks that the boot reported its ended context. +func (w *bootWorld) contextEnded() error { + if !errors.Is(w.err, context.Canceled) { + return fmt.Errorf("the boot ended with %v, want its context's end", w.err) + } + return nil +} + +// failedWith checks that the boot stopped with the text. +func (w *bootWorld) failedWith(text string) error { + if w.err == nil || w.err.Error() != text { + return fmt.Errorf("the boot ended with %v, want %q", w.err, text) + } + return nil +} + +// givenBoot registers the steps that set the plugins, the records and the failures. +func givenBoot(sc *godog.ScenarioContext, w *bootWorld) { + sc.Step(`^the compiled plugins (.+)$`, w.compile) + sc.Step(`^the store holds no record$`, w.noRecord) + sc.Step(`^the store records (.+) as (active|inactive|not installed)$`, w.seed) + sc.Step(`^the store records "([^"]*)" as paused with "([^"]*)"$`, w.pausedWith) + sc.Step(`^the Register of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.registerFail, id, text) }) + sc.Step(`^the Register of "([^"]*)" panics with "([^"]*)"$`, func(id, text string) { set(w.registerBoom, id, text) }) + sc.Step(`^the migration of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.migrateFails, id, text) }) + sc.Step(`^the Start of "([^"]*)" fails with "([^"]*)"$`, func(id, text string) { set(w.startFails, id, text) }) + sc.Step(`^the migration record of "([^"]*)" fails with "([^"]*)"$`, + func(id, text string) { set(w.recordFails, id, text) }) + sc.Step(`^the application finds that "([^"]*)" declares a role it cannot accept$`, w.rejectRole) + sc.Step(`^the context of the boot ends while "([^"]*)" (starts|registers)$`, + func(id, step string) { set(w.endsOn, step, id) }) + sc.Step(`^the store fails to read with "([^"]*)"$`, w.readFails) + sc.Step(`^the store fails to write with "([^"]*)"$`, w.writeFails) +} + +// thenBoot registers the step that boots and the steps that check what the boot did. +func thenBoot(sc *godog.ScenarioContext, w *bootWorld) { + sc.Step(`^the application boots its plugins$`, w.bootPlugins) + sc.Step(`^((?:"[^"]*"(?:, | and )?)+) started(?:, in that order)?$`, w.startedInOrder) + sc.Step(`^"([^"]*)" never started$`, w.neverStarted) + sc.Step(`^the store records "([^"]*)" as (active|inactive|not installed) with no error$`, w.recordsClean) + sc.Step(`^the store was not written$`, w.notWritten) + sc.Step(`^the Register of "([^"]*)" never ran$`, w.neverRegistered) + sc.Step(`^the boot reports that its context ended$`, w.contextEnded) + sc.Step(`^the boot fails with: (.+)$`, w.failedWith) +} + +// initializeBoot gives each boot scenario a world of its own and its steps. +func initializeBoot(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { + w := &bootWorld{ + t: t, seeds: map[string]pluginkit.PluginRecord{}, registerFail: map[string]string{}, + registerBoom: map[string]string{}, migrateFails: map[string]string{}, startFails: map[string]string{}, + recordFails: map[string]string{}, rejected: map[string]bool{}, endsOn: map[string]string{}, + } + givenBoot(sc, w) + thenBoot(sc, w) + } +} diff --git a/pluginkit/pluginstore.go b/pluginkit/pluginstore.go new file mode 100644 index 0000000..17d500d --- /dev/null +++ b/pluginkit/pluginstore.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginkit + +import "context" + +// PluginState is where one compiled plugin stands for the application. +type PluginState string + +const ( + // PluginNotInstalled is a compiled plugin the application has not installed, which a boot never registers. + PluginNotInstalled PluginState = "not installed" + // PluginInactive is a plugin turned off on purpose, which a boot never registers. + PluginInactive PluginState = "inactive" + // PluginActive is a plugin a boot registers and starts. + PluginActive PluginState = "active" + // PluginPaused is a plugin a boot stopped after it failed, which every boot tries again. + PluginPaused PluginState = "paused" +) + +// PluginRecord is what an application keeps about one plugin between boots. +type PluginRecord struct { + ID string + State PluginState + // LastError is the text of the failure that paused the plugin, and empty otherwise. + LastError string +} + +// PluginStore is an application's record of each plugin's state. +type PluginStore interface { + // Records returns every record, keyed by plugin id, in one read. + Records(ctx context.Context) (map[string]PluginRecord, error) + // Activate marks the plugin id active and clears its error, adding its record when it has none. + Activate(ctx context.Context, id string) error + // Pause marks the plugin id paused with the text of its failure, adding its record when it has none. + Pause(ctx context.Context, id, failure string) error +} diff --git a/pluginkit/pluginstoretest/check_test.go b/pluginkit/pluginstoretest/check_test.go new file mode 100644 index 0000000..d1a1d4c --- /dev/null +++ b/pluginkit/pluginstoretest/check_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginstoretest + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +// recorder is a [testing.TB] that keeps each failure it is told about and goes on. +type recorder struct { + testing.TB + failures []string +} + +// Helper marks nothing. +func (r *recorder) Helper() {} + +// Errorf keeps the failure. +func (r *recorder) Errorf(format string, args ...any) { + r.failures = append(r.failures, fmt.Sprintf(format, args...)) +} + +// Fatalf keeps the failure and returns, where a real test would stop. +func (r *recorder) Fatalf(format string, args ...any) { + r.failures = append(r.failures, fmt.Sprintf(format, args...)) +} + +// unreadable is a store that cannot read its records. +type unreadable struct { + pluginkit.PluginStore +} + +// Records answers the store's failure. +func (unreadable) Records(context.Context) (map[string]pluginkit.PluginRecord, error) { + return nil, errors.New("connection dropped") +} + +func TestActReportsAWriteTheStoreRefused(t *testing.T) { + r := &recorder{} + + act(r, errors.New("connection dropped")) + + if len(r.failures) != 1 || r.failures[0] != "the store refused a write: connection dropped" { + t.Errorf("failures = %q, want the refused write", r.failures) + } +} + +func TestWantReportsAStoreThatCannotRead(t *testing.T) { + r := &recorder{} + + want(r, unreadable{}, nil) + + if len(r.failures) != 1 || r.failures[0] != "the store could not read its records: connection dropped" { + t.Errorf("failures = %q, want the failed read", r.failures) + } +} + +func TestWantReportsRecordsThatDiffer(t *testing.T) { + r := &recorder{} + store := NewMemory(pluginkit.PluginRecord{ID: "events", State: pluginkit.PluginActive}) + + want(r, store, map[string]pluginkit.PluginRecord{"events": {ID: "events", State: pluginkit.PluginPaused}}) + + if len(r.failures) != 1 { + t.Errorf("failures = %q, want one naming the records that differ", r.failures) + } +} diff --git a/pluginkit/pluginstoretest/memory_test.go b/pluginkit/pluginstoretest/memory_test.go new file mode 100644 index 0000000..bf6516a --- /dev/null +++ b/pluginkit/pluginstoretest/memory_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pluginstoretest_test + +import ( + "maps" + "testing" + + "github.com/gopherium/framework/pluginkit" + "github.com/gopherium/framework/pluginkit/pluginstoretest" +) + +func TestMemoryKeepsThePluginStoreContract(t *testing.T) { + pluginstoretest.Run(t, func(*testing.T) pluginkit.PluginStore { return pluginstoretest.NewMemory() }) +} + +func TestNewMemoryHoldsTheRecordsItIsGiven(t *testing.T) { + seeded := []pluginkit.PluginRecord{ + {ID: "events", State: pluginkit.PluginInactive}, + {ID: "feed", State: pluginkit.PluginPaused, LastError: "a setting is missing"}, + } + + got, err := pluginstoretest.NewMemory(seeded...).Records(t.Context()) + + want := map[string]pluginkit.PluginRecord{"events": seeded[0], "feed": seeded[1]} + if err != nil || !maps.Equal(got, want) { + t.Errorf("Records() = %+v, %v, want %+v", got, err, want) + } +} + +func TestMemoryRecordsAreACopyOfWhatItHolds(t *testing.T) { + store := pluginstoretest.NewMemory(pluginkit.PluginRecord{ID: "events", State: pluginkit.PluginActive}) + first, _ := store.Records(t.Context()) + + delete(first, "events") + again, _ := store.Records(t.Context()) + + if len(again) != 1 { + t.Errorf("Records() after a change to the last answer = %+v, want events still held", again) + } +} diff --git a/pluginkit/pluginstoretest/pluginstoretest.go b/pluginkit/pluginstoretest/pluginstoretest.go new file mode 100644 index 0000000..f9f1379 --- /dev/null +++ b/pluginkit/pluginstoretest/pluginstoretest.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package pluginstoretest checks that a [pluginkit.PluginStore] keeps the contract a boot relies on. +package pluginstoretest + +import ( + "context" + "maps" + "sync" + "testing" + + "github.com/gopherium/framework/pluginkit" +) + +// Run runs every case of the contract, each on a new empty store from newStore. +func Run(t *testing.T, newStore func(t *testing.T) pluginkit.PluginStore) { + t.Helper() + for name, check := range cases() { + t.Run(name, func(t *testing.T) { + check(t, newStore(t)) + }) + } +} + +// cases are the checks of the contract, by name. +func cases() map[string]func(t *testing.T, store pluginkit.PluginStore) { + return map[string]func(t *testing.T, store pluginkit.PluginStore){ + "an empty store holds no record": func(t *testing.T, store pluginkit.PluginStore) { + want(t, store, nil) + }, + "activating a plugin it never saw records it active": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "pausing a plugin it never saw records it paused": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "a setting is missing")}) + }, + "pausing an active plugin keeps its failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "a setting is missing")}) + }, + "pausing a paused plugin keeps the newer failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + act(t, store.Pause(t.Context(), "events", "no route table")) + want(t, store, map[string]pluginkit.PluginRecord{"events": paused("events", "no route table")}) + }, + "activating a paused plugin clears its failure": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "events", "a setting is missing")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "activating an active plugin keeps it active": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{"events": active("events")}) + }, + "activating a plugin keeps every other record": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Pause(t.Context(), "feed", "no route table")) + act(t, store.Activate(t.Context(), "events")) + want(t, store, map[string]pluginkit.PluginRecord{ + "events": active("events"), "feed": paused("feed", "no route table"), + }) + }, + "one read answers every plugin by its id": func(t *testing.T, store pluginkit.PluginStore) { + act(t, store.Activate(t.Context(), "events")) + act(t, store.Pause(t.Context(), "feed", "no route table")) + want(t, store, map[string]pluginkit.PluginRecord{ + "events": active("events"), "feed": paused("feed", "no route table"), + }) + }, + } +} + +// active returns the record of an active plugin id. +func active(id string) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: pluginkit.PluginActive} +} + +// paused returns the record of the plugin id paused for failure. +func paused(id, failure string) pluginkit.PluginRecord { + return pluginkit.PluginRecord{ID: id, State: pluginkit.PluginPaused, LastError: failure} +} + +// act stops t when the store refused a write. +func act(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Fatalf("the store refused a write: %v", err) + } +} + +// want checks that store holds exactly records. +func want(t testing.TB, store pluginkit.PluginStore, records map[string]pluginkit.PluginRecord) { + t.Helper() + got, err := store.Records(context.Background()) + if err != nil { + t.Fatalf("the store could not read its records: %v", err) + return + } + if !maps.Equal(got, records) { + t.Errorf("records = %+v, want %+v", got, records) + } +} + +// Memory is a [pluginkit.PluginStore] kept in memory, safe for concurrent use. +type Memory struct { + mu sync.Mutex + records map[string]pluginkit.PluginRecord +} + +// NewMemory returns a store in memory holding records. +func NewMemory(records ...pluginkit.PluginRecord) *Memory { + held := make(map[string]pluginkit.PluginRecord, len(records)) + for _, record := range records { + held[record.ID] = record + } + return &Memory{records: held} +} + +// Records returns a copy of every record held. +func (m *Memory) Records(context.Context) (map[string]pluginkit.PluginRecord, error) { + m.mu.Lock() + defer m.mu.Unlock() + return maps.Clone(m.records), nil +} + +// Activate marks the plugin id active and clears its error. +func (m *Memory) Activate(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records[id] = active(id) + return nil +} + +// Pause marks the plugin id paused with the text of its failure. +func (m *Memory) Pause(_ context.Context, id, failure string) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records[id] = paused(id, failure) + return nil +}