diff --git a/Docs/content/en/adr/030-plugin-middleware-pipeline.md b/Docs/content/en/adr/030-plugin-middleware-pipeline.md index 33ba363..d225239 100644 --- a/Docs/content/en/adr/030-plugin-middleware-pipeline.md +++ b/Docs/content/en/adr/030-plugin-middleware-pipeline.md @@ -1,4 +1,4 @@ -[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./029-structured-plugin-health-contract.md) | [Next]() +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./029-structured-plugin-health-contract.md) | [Next](./031-plugin-discovery-manifest-gate.md) # [ADR-030] Declare Plugin Middleware As A First-Class Transport-Explicit Pipeline @@ -54,4 +54,4 @@ A single implicit middleware slot cannot express ordering across plugins, per-en - [ADR-013](./013-dual-rest-and-grpc-transport.md) - dual REST and gRPC transport surfaces - [ADR-009](./009-dynamic-plugin-discovery.md) - plugin discovery and contract boundary -[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./029-structured-plugin-health-contract.md) | [Next]() +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./029-structured-plugin-health-contract.md) | [Next](./031-plugin-discovery-manifest-gate.md) diff --git a/Docs/content/en/adr/031-plugin-discovery-manifest-gate.md b/Docs/content/en/adr/031-plugin-discovery-manifest-gate.md new file mode 100644 index 0000000..8167a3f --- /dev/null +++ b/Docs/content/en/adr/031-plugin-discovery-manifest-gate.md @@ -0,0 +1,57 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./030-plugin-middleware-pipeline.md) | [Next](./032-plugin-isolation-ordering.md) + +# [ADR-031] Discover Plugins Through Manifests With A Pre-Load Compatibility Gate + +*2026-09* | Status: accepted + +**Tag:** #adr_031 + +**Date:** 2026-09-24 + +**Scope:** AuthKit.Plugins.Abstractions + Host plugin loading + +## Context + +Plugins were loaded by single static host routine that located an entry assembly, constructed the instance, and only then read metadata from the instance itself. The host could not reason about a plugin before constructing it, so a plugin requiring a newer host failed late (or crashed startup), disabled plugins were still constructed, and duplicate identities surfaced far from their cause. There was no seam for custom discovery or loading. + +## Problem + +Metadata lived only on the constructed `IAuthKitPlugin` instance, which forced the host to build the object before any compatibility decision. A single `LoadPlugins` routine mixed discovery, compatibility, construction, and validation, so none of those stages could be replaced, tested, or reasoned about in isolation. + +## Decision + +- Split the pipeline into swappable stages with explicit ownership: `IPluginDiscoverer` finds candidates and reads manifests (never activates), `IPluginLoader` loads assemblies and constructs instances (never judges compatibility), and the host pipeline owns validation, gating, consistency, and activation around them. +- The discoverer reads `plugin.json` / `plugin.manifest` / `manifest.json` from disk without loading assemblies, yielding `DiscoveredPlugin` (manifest + opaque location + discovery error). Order per stage: discovery errors → structural validation → duplicate Id rejection → compatibility gate → load → manifest/instance consistency → contract validation. +- The compatibility gate runs pre-load on the manifest: `IsEnabled == false` skips quietly, `HostVersion < MinHostVersion` hard-rejects (no warn-only — a too-new plugin risks `MissingMethodException` / `TypeLoadException`). Undefined `MinHostVersion` means unaffected. +- Manifest/instance consistency (`Id`, `Name`, `Version`, `IsEnabled`, set-equal `Capabilities`, `MinHostVersion`, `DependsOn`) is a hard failure. Duplicate manifest Ids are rejected deterministically (first by location wins). +- Outcomes are captured in the host-internal `PluginLoadResult` (loaded / skipped-disabled / rejected / invalid with reasons) for startup logs and diagnostics; it is deliberately not part of the plugin contract. +- A manifest is required: directories without a readable manifest are invalid and never load. There is no legacy fallback. `PluginManifest` never inherits `IAuthKitPlugin` — shared semantics, separate models. + +### Design Rationale + +- Reading metadata before loading moves failures (bad manifest, duplicate Id, too-new plugin, disabled plugin) ahead of assembly loading, where they are cheap and diagnosable. +- Opaque `Location` keeps the contract source-agnostic (directory today, feed or package tomorrow) without leaking loader details into discovery. +- Per-candidate loader attribution makes every outcome explainable in `PluginLoadResult` instead of failing the whole batch or crashing startup (the previous behavior on contract violation). +- The loader stays dumb on purpose: compatibility policy lives in exactly one place (the gate + pipeline), so custom loaders cannot silently change acceptance rules. + +## Rejected + +- Keeping the monolithic static loader: no seam for custom discovery/loading, untestable stages, late failures. +- Warn-only compatibility mode: loading a plugin built for a newer host corrupts behavior instead of degrading gracefully; policy engines (`Strict`/`Warn`/`Ignore`) belong to a future host feature, not the basic gate. +- Max-host-version ceiling and per-feature capability negotiation now: reserved for future gate rules. +- Coupling manifest to instance via inheritance: binds the pre-activation model to runtime construction, defeating the purpose of the gate. + +## Consequences + +- Every plugin solution must ship its `manifest.json` (committed like Shield and Example, or generated at build like DevTokens and DevTools via `AuthKit.ManifestGenerator`, which the Dockerfile runs after publish) — without it the plugin is rejected at startup. +- Graph problems split by severity: structural issues (self/duplicate/invalid dependency entries, duplicate Ids) reject only the offending plugin as `Invalid`; hard startup failure is reserved for unorderable graphs (unknown dependency, cycle). +- `SemanticVersion` (SemVer 2.0.0, build metadata ignored for precedence) is the only version comparison; `System.Version` must never be used. +- Future gate rules (capabilities, platform, max version) plug into `CompatibilityGate` without touching discovery or loading. + +## Related + +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin discovery and contract boundary +- [ADR-010](./010-plugin-loading-from-directory.md) - plugin loading and legacy middleware slot +- [ADR-028](./028-plugin-contract-and-dynamic-loading-architecture.md) - plugin contract and dynamic loading architecture + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./030-plugin-middleware-pipeline.md) | [Next](./032-plugin-isolation-ordering.md) diff --git a/Docs/content/en/adr/032-plugin-isolation-ordering.md b/Docs/content/en/adr/032-plugin-isolation-ordering.md new file mode 100644 index 0000000..d305b62 --- /dev/null +++ b/Docs/content/en/adr/032-plugin-isolation-ordering.md @@ -0,0 +1,56 @@ +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./031-plugin-discovery-manifest-gate.md) | [Next]() + +# [ADR-032] Isolate Plugins In Per-Plugin Load Contexts With Deterministic Ordering + +*2026-09* | Status: accepted + +**Tag:** #adr_032 + +**Date:** 2026-09-29 + +**Scope:** Host plugin loading (isolation, ordering, discovery cache) + +## Context + +All plugins loaded into the default `AssemblyLoadContext`, so conflicting transitive dependencies collapsed into one universe: whichever copy won broke the others with `TypeLoadException` or silent wrong-version binds. Load order followed discovery order, so dependencies routinely lost to dependents. Every restart paid full rediscovery. + +## Problem + +One shared context cannot host conflicting dependency versions, and filesystem order is not a loading order. Without isolation, adding any plugin risks every other plugin; without ordering, dependency edges are luck; without caching, startup redoes all discovery work including assembly probing. + +## Decision + +- Each plugin loads into its own collectible `AssemblyLoadContext` (`PluginLoadContext`), attached to the contract `LoadedPlugin` — never to `DiscoveredPlugin`. Collectible enables future unload orchestration; the loader itself never unloads. +- Sharing wins by rule, in order: explicit shared contracts (`AuthKit.Plugins.Abstractions`, `Grpc.Core.Api`, `Google.Protobuf`), anything already loaded by default, anything shipped in the host application directory. Everything else resolves privately from the plugin directory. A plugin therefore always sees the host's `Core` types (DI identity holds) and never its own `Interceptor` copy. +- Accepted plugins load in Kahn topological order: dependencies first, ties by `Priority` ascending then registration order, never re-sorted afterward. Unknown dependency ids and cycles are startup errors; dependents of unavailable plugins are rejected as dependency-unavailable with propagation. +- Discovery results cache in a host-local file (`PluginManifest` + location + source fingerprint + schema version; never load contexts, types, or instances). Stale or corrupt cache is a miss, never a failure. +- The compatibility gate reads the effective enabled flag (manifest `IsEnabled` anded with host config, which may disable but never re-enable) before ordering, so disabled plugins are never ordered, isolated, or loaded. + +### Design Rationale + +- Returning null (fall back to default) for shared assemblies keeps one type universe for contracts while private universes diverge per plugin — the exact property DI and `is` checks need. +- The host-directory rule (not just already-loaded) closes the startup-ordering hole: loading runs before first gRPC/DI use, so an explicit list alone would still duplicate copies on a cold host. +- Kahn with priority-then-registration is deterministic for a given discovered set and reviewable in logs; discovery order alone stays the final tiebreak, never the strategy. +- Cache stores fingerprints, not results trust: any input change (manifest or dll bytes) invalidates, so stale entries cannot load. + +## Rejected + +- Single shared context with version unification: forces one dependency version on all plugins and the host — the original problem. +- Copying host assemblies per plugin directory: duplicates contract types per ALC and breaks DI identity silently. +- Ordering by discovery order or full re-sort by priority afterward: the former is nondeterministic, the latter breaks dependency edges. +- Distributed or in-memory-only cache: host-local file survives restarts with zero infrastructure; caching activated instances is explicitly out. + +## Consequences + +- Plugin solutions must not rely on privately loading assemblies the host ships; host-owned wins by rule and the plugin gets the host copy. +- Every plugin directory still needs its manifest (ADR-031); ordering and validation keys off manifest identity. +- `PluginLoadResult` distinguishes terminal outcomes (loaded / skipped-disabled / rejected / invalid) so each candidate is explainable. +- Future work (unload orchestration, max-version ceiling, capability rules) plugs into `PluginLoadContext`, `CompatibilityGate`, or `DependencyGraph` without touching discovery. + +## Related + +- [ADR-031](./031-plugin-discovery-manifest-gate.md) - manifest discovery with pre-load compatibility gate +- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin discovery and contract boundary +- [ADR-028](./028-plugin-contract-and-dynamic-loading-architecture.md) - plugin contract and dynamic loading architecture + +[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./031-plugin-discovery-manifest-gate.md) | [Next]() diff --git a/Docs/content/en/adr/README.md b/Docs/content/en/adr/README.md index 8d17702..24ba61b 100644 --- a/Docs/content/en/adr/README.md +++ b/Docs/content/en/adr/README.md @@ -80,6 +80,8 @@ The table below shows the architecture areas and their current scope. | [ADR-028](./028-plugin-contract-and-dynamic-loading-architecture.md) | Define The Plugin Contract And Dynamic Loading Architecture | Plugins | accepted | 2026-09-11 | | [ADR-029](./029-structured-plugin-health-contract.md) | Expose Structured And Cancellable Plugin Health Results | Plugins | accepted | 2026-09-13 | | [ADR-030](./030-plugin-middleware-pipeline.md) | Declare Plugin Middleware As A First-Class Transport-Explicit Pipeline | Plugins | accepted | 2026-09-24 | +| [ADR-031](./031-plugin-discovery-manifest-gate.md) | Discover Plugins Through Manifests With A Pre-Load Compatibility Gate | Plugins | accepted | 2026-09-24 | +| [ADR-032](./032-plugin-isolation-ordering.md) | Isolate Plugins In Per-Plugin Load Contexts With Deterministic Ordering | Plugins | accepted | 2026-09-29 | ## Relationships Between Areas diff --git a/Docs/content/pl/adr/030-plugin-middleware-pipeline.md b/Docs/content/pl/adr/030-plugin-middleware-pipeline.md index 9729e9b..49b92da 100644 --- a/Docs/content/pl/adr/030-plugin-middleware-pipeline.md +++ b/Docs/content/pl/adr/030-plugin-middleware-pipeline.md @@ -1,4 +1,4 @@ -[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/029-structured-plugin-health-contract/) | [Następny]() +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/029-structured-plugin-health-contract/) | [Następny](/pl/adr/031-plugin-discovery-manifest-gate/) # [ADR-030] Deklaratywny Pipeline Middleware Pluginów Z Jawnym Transportem @@ -54,4 +54,4 @@ Pojedynczy implicit slot nie wyraża kolejności między pluginami, włączania - [ADR-013](/pl/adr/013-dual-rest-and-grpc-transport/) - dualny transport REST i gRPC - [ADR-009](/pl/adr/009-dynamic-plugin-discovery/) - discovery pluginów i granica kontraktu -[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/029-structured-plugin-health-contract/) | [Następny]() +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/029-structured-plugin-health-contract/) | [Następny](/pl/adr/031-plugin-discovery-manifest-gate/) diff --git a/Docs/content/pl/adr/031-plugin-discovery-manifest-gate.md b/Docs/content/pl/adr/031-plugin-discovery-manifest-gate.md new file mode 100644 index 0000000..2fa7189 --- /dev/null +++ b/Docs/content/pl/adr/031-plugin-discovery-manifest-gate.md @@ -0,0 +1,57 @@ +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/030-plugin-middleware-pipeline/) | [Następny](/pl/adr/032-plugin-isolation-ordering/) + +# [ADR-031] Odkrywanie Pluginów Przez Manifesty Z Bramką Kompatybilności Przed Ładowaniem + +*2026-09* | Status: accepted + +**Tag:** #adr_031 + +**Date:** 2026-09-24 + +**Scope:** AuthKit.Plugins.Abstractions + ładowanie pluginów hosta + +## Context + +Pluginy ładowała jedna statyczna procedura hosta: znajdowała assembly wejściowe, konstruowała instancję i dopiero potem czytała metadane z samej instancji. Host nie mógł rozumować o pluginie przed jego konstrukcją, więc plugin wymagający nowszego hosta padał późno (albo kładł start), wyłączone pluginy i tak były konstruowane, a zduplikowane tożsamości wychodziły daleko od przyczyny. Nie było szwu na własne discovery ani ładowanie. + +## Problem + +Metadane żyły tylko na skonstruowanej instancji `IAuthKitPlugin`, co zmuszało hosta do zbudowania obiektu przed jakąkolwiek decyzją o kompatybilności. Jedna procedura `LoadPlugins` mieszała discovery, kompatybilność, konstrukcję i walidację, więc żaden z tych etapów nie dał się wymienić, testować ani analizować w izolacji. + +## Decision + +- Podział pipeline na wymienne etapy z jawną własnością: `IPluginDiscoverer` znajduje kandydatów i czyta manifesty (nigdy nie aktywuje), `IPluginLoader` ładuje assembly i konstruuje instancje (nigdy nie ocenia kompatybilności), a pipeline hosta odpowiada za walidację, bramkę, spójność i aktywację wokół nich. +- Discoverer czyta `plugin.json` / `plugin.manifest` / `manifest.json` z dysku bez ładowania assembly, zwracając `DiscoveredPlugin` (manifest + nieprzezroczysta lokalizacja + błąd discovery). Kolejność etapów: błędy discovery → walidacja strukturalna → odrzucenie duplikatów Id → bramka kompatybilności → ładowanie → spójność manifest/instancja → walidacja kontraktu. +- Bramka kompatybilności działa przed ładowaniem na manifeście: `IsEnabled == false` cicho pomija, `HostVersion < MinHostVersion` twardo odrzuca (bez trybu warn-only — za nowy plugin grozi `MissingMethodException` / `TypeLoadException`). Brak `MinHostVersion` znaczy brak wpływu. +- Spójność manifest/instancja (`Id`, `Name`, `Version`, `IsEnabled`, równe zbiory `Capabilities`, `MinHostVersion`, `DependsOn`) to twardy błąd. Zduplikowane Id manifestów odrzucane deterministycznie (pierwszy po lokalizacji wygrywa). +- Wyniki zbiera hostowy `PluginLoadResult` (załadowane / pominięte-wyłączone / odrzucone / niepoprawne, z powodami) do logów startowych i diagnostyki; celowo nie jest częścią kontraktu pluginu. +- Manifest jest wymagany: katalogi bez czytelnego manifestu są niepoprawne i nigdy się nie ładują. Nie ma ścieżki legacy. `PluginManifest` nigdy nie dziedziczy po `IAuthKitPlugin` — wspólna semantyka, osobne modele. + +### Design Rationale + +- Czytanie metadanych przed ładowaniem przesuwa błędy (zły manifest, duplikat Id, za nowy plugin, wyłączony plugin) przed ładowanie assembly — tam są tanie i diagnozowalne. +- Nieprzezroczysta `Location` uniezależnia kontrakt od źródła (dziś katalog, jutro feed albo pakiet) bez przeciekania detali loadera do discovery. +- Atrybucja per kandydat sprawia, że każdy wynik da się wyjaśnić w `PluginLoadResult`, zamiast kłaść cały batch albo crashować start (poprzednie zachowanie przy naruszeniu kontraktu). +- Loader jest celowo głupi: polityka kompatybilności żyje w dokładnie jednym miejscu (bramka + pipeline), więc własne loadery nie zmienią po cichu reguł akceptacji. + +## Rejected + +- Monolityczny statyczny loader: brak szwu na własne discovery/ładowanie, nietestowalne etapy, późne błędy. +- Tryb warn-only: ładowanie pluginu pod nowszego hosta psuje zachowanie zamiast graceful degradation; silniki polityk (`Strict`/`Warn`/`Ignore`) to przyszła funkcja hosta, nie podstawowa bramka. +- Sufit max-host-version i negocjacja capabilities teraz: zarezerwowane na przyszłe reguły bramki. +- Sprzężenie manifestu z instancją przez dziedziczenie: wiąże model pre-aktywacyjny z konstrukcją runtime, niwecząc sens bramki. + +## Consequences + +- Każde rozwiązanie pluginu musi dostarczać swój `manifest.json` (commitowany jak Shield i Example albo generowany przy buildzie jak DevTokens i DevTools przez `AuthKit.ManifestGenerator`, który Dockerfile odpala po publikacji) — bez niego plugin odpada na starcie. +- Problemy grafu dzielą się po wadze: strukturalne (self/duplikaty/złe wpisy zależności, duplikaty Id) odrzucają tylko winny plugin jako `Invalid`; twardy błąd startu rezerwujemy dla grafów nieuporządkowalnych (nieznana zależność, cykl). +- `SemanticVersion` (SemVer 2.0.0, build metadata ignorowane w precedencji) to jedyne porównywanie wersji; `System.Version` nigdy. +- Przyszłe reguły bramki (capabilities, platforma, max version) wpina się w `CompatibilityGate` bez ruszania discovery ani ładowania. + +## Powiązane + +- [ADR-009](/pl/adr/009-dynamic-plugin-discovery/) - discovery pluginów i granica kontraktu +- [ADR-010](/pl/adr/010-plugin-loading-from-directory/) - ładowanie pluginów i legacy slot middleware +- [ADR-028](/pl/adr/028-plugin-contract-and-dynamic-loading-architecture/) - kontrakt pluginu i architektura dynamicznego ładowania + +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/030-plugin-middleware-pipeline/) | [Następny](/pl/adr/032-plugin-isolation-ordering/) diff --git a/Docs/content/pl/adr/032-plugin-isolation-ordering.md b/Docs/content/pl/adr/032-plugin-isolation-ordering.md new file mode 100644 index 0000000..b3dfb79 --- /dev/null +++ b/Docs/content/pl/adr/032-plugin-isolation-ordering.md @@ -0,0 +1,56 @@ +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/031-plugin-discovery-manifest-gate/) | [Następny]() + +# [ADR-032] Izolacja Pluginów We Własnych Kontekstach Ładowania Z Deterministyczną Kolejnością + +*2026-09* | Status: accepted + +**Tag:** #adr_032 + +**Date:** 2026-09-29 + +**Scope:** Ładowanie pluginów hosta (izolacja, kolejność, cache discovery) + +## Context + +Wszystkie pluginy lądowały w domyślnym `AssemblyLoadContext`, więc konfliktujące zależności przechodnie zwijały się do jednego uniwersum: która kopia wygrała, reszta padała z `TypeLoadException` albo cichym zbindowaniem złej wersji. Kolejność ładowania szła za kolejnością discovery, więc zależności rutynowo przegrywały z dependentami. Każdy restart płacił pełne rediscovery. + +## Problem + +Jeden współdzielony kontekst nie pomieści konfliktujących wersji zależności, a kolejność plików to nie kolejność ładowania. Bez izolacji każdy nowy plugin ryzykuje wszystkie pozostałe; bez kolejności krawędzie zależności to loteria; bez cache start powtarza całą pracę discovery z sondowaniem assembly włącznie. + +## Decision + +- Każdy plugin ładuje się do własnego kolekcjonowalnego `AssemblyLoadContext` (`PluginLoadContext`), wpiętego w kontraktowy `LoadedPlugin` — nigdy w `DiscoveredPlugin`. Kolekcjonowalność pod przyszły unload; sam loader nigdy nie zwalnia. +- Współdzielenie wygrywa regułą, po kolei: jawne współdzielone kontrakty (`AuthKit.Plugins.Abstractions`, `Grpc.Core.Api`, `Google.Protobuf`), wszystko już załadowane przez default, wszystko dostarczone z katalogiem hosta. Reszta idzie prywatnie z katalogu pluginu. Dzięki temu plugin zawsze widzi hostowe typy `Core` (tożsamość w DI trzyma) i nigdy własnej kopii `Interceptor`. +- Zaakceptowane pluginy ładują się w kolejności topologicznej Kahna: najpierw zależności, remisy po `Priority` rosnąco, potem po kolejności rejestracji, bez dosortowywania na końcu. Nieznane Id zależności i cykle to błąd startu; dependenci niedostępnych odpadają jako dependency-unavailable z propagacją. +- Wyniki discovery cache'ujemy w hostowym pliku (manifest + lokalizacja + fingerprint źródeł + wersja schematu; nigdy konteksty, typy ani instancje). Nieświeży lub zepsuty cache to miss, nigdy błąd. +- Bramka kompatybilności czyta efektywna flagę włączenia (manifest `IsEnabled` AND z configiem hosta, który może wyłączyć, ale nigdy włączyć) przed kolejkowaniem, więc wyłączone pluginy nigdy nie są szeregowane, izolowane ani ładowane. + +### Design Rationale + +- Zwracanie null (fallback do default) dla współdzielonych assembly trzyma jedno uniwersum typów dla kontraktów, a prywatne uniwersa rozjeżdżają się per plugin — dokładnie ta własność, której potrzebują DI i `is`. +- Reguła katalogu hosta (nie tylko już-załadowane) zamyka dziurę kolejności startu: ładowanie leci przed pierwszym użyciem gRPC/DI, więc sama jawna lista i tak duplikowałaby kopie na zimnym hoście. +- Kahn z priorytetem i rejestracją jest deterministyczny dla danego zbioru i czytelny w logach; kolejność discovery zostaje ostatecznym tiebreakiem, nigdy strategią. +- Cache trzyma fingerprinty, nie zaufanie do wyników: każda zmiana inputu (manifest albo bajty dll) unieważnia, więc przeterminowane wpisy nie ładują. + +## Rejected + +- Jeden współdzielony kontekst z unifikacją wersji: wymusza jedną wersję zależności na wszystkich pluginach i hoście — czyli oryginalny problem. +- Kopiowanie hostowych assembly per katalog pluginu: duplikuje typy kontraktów per ALC i cicho łamie tożsamość w DI. +- Szeregowanie po discovery albo pełne dosortowanie po priorytecie na końcu: pierwsze jest niedeterministyczne, drugie łamie krawędzie zależności. +- Rozproszony albo tylko in-memory cache: hostowy plik przeżywa restarty bez infrastruktury; cache'owanie aktywowanych instancji jawnie poza zakresem. + +## Consequences + +- Solucje pluginów nie mogą liczyć na prywatne ładowanie assembly dostarczanych przez hosta; hostowe wygrywa regułą i plugin dostaje kopię hosta. +- Każdy katalog pluginu dalej wymaga manifestu (ADR-031); tożsamość manifestu kluczem sortowania i walidacji. +- `PluginLoadResult` rozróżnia wyniki terminalne (loaded / skipped-disabled / rejected / invalid), więc każdy kandydat jest wyjaśnialny. +- Przyszła praca (orkiestracja unloadu, sufit wersji, reguły capabilities) wpina się w `PluginLoadContext`, `CompatibilityGate` albo `DependencyGraph` bez ruszania discovery. + +## Powiązane + +- [ADR-031](/pl/adr/031-plugin-discovery-manifest-gate/) - discovery manifestów z bramką kompatybilności przed ładowaniem +- [ADR-009](/pl/adr/009-dynamic-plugin-discovery/) - discovery pluginów i granica kontraktu +- [ADR-028](/pl/adr/028-plugin-contract-and-dynamic-loading-architecture/) - kontrakt pluginu i architektura dynamicznego ładowania + +[/pl/](/pl/) | [Indeks kategorii](/pl/adr/) | [Poprzedni](/pl/adr/031-plugin-discovery-manifest-gate/) | [Następny]() diff --git a/Docs/content/pl/adr/README.md b/Docs/content/pl/adr/README.md index 9682ce6..8e43c96 100644 --- a/Docs/content/pl/adr/README.md +++ b/Docs/content/pl/adr/README.md @@ -80,6 +80,8 @@ Poniższa tabela przedstawia obszary architektury i ich bieżący zakres. | [ADR-028](/pl/adr/028-plugin-contract-and-dynamic-loading-architecture/) | Zdefiniuj kontrakt wtyczek i architekturę dynamicznego ładowania | Plugins | accepted | 2026-09-11 | | [ADR-029](/pl/adr/029-structured-plugin-health-contract/) | Eksponowanie ustrukturyzowanych i anulowalnych wyników kondycji wtyczek | Plugins | accepted | 2026-09-13 | | [ADR-030](/pl/adr/030-plugin-middleware-pipeline/) | Deklaratywny Pipeline Middleware Pluginów Z Jawnym Transportem | Plugins | accepted | 2026-09-24 | +| [ADR-031](/pl/adr/031-plugin-discovery-manifest-gate/) | Odkrywanie Pluginów Przez Manifesty Z Bramką Kompatybilności Przed Ładowaniem | Plugins | accepted | 2026-09-24 | +| [ADR-032](/pl/adr/032-plugin-isolation-ordering/) | Izolacja Pluginów We Własnych Kontekstach Ładowania Z Deterministyczną Kolejnością | Plugins | accepted | 2026-09-29 | ## Relacje pomiędzy obszarami diff --git a/src/Host/Plugins/Configuration/PluginConfigurationInvoker.cs b/src/Host/Plugins/Configuration/PluginConfigurationInvoker.cs index 24f0b47..553be72 100644 --- a/src/Host/Plugins/Configuration/PluginConfigurationInvoker.cs +++ b/src/Host/Plugins/Configuration/PluginConfigurationInvoker.cs @@ -9,16 +9,10 @@ namespace Host.Plugins.Configuration; /// Selects and invokes one compatible plugin configuration overload. /// /// -/// -/// Plugins may implement any single supported ConfigureServices overload. -/// The invoker picks the most specific overload implemented by the plugin instead -/// of requiring all plugins to adopt a single signature. -/// -/// -/// The default interface implementations of IAuthKitPlugin.ConfigureServices -/// forward to one another, so only the most specific overload actually overridden -/// by the plugin is invoked. -/// +/// Plugins implement the service collection overload taking +/// , optionally the host builder overload +/// instead. The invoker picks the most specific overload implemented by the +/// plugin. Plugins implementing neither fail fast. /// internal static class PluginConfigurationInvoker { @@ -28,6 +22,9 @@ internal static class PluginConfigurationInvoker /// The plugin being configured. /// The host builder used by AuthKit. /// The application configuration. + /// + /// The plugin implements no supported ConfigureServices overload. + /// public static void Configure( IAuthKitPlugin plugin, IHostApplicationBuilder builder, @@ -53,7 +50,9 @@ public static void Configure( return; } - plugin.ConfigureServices(builder.Services, configuration); + throw new InvalidOperationException( + $"Plugin '{plugin.Id}' implements no supported ConfigureServices overload. " + + "Implement ConfigureServices(IServiceCollection, AuthKitPluginContext)."); } /// @@ -76,4 +75,4 @@ private static bool HasImplementation(Type pluginType, params Type[] parameterTy return method is not null && method.DeclaringType != typeof(IAuthKitPlugin); } -} \ No newline at end of file +} diff --git a/src/Host/Plugins/Loading/DefaultPluginLoader.cs b/src/Host/Plugins/Loading/DefaultPluginLoader.cs new file mode 100644 index 0000000..bd81eb5 --- /dev/null +++ b/src/Host/Plugins/Loading/DefaultPluginLoader.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using ContractLoadedPlugin = AuthKit.Plugins.Abstractions.Contracts.Discovery.LoadedPlugin; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace Host.Plugins.Loading; + +/// +/// Default loader: resolves each discovered location to an entry assembly, +/// loads it into an isolated , and constructs +/// the plugin instance. Loads and constructs only — no compatibility decisions, +/// no validation, no activation. +/// +/// +/// +/// Each plugin gets its own collectible load context. Host and framework +/// assemblies already loaded by default stay shared; only plugin-private +/// dependencies resolve from the plugin directory, so conflicting transitive +/// versions cannot collide. +/// +/// +/// Each plugin directory contributes at most one candidate: the entry assembly +/// whose file name matches the directory name. Anything else is logged and +/// skipped so the pipeline can report it as invalid. +/// +/// +/// The logger used to report load results. +public sealed class DefaultPluginLoader(ILogger logger) : IPluginLoader +{ + /// + /// Loads assemblies and constructs one instance per discovered candidate. + /// + /// Discovered candidates, in pipeline order. + /// Stops the load loop between candidates. + /// Constructed plugins, in input order. Candidates that fail to load are skipped. + /// + /// Loads and constructs only: no compatibility decisions, no validation, + /// no activation. Failures are logged with their location and skipped, so + /// the pipeline can report them as invalid. + /// + public Task> LoadAsync( + IReadOnlyList plugins, + CancellationToken cancellationToken = default) + { + var loaded = new List(); + + foreach (var discovered in plugins) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = TryLoad(discovered); + if (result is not null) + loaded.Add(result); + } + + return Task.FromResult>(loaded); + } + + private ContractLoadedPlugin? TryLoad(DiscoveredPlugin discovered) + { + if (discovered.Manifest is null) + { + logger.LogError( + "Skipping plugin '{Location}': candidate has no manifest.", + discovered.Location); + return null; + } + + var pluginDir = discovered.Location; + var pluginName = Path.GetFileName(pluginDir); + var entryDllPath = Path.Join(pluginDir, $"{pluginName}.dll"); + + if (!File.Exists(entryDllPath)) + { + logger.LogError( + "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.", + pluginDir, entryDllPath); + return null; + } + + try + { + var context = new PluginLoadContext(entryDllPath); + var assembly = context.LoadEntry(); + + var pluginType = assembly.GetTypes() + .FirstOrDefault(t => t is { IsPublic: true, IsAbstract: false } + && typeof(IAuthKitPlugin).IsAssignableFrom(t) + && t.GetConstructor(Type.EmptyTypes) is not null); + + if (pluginType is null) + { + logger.LogError( + "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.", + entryDllPath); + return null; + } + + var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; + + return new ContractLoadedPlugin + { + Manifest = discovered.Manifest, + PluginType = pluginType, + Instance = plugin, + LoadContext = context, + }; + } + catch (Exception ex) when (ex is FileNotFoundException + or FileLoadException + or BadImageFormatException + or ReflectionTypeLoadException + or TypeLoadException + or MissingMethodException + or TargetInvocationException + or InvalidCastException) + { + logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); + return null; + } + } +} diff --git a/src/Host/Plugins/Loading/DependencyGraph.cs b/src/Host/Plugins/Loading/DependencyGraph.cs new file mode 100644 index 0000000..2fe2464 --- /dev/null +++ b/src/Host/Plugins/Loading/DependencyGraph.cs @@ -0,0 +1,182 @@ +using AuthKit.Plugins.Abstractions.Contracts.Discovery; + +namespace Host.Plugins.Loading; + +/// +/// Dependency graph over accepted candidates: validation and deterministic +/// topological ordering. +/// +/// +/// +/// Ordering is Kahn's algorithm: at each step, among the currently +/// dependency-ready plugins, the next is picked by Priority ascending, +/// then by stable registration (discovery) order ascending. The result is never +/// re-sorted afterward, so dependency edges always hold. +/// +/// +internal static class DependencyGraph +{ + /// + /// Validates the dependency graph of accepted candidates. + /// + /// Gate-accepted candidates with their registration index. + /// All discovered ids (accepted or not). + /// + /// A dependency id is entirely unknown, or the accepted graph contains a cycle. + /// + /// + /// A candidate has no manifest. + /// + public static void Validate( + IReadOnlyList<(DiscoveredPlugin Candidate, int RegistrationOrder)> accepted, + IReadOnlySet knownIds) + { + var nodes = Nodes(accepted); + + foreach (var node in nodes) + { + foreach (var dependency in node.DependsOn) + { + if (!knownIds.Contains(dependency)) + throw new InvalidOperationException( + $"Plugin '{node.Id}' depends on unknown plugin '{dependency}'."); + } + } + + if (FindCycle(nodes) is { } cycle) + throw new InvalidOperationException( + $"Plugin dependency cycle detected: {string.Join(" -> ", cycle)}."); + } + + /// + /// Topologically sorts accepted candidates: dependencies first, ties broken + /// by priority, then registration order. + /// + /// Gate-accepted candidates with their registration index. + /// Candidates in load order. + /// + /// A candidate has no manifest. + /// + public static IReadOnlyList Sort( + IReadOnlyList<(DiscoveredPlugin Candidate, int RegistrationOrder)> accepted) + { + var nodes = Nodes(accepted); + var byId = nodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase); + var pending = nodes.ToDictionary( + node => node.Id, + node => node.DependsOn.Length, + StringComparer.OrdinalIgnoreCase); + + var ready = nodes + .Where(node => pending[node.Id] == 0) + .OrderBy(node => node.Priority) + .ThenBy(node => node.RegistrationOrder) + .Select(node => node.Id) + .ToList(); + + var order = new List(); + while (ready.Count > 0) + { + var id = ready[0]; + ready.RemoveAt(0); + order.Add(byId[id].Candidate); + + foreach (var node in nodes) + { + if (!node.DependsOn.Contains(id)) + continue; + + pending[node.Id]--; + if (pending[node.Id] == 0) + InsertReady(ready, byId, node); + } + } + + return order; + } + + private static void InsertReady( + List ready, + Dictionary byId, + GraphNode node) + { + var index = ready.FindIndex(existing => + byId[existing].Priority > node.Priority || + (byId[existing].Priority == node.Priority && + byId[existing].RegistrationOrder > node.RegistrationOrder)); + + if (index < 0) + ready.Add(node.Id); + else + ready.Insert(index, node.Id); + } + + private static IReadOnlyList? FindCycle(IReadOnlyList nodes) + { + var ids = nodes.Select(node => node.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var byId = nodes.ToDictionary(node => node.Id, StringComparer.OrdinalIgnoreCase); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var stack = new Stack(); + + foreach (var node in nodes) + { + if (Dfs(node.Id, byId, ids, visited, stack) is { } cycle) + return cycle; + } + + return null; + } + + private static IReadOnlyList? Dfs( + string id, + Dictionary byId, + HashSet ids, + HashSet visited, + Stack stack) + { + if (stack.Contains(id, StringComparer.OrdinalIgnoreCase)) + return stack.Reverse().Append(id).ToList(); + + if (!visited.Add(id)) + return null; + + stack.Push(id); + try + { + foreach (var dependency in byId[id].DependsOn) + { + if (!ids.Contains(dependency)) + continue; + + if (Dfs(dependency, byId, ids, visited, stack) is { } cycle) + return cycle; + } + + return null; + } + finally + { + stack.Pop(); + } + } + + private static IReadOnlyList Nodes( + IReadOnlyList<(DiscoveredPlugin Candidate, int RegistrationOrder)> accepted) => + accepted.Select(entry => entry.Candidate.Manifest is { } manifest + ? new GraphNode( + entry.Candidate, + manifest.Id, + manifest.Priority, + manifest.DependsOn.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + entry.RegistrationOrder) + : throw new ArgumentException( + $"Candidate at '{entry.Candidate.Location}' has no manifest.", + nameof(accepted))).ToList(); + + private sealed record GraphNode( + DiscoveredPlugin Candidate, + string Id, + int Priority, + string[] DependsOn, + int RegistrationOrder); +} diff --git a/src/Host/Plugins/Loading/DirectoryPluginDiscoverer.cs b/src/Host/Plugins/Loading/DirectoryPluginDiscoverer.cs new file mode 100644 index 0000000..a107c38 --- /dev/null +++ b/src/Host/Plugins/Loading/DirectoryPluginDiscoverer.cs @@ -0,0 +1,74 @@ +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using Host.Plugins.Loading.Manifest; + +namespace Host.Plugins.Loading; + +/// +/// Default discoverer one subdirectory per plugin under a root path. +/// Reads each manifest from the disk without loading any assembly, so the +/// compatibility gate can run first. Directories are never executed here. +/// +/// +/// +/// Candidates stream in ordinal directory order, including invalid ones: +/// unreadable manifests surface as candidates with DiscoveryError set +/// instead of throwing, so the pipeline can report every directory. +/// +/// +/// A missing root path is not an error discovery yields nothing, and the host +/// starts with zero plugins. +/// +/// +/// The root directory containing one subdirectory per plugin. +/// The logger used to report discovery results. +public sealed class DirectoryPluginDiscoverer( + string pluginsRootPath, + ILogger logger) : IPluginDiscoverer +{ + /// + /// Streams one candidate per plugin directory, manifest included. + /// + /// Stops discovery between directories. + /// Candidates in ordinal directory order, including invalid ones. + /// + /// Never throws for a single bad directory: unreadable manifests surface as + /// candidates with DiscoveryError set, so the pipeline can report them. + /// + public async IAsyncEnumerable DiscoverAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (!Directory.Exists(pluginsRootPath)) + { + logger.LogWarning("Plugins path '{Path}' does not exist — starting with zero plugins.", pluginsRootPath); + yield break; + } + + foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath).Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!PluginManifestReader.TryRead(pluginDir, out var manifest, out var error)) + { + logger.LogWarning("Discovered plugin at '{Location}' with unreadable manifest: {Error}", pluginDir, error); + yield return new DiscoveredPlugin + { + Manifest = null, + Location = pluginDir, + DiscoveryError = error, + }; + continue; + } + + logger.LogInformation( + "Discovered plugin '{Id}' v{Version} at '{Location}'.", + manifest!.Id, manifest.Version, pluginDir); + yield return new DiscoveredPlugin + { + Manifest = manifest, + Location = pluginDir, + }; + } + + await Task.CompletedTask; + } +} diff --git a/src/Host/Plugins/Loading/FilePluginDiscoveryCache.cs b/src/Host/Plugins/Loading/FilePluginDiscoveryCache.cs new file mode 100644 index 0000000..cafd8e6 --- /dev/null +++ b/src/Host/Plugins/Loading/FilePluginDiscoveryCache.cs @@ -0,0 +1,164 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins.Loading; + +/// +/// File-backed discovery cache. Stores manifests with per-location source +/// fingerprints; any input change invalidates the whole file. +/// +/// +/// +/// Cached data is discovery metadata only (manifest + location + fingerprint), +/// never load contexts, types, or instances. +/// +/// +/// Best-effort persistence: corrupt or unreadable cache files are treated as a +/// miss, and store failures are logged without failing startup. +/// +/// +/// Where the cache file lives. +/// The logger used to report cache hits, misses, and failures. +public sealed class FilePluginDiscoveryCache(string cacheFilePath, ILogger logger) : IPluginDiscoveryCache +{ + private const int SchemaVersion = 1; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = false, + }; + + /// + /// Returns cached discovery results or null on cache miss or staleness. + /// + /// Stops fingerprint verification between entries. + /// Cached candidates or null to trigger full discovery. + public Task?> TryGetAsync(CancellationToken cancellationToken = default) + { + try + { + if (!File.Exists(cacheFilePath)) + return Miss(); + + using var stream = File.OpenRead(cacheFilePath); + var stored = JsonSerializer.Deserialize(stream, Options); + if (stored is null || stored.FormatVersion != SchemaVersion) + return Miss(); + + var entries = new List(); + foreach (var entry in stored.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (Fingerprint(entry.Location) != entry.SourceFingerprint) + { + logger.LogInformation("Discovery cache stale for '{Location}'; full rediscovery.", entry.Location); + return Miss(); + } + + entries.Add(new DiscoveredPlugin + { + Manifest = entry.Manifest is { } manifest ? Normalize(manifest) : null, + Location = entry.Location, + DiscoveryError = entry.DiscoveryError, + }); + } + + logger.LogInformation("Discovery cache hit: {Count} plugins reused.", entries.Count); + return Task.FromResult?>(entries); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + logger.LogWarning(ex, "Discovery cache unreadable; falling back to full discovery."); + return Miss(); + } + } + + /// + /// Stores fresh discovery results for future hits. + /// + /// Freshly discovered candidates. + /// Token (best-effort store ignores cancellation mid-write). + public Task StoreAsync(IReadOnlyList plugins, CancellationToken cancellationToken = default) + { + try + { + var directory = Path.GetDirectoryName(cacheFilePath); + if (directory is not null) + Directory.CreateDirectory(directory); + + var file = new CacheFile( + SchemaVersion, + plugins.Select(candidate => new CacheEntry( + candidate.Location, + Fingerprint(candidate.Location), + candidate.Manifest, + candidate.DiscoveryError)).ToList()); + + using var stream = File.Create(cacheFilePath); + JsonSerializer.Serialize(stream, file, Options); + logger.LogInformation("Discovery cache stored: {Count} plugins.", plugins.Count); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning(ex, "Discovery cache store failed; continuing without cache."); + } + + return Task.CompletedTask; + } + + private static string Fingerprint(string location) + { + using var sha = SHA256.Create(); + var files = Directory.Exists(location) + ? Directory.GetFiles(location, "*.json") + .Concat(Directory.GetFiles(location, "*.dll")) + .Order(StringComparer.Ordinal) + .ToList() + : new List(); + + foreach (var file in files) + { + var name = Encoding.UTF8.GetBytes(Path.GetFileName(file)); + sha.TransformBlock(name, 0, name.Length, null, 0); + try + { + using var stream = File.OpenRead(file); + var buffer = new byte[8192]; + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + sha.TransformBlock(buffer, 0, read, null, 0); + } + catch (IOException) + { + sha.TransformBlock([0], 0, 1, null, 0); + } + } + + sha.TransformFinalBlock([], 0, 0); + return Convert.ToHexString(sha.Hash!); + } + + private static PluginManifest Normalize(PluginManifest manifest) => + manifest with + { + Capabilities = new HashSet(manifest.Capabilities, StringComparer.OrdinalIgnoreCase), + Tags = manifest.Tags.ToArray(), + DependsOn = manifest.DependsOn.ToArray(), + }; + + private static Task?> Miss() => + Task.FromResult?>(null); + + private sealed record CacheFile(int FormatVersion, List Entries); + + private sealed record CacheEntry( + string Location, + string SourceFingerprint, + PluginManifest? Manifest, + string? DiscoveryError); +} diff --git a/src/Host/Plugins/Loading/Gate/CompatibilityGate.cs b/src/Host/Plugins/Loading/Gate/CompatibilityGate.cs new file mode 100644 index 0000000..340f4c9 --- /dev/null +++ b/src/Host/Plugins/Loading/Gate/CompatibilityGate.cs @@ -0,0 +1,70 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins.Loading.Gate; + +/// +/// Preload compatibility gate. Runs on the manifest before the plugin assembly +/// is loaded. The first version rule is MinHostVersion. Rejections are +/// hard failures, never warn only loading a plugin that requires a newer host +/// risks MissingMethodException / TypeLoadException. +/// +/// +/// +/// The gate inspects the manifest only. It never loads assemblies, constructs +/// instances, or activates runtime behavior, so rejected plugins cost nothing +/// beyond manifest parsing. +/// +/// +/// Version policy may grow here (capabilities, platform, ceilings) without +/// touching discovery or loading. +/// +/// +internal static class CompatibilityGate +{ + /// + /// Evaluates the manifest against the host version and configuration. + /// + /// The discovered manifest. + /// The running host version. Null skips the version rule. + /// Optional host configuration for the effective enabled flag. + /// The verdict with a human-readable reason for non-accepts. + public static (GateVerdict Verdict, string? Reason) Check( + PluginManifest manifest, + SemanticVersion? hostVersion, + IConfiguration? hostConfiguration = null) + { + if (!EffectiveIsEnabled(manifest, hostConfiguration)) + return (GateVerdict.SkipDisabled, $"Plugin '{manifest.Id}' is disabled."); + + if (manifest.MinHostVersion is { } min && hostVersion is { } host && host < min) + return (GateVerdict.Reject, + $"Host version {host} is lower than plugin '{manifest.Id}' required minimum {min}."); + + return (GateVerdict.Accept, null); + } + + /// + /// Computes the effective enabled flag without mutating the manifest. + /// + /// + /// + /// Truth table: manifest true + absent → true, true and true → true, + /// true and false → false, false and anything → false. The host may disable + /// but never re-enable a manifest-disabled plugin. + /// + /// + /// The discovered manifest. + /// Optional host configuration (Plugins:{id}:IsEnabled). + /// The effective flag the gate decides on. + public static bool EffectiveIsEnabled(PluginManifest manifest, IConfiguration? hostConfiguration) + { + if (!manifest.IsEnabled) + return false; + + var configured = hostConfiguration?[$"Plugins:{manifest.Id}:IsEnabled"]; + if (configured is null) + return true; + + return !bool.TryParse(configured, out var parsed) || parsed; + } +} diff --git a/src/Host/Plugins/Loading/Gate/GateVerdict.cs b/src/Host/Plugins/Loading/Gate/GateVerdict.cs new file mode 100644 index 0000000..10606b9 --- /dev/null +++ b/src/Host/Plugins/Loading/Gate/GateVerdict.cs @@ -0,0 +1,23 @@ +namespace Host.Plugins.Loading.Gate; + +/// +/// Compatibility gate outcome for one manifest. +/// +/// +/// +/// The verdict is terminal per candidate proceeds to +/// loading, while any other verdict reports its issue and skips all later +/// pipeline stages for that candidate. +/// +/// +internal enum GateVerdict +{ + /// Proceed to loading. + Accept, + + /// Skip quietly disabled plugin. + SkipDisabled, + + /// Hard reject incompatible host. + Reject, +} diff --git a/src/Host/Plugins/Loading/LoadedPlugin.cs b/src/Host/Plugins/Loading/LoadedPlugin.cs index 8e2e1b9..0b42a0d 100644 --- a/src/Host/Plugins/Loading/LoadedPlugin.cs +++ b/src/Host/Plugins/Loading/LoadedPlugin.cs @@ -1,5 +1,5 @@ using System.Reflection; -using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Models; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; namespace Host.Plugins.Loading; @@ -17,11 +17,17 @@ namespace Host.Plugins.Loading; /// The plugin directory is retained for diagnostics and identifying the /// location from which the plugin was loaded. /// +/// +/// The manifest is the same instance the candidate was discovered with, +/// already verified consistent with by the pipeline. +/// /// /// The loaded AuthKit plugin contract instance. /// The assembly containing the loaded plugin. /// The directory from which the plugin was loaded. +/// The manifest the plugin was discovered with. public sealed record LoadedPlugin( IAuthKitPlugin Plugin, Assembly Assembly, - string PluginDirectory); \ No newline at end of file + string PluginDirectory, + PluginManifest? Manifest = null); \ No newline at end of file diff --git a/src/Host/Plugins/Loading/Manifest/ManifestValidator.cs b/src/Host/Plugins/Loading/Manifest/ManifestValidator.cs new file mode 100644 index 0000000..8bba606 --- /dev/null +++ b/src/Host/Plugins/Loading/Manifest/ManifestValidator.cs @@ -0,0 +1,59 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins.Loading.Manifest; + +/// +/// Structural pre load validation of discovered manifests. Runs before any +/// plugin assembly is loaded every failure rejects the candidate. +/// +/// +/// +/// Only shape is checked here: presence, emptiness, duplicates, and +/// self dependencies. Semantic checks needing the loaded instance (consistency) +/// or the host (compatibility gate) belong to later pipeline stages. +/// +/// +internal static class ManifestValidator +{ + /// + /// Validates one manifest structurally. Returns zero or more error messages. + /// + /// The discovered manifest. + /// Structural violations; empty when the manifest is well-formed. + public static IReadOnlyList Validate(PluginManifest manifest) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(manifest.Id)) + errors.Add("Id must not be empty."); + + if (string.IsNullOrWhiteSpace(manifest.Name)) + errors.Add("Name must not be empty."); + + if (manifest.Tags.Any(string.IsNullOrWhiteSpace)) + errors.Add("Tags must not contain null or whitespace elements."); + + if (manifest.DependsOn.Any(string.IsNullOrWhiteSpace)) + errors.Add("DependsOn must not contain null or whitespace entries."); + + if (manifest.DependsOn.Distinct(StringComparer.OrdinalIgnoreCase).Count() != manifest.DependsOn.Count) + errors.Add("DependsOn must not contain duplicates."); + + if (manifest.DependsOn.Contains(manifest.Id, StringComparer.OrdinalIgnoreCase)) + errors.Add($"Plugin must not depend on itself ('{manifest.Id}')."); + + return errors; + } + + /// + /// Finds duplicate manifest Ids (case-insensitive) across the discovered set. + /// + /// Structurally valid manifests. + /// Ids claimed by more than one manifest. + public static IReadOnlySet FindDuplicateIds(IEnumerable manifests) => + manifests + .GroupBy(manifest => manifest.Id, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Host/Plugins/Loading/Manifest/PluginManifestReader.cs b/src/Host/Plugins/Loading/Manifest/PluginManifestReader.cs new file mode 100644 index 0000000..4395cca --- /dev/null +++ b/src/Host/Plugins/Loading/Manifest/PluginManifestReader.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using AuthKit.Plugins.Abstractions.Models; + +namespace Host.Plugins.Loading.Manifest; + +/// +/// Reads plugin manifest from plugin directory without loading any assembly. +/// +/// +/// +/// Looks for plugin.json, plugin.manifest, then manifest.json +/// (first match wins). A manifest is required: directories without one are +/// invalid candidates. +/// +/// +/// Deserialization is key case insensitive, and collections are snapshotted +/// into arrays with case insensitive capability sets, so later stages observe +/// stable, normalized metadata. +/// +/// +internal static class PluginManifestReader +{ + private static readonly string[] FileNames = ["plugin.json", "plugin.manifest", "manifest.json"]; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + }; + + /// + /// Tries to read the manifest from . + /// + /// One plugin directory. + /// The normalized manifest or null on failure. + /// The failure reason or null on success. + /// + /// True with valid manifest. False with set when + /// no manifest file exists or it cannot be parsed. + /// + public static bool TryRead( + string pluginDirectory, + out PluginManifest? manifest, + out string? error) + { + var path = FileNames + .Select(name => Path.Combine(pluginDirectory, name)) + .FirstOrDefault(File.Exists); + + if (path is null) + { + manifest = null; + error = $"No manifest file found in '{pluginDirectory}' (expected plugin.json, plugin.manifest, or manifest.json)."; + return false; + } + + try + { + var json = File.ReadAllText(path); + var read = JsonSerializer.Deserialize(json, Options) + ?? throw new JsonException("Manifest deserialized to null."); + + manifest = Normalize(read); + error = null; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or FormatException) + { + manifest = null; + error = $"Manifest '{Path.GetFileName(path)}' is unreadable: {ex.Message}"; + return false; + } + } + + private static PluginManifest Normalize(PluginManifest manifest) => + manifest with + { + Capabilities = new HashSet(manifest.Capabilities, StringComparer.OrdinalIgnoreCase), + Tags = [.. manifest.Tags], + DependsOn = [.. manifest.DependsOn], + }; +} diff --git a/src/Host/Plugins/Loading/Pipeline/PluginLoader.cs b/src/Host/Plugins/Loading/Pipeline/PluginLoader.cs new file mode 100644 index 0000000..567d0d5 --- /dev/null +++ b/src/Host/Plugins/Loading/Pipeline/PluginLoader.cs @@ -0,0 +1,106 @@ +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins.Loading.Results; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace Host.Plugins.Loading.Pipeline; + +/// +/// Discovers and loads AuthKit plugins from a specified directory during host startup. +/// +/// +/// +/// Plugins are loaded before +/// is called, allowing infrastructure such as Wolverine, Marten, and MVC to discover +/// plugin assemblies while their configuration is being built. +/// +/// +/// The pipeline is discovery → pre-load validation → compatibility gate → +/// dependency graph → load → contract validation → consistency. Every plugin +/// directory must carry a manifest; manifest-less directories are invalid. +/// Custom / +/// implementations can replace the defaults. +/// +/// +/// This facade always returns the accepted plugins. Per-candidate diagnostics +/// go to the log; programmatic access uses +/// with directly. +/// +/// +public static class PluginLoader +{ + /// + /// Discovers and loads all valid AuthKit plugins from the specified root directory. + /// + /// The root directory containing one subdirectory per plugin. + /// The logger used to report plugin discovery, loading, and validation results. + /// The version of the host application, used to reject plugins that require a newer host. + /// Optional host configuration for the effective enabled flag. + /// Optional discovery cache. Null disables caching. + /// A readonly collection containing all successfully loaded plugins. + /// + /// Each plugin directory is expected to contain an entry assembly whose file name + /// matches the directory name. Directories without matching assembly or assemblies + /// without valid implementation are skipped. + /// + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger, + SemanticVersion hostVersion, + IConfiguration? hostConfiguration = null, + IPluginDiscoveryCache? discoveryCache = null) => + LoadPlugins(pluginsRootPath, logger, hostVersion, hostConfiguration, discoveryCache, discoverer: null, loader: null); + + /// + /// Discovers and loads plugins with swappable discovery/loading. + /// + /// Used only by the default discoverer; ignored when is supplied. + /// The logger used to report plugin discovery, loading, and validation results. + /// The version of the host application, used to reject plugins that require a newer host. + /// Optional host configuration for the effective enabled flag. + /// Optional discovery cache. Null disables caching. + /// Custom discoverer. Defaults to directory discovery. + /// Custom loader. Defaults to assembly loading. + /// A readonly collection containing all successfully loaded plugins. + public static IReadOnlyList LoadPlugins( + string pluginsRootPath, + ILogger logger, + SemanticVersion hostVersion, + IConfiguration? hostConfiguration, + IPluginDiscoveryCache? discoveryCache, + IPluginDiscoverer? discoverer, + IPluginLoader? loader) + { + discoverer ??= new DirectoryPluginDiscoverer(pluginsRootPath, logger); + loader ??= new DefaultPluginLoader(logger); + + var pipeline = new PluginLoadingPipeline(discoverer, loader, logger, hostVersion, hostConfiguration, discoveryCache); + var result = pipeline.RunAsync().GetAwaiter().GetResult(); + + LogSummary(logger, result); + return result.Loaded; + } + + private static void LogSummary(ILogger logger, PluginLoadResult result) + { + foreach (var issue in result.Issues) + { + var message = issue.Outcome switch + { + PluginOutcome.SkippedDisabled => "Disabled", + PluginOutcome.Rejected => "Rejected", + _ => "Invalid", + }; + + logger.LogWarning( + "Plugin {State}: '{Location}' ({Id}): {Reason}", + message, issue.Location, issue.PluginId ?? "", issue.Reason); + } + + logger.LogInformation( + "Plugin loading complete: {Loaded} loaded, {Issues} with issues.", + result.Loaded.Count, result.Issues.Count); + } +} diff --git a/src/Host/Plugins/Loading/Pipeline/PluginLoadingPipeline.cs b/src/Host/Plugins/Loading/Pipeline/PluginLoadingPipeline.cs new file mode 100644 index 0000000..3ba794f --- /dev/null +++ b/src/Host/Plugins/Loading/Pipeline/PluginLoadingPipeline.cs @@ -0,0 +1,278 @@ +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins.Contract; +using Host.Plugins.Loading.Gate; +using Host.Plugins.Loading.Manifest; +using Host.Plugins.Loading.Results; + +namespace Host.Plugins.Loading.Pipeline; + +/// +/// Host loading pipeline: discovery preload validation compatibility gate +/// dependency graph isolated load contract validation manifest consistency. +/// +/// +/// +/// Custom / +/// implementations can replace the defaults the pipeline stages around them +/// stay the same. +/// +/// +/// Loading is one batch call with the sorted, accepted list; results attribute +/// by manifest ID, so a loader failure maps to exactly one location in the +/// resulting . +/// +/// +/// The candidate source. Never activated by the pipeline. +/// The instance constructor. Never judges compatibility. +/// The logger used to report stage outcomes. +/// The running host version for the compatibility gate. Null skips the version rule. +/// Optional host configuration for the effective enabled flag. +public sealed class PluginLoadingPipeline( + IPluginDiscoverer discoverer, + IPluginLoader loader, + ILogger logger, + SemanticVersion? hostVersion, + IConfiguration? hostConfiguration = null, + IPluginDiscoveryCache? discoveryCache = null) +{ + /// + /// Runs the full pipeline and returns accepted plugins with diagnostics. + /// + /// Stops the pipeline between candidates. + /// Accepted plugins plus one issue per rejected candidate. + /// + /// Stage order per candidate: discovery error → structural validation → + /// duplicate ID → compatibility gate → dependency graph → load in + /// topological order → manifest consistency → contract validation. + /// The first failing stage reports the issue; later stages never run for + /// that candidate. Dependents of failed plugins are rejected as + /// dependency-unavailable without loading. + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + var issues = new List(); + var discovered = new List(); + + if (discoveryCache is not null + && await discoveryCache.TryGetAsync(cancellationToken) is { } cached) + { + discovered.AddRange(cached); + } + else + { + await foreach (var candidate in discoverer.DiscoverAsync(cancellationToken)) + discovered.Add(candidate); + + if (discoveryCache is not null) + await discoveryCache.StoreAsync(discovered, cancellationToken); + } + + // 1. Discovery errors are invalid and never load. + var candidates = new List(); + foreach (var candidate in discovered) + { + if (candidate.DiscoveryError is { } error) + { + issues.Add(new PluginLoadIssue(candidate.Location, null, PluginOutcome.Invalid, error)); + logger.LogError("Skipping plugin '{Location}': {Error}", candidate.Location, error); + continue; + } + + candidates.Add(candidate); + } + + // 2. Structural preload validation (manifests only, no assemblies loaded). + var structurallyValid = new List(); + foreach (var candidate in candidates) + { + if (candidate.Manifest is not { } manifest) + { + const string missingReason = "Missing manifest: candidate has no manifest and no discovery error."; + issues.Add(new PluginLoadIssue(candidate.Location, null, PluginOutcome.Invalid, missingReason)); + logger.LogError("Skipping plugin '{Location}': {Reason}", candidate.Location, missingReason); + continue; + } + + var errors = ManifestValidator.Validate(manifest); + if (errors.Count == 0) + { + structurallyValid.Add(candidate); + continue; + } + + var reason = $"Invalid manifest for plugin '{manifest.Id}': {string.Join("; ", errors)}"; + issues.Add(new PluginLoadIssue(candidate.Location, manifest.Id, PluginOutcome.Invalid, reason)); + logger.LogError("Skipping plugin '{Location}': {Reason}", candidate.Location, reason); + } + + // 3. Duplicate Ids are rejected deterministically (first by location wins). + var deduplicated = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var candidate in structurallyValid.OrderBy(c => c.Location, StringComparer.Ordinal)) + { + // Manifest presence was verified in step 2. + var manifest = candidate.Manifest!; + if (!seenIds.Add(manifest.Id)) + { + var reason = $"Duplicate plugin Id '{manifest.Id}': already discovered."; + issues.Add(new PluginLoadIssue(candidate.Location, manifest.Id, PluginOutcome.Invalid, reason)); + logger.LogError("Skipping plugin '{Location}': {Reason}", candidate.Location, reason); + continue; + } + + deduplicated.Add(candidate); + } + + // 4. Compatibility gate. + var toLoad = new List(); + foreach (var candidate in deduplicated) + { + // Manifest presence was verified in step 2. + var manifest = candidate.Manifest!; + var (verdict, reason) = CompatibilityGate.Check(manifest, hostVersion, hostConfiguration); + switch (verdict) + { + case GateVerdict.Accept: + toLoad.Add(candidate); + break; + case GateVerdict.SkipDisabled: + issues.Add(new PluginLoadIssue(candidate.Location, manifest.Id, PluginOutcome.SkippedDisabled, reason!)); + logger.LogInformation("Skipping disabled plugin '{Id}'.", manifest.Id); + break; + case GateVerdict.Reject: + issues.Add(new PluginLoadIssue(candidate.Location, manifest.Id, PluginOutcome.Rejected, reason!)); + logger.LogError("Rejecting plugin '{Id}': {Reason}", manifest.Id, reason); + break; + } + } + + // 5. Dependency graph: unknown ids and cycles are startup errors. + var indexed = toLoad + .Select((candidate, index) => (Candidate: candidate, RegistrationOrder: index)) + .ToList(); + var knownIds = deduplicated + .Select(candidate => candidate.Manifest!.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + DependencyGraph.Validate(indexed, knownIds); + + // 6. Dependents of unavailable plugins (skipped, rejected, invalid) are + // rejected as dependency-unavailable without loading (fixpoint, so + // transitive dependents propagate regardless of discovery order). + var loadable = new List<(DiscoveredPlugin Candidate, int RegistrationOrder)>(); + var unavailable = new HashSet( + knownIds.Where(id => !indexed.Any(entry => + string.Equals(entry.Candidate.Manifest!.Id, id, StringComparison.OrdinalIgnoreCase))), + StringComparer.OrdinalIgnoreCase); + var pending = new List<(DiscoveredPlugin Candidate, int RegistrationOrder)>(indexed); + bool progressed; + do + { + progressed = false; + var remaining = new List<(DiscoveredPlugin Candidate, int RegistrationOrder)>(); + foreach (var entry in pending) + { + // Manifest presence was verified in step 2. + var manifest = entry.Candidate.Manifest!; + var blockedBy = manifest.DependsOn + .FirstOrDefault(dependency => unavailable.Contains(dependency)); + if (blockedBy is null) + { + remaining.Add(entry); + continue; + } + + var reason = $"Dependency '{blockedBy}' is unavailable; plugin '{manifest.Id}' cannot load."; + issues.Add(new PluginLoadIssue(entry.Candidate.Location, manifest.Id, PluginOutcome.Rejected, reason)); + logger.LogError("Rejecting plugin '{Id}': {Reason}", manifest.Id, reason); + unavailable.Add(manifest.Id); + progressed = true; + } + + pending = remaining; + } + while (progressed); + + loadable.AddRange(pending); + + // 7. Topological order: dependencies first, ties by priority then registration. + var ordered = DependencyGraph.Sort(loadable); + + // 8. Load the sorted, accepted list in one batch (construct only). + // Results attribute by manifest Id, unique since step 3. Missing ids + // are loader failures; dependents of failed plugins are rejected as + // dependency-unavailable without loading. + var loadedById = (await loader.LoadAsync(ordered, cancellationToken)) + .Where(contract => contract.Manifest is not null) + .GroupBy(contract => contract.Manifest.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var accepted = new List(); + var failedIds = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var candidate in ordered) + { + var manifest = candidate.Manifest!; + var missing = manifest.DependsOn + .FirstOrDefault(dependency => failedIds.Contains(dependency)); + if (missing is not null) + { + var reason = $"Dependency '{missing}' is unavailable; plugin '{manifest.Id}' cannot load."; + issues.Add(new PluginLoadIssue(candidate.Location, manifest.Id, PluginOutcome.Rejected, reason)); + logger.LogError("Rejecting plugin '{Id}': {Reason}", manifest.Id, reason); + failedIds.Add(manifest.Id); + continue; + } + + if (!loadedById.TryGetValue(manifest.Id, out var contract)) + { + issues.Add(new PluginLoadIssue( + candidate.Location, + manifest.Id, + PluginOutcome.Invalid, + "Loader failed to construct the plugin instance.")); + failedIds.Add(manifest.Id); + continue; + } + + // 8a. Manifest ↔ instance consistency. + try + { + PluginValidator.ValidateConsistency(contract.Manifest, contract.Instance); + } + catch (Exception ex) + { + var reason = $"Manifest/instance mismatch for plugin '{contract.Manifest.Id}': {ex.Message}"; + issues.Add(new PluginLoadIssue(candidate.Location, contract.Manifest.Id, PluginOutcome.Invalid, reason)); + logger.LogError("Rejecting plugin '{Id}': {Reason}", contract.Manifest.Id, reason); + failedIds.Add(manifest.Id); + continue; + } + + // 8b. Full contract validation. Throws on violation. + try + { + PluginContractValidator.Validate(contract.Instance, logger); + } + catch (Exception ex) + { + var reason = $"Contract validation failed for plugin '{contract.Manifest.Id}': {ex.Message}"; + issues.Add(new PluginLoadIssue(candidate.Location, contract.Manifest.Id, PluginOutcome.Invalid, reason)); + logger.LogError("Rejecting plugin '{Id}': {Reason}", contract.Manifest.Id, reason); + failedIds.Add(manifest.Id); + continue; + } + + accepted.Add(new LoadedPlugin( + contract.Instance, + contract.PluginType.Assembly, + candidate.Location, + contract.Manifest)); + + logger.LogInformation( + "Loaded plugin '{Name}' v{Version} from {Dir}", + contract.Instance.Name, contract.Instance.Version, candidate.Location); + } + + return new PluginLoadResult(accepted, issues); + } +} diff --git a/src/Host/Plugins/Loading/PluginLoadContext.cs b/src/Host/Plugins/Loading/PluginLoadContext.cs new file mode 100644 index 0000000..7eb44a3 --- /dev/null +++ b/src/Host/Plugins/Loading/PluginLoadContext.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Host.Plugins.Loading; + +/// +/// Isolated load context for one plugin. Host and framework assemblies are +/// shared with the default context; only plugin-private dependencies resolve +/// from the plugin directory. +/// +/// +/// +/// Resolution order per assembly name: well-known shared contracts, then +/// anything already loaded in the default context, then anything shipped with +/// the host application directory (shared, returned as null to fall back), +/// otherwise the plugin directory via +/// , otherwise default fallback. +/// +/// +/// The shared-contract list matters most at startup: plugin loading runs +/// before first gRPC use, so without it a plugin would privately load its own +/// Grpc.Core.Api copy and its interceptors would fail the host's +/// Interceptor identity check. +/// +/// +/// Collectible to enable future unload/hot-reload orchestration; the loader +/// itself never unloads. +/// +/// +/// The plugin entry assembly path. +internal sealed class PluginLoadContext(string entryAssemblyPath) : AssemblyLoadContext(isCollectible: true) +{ + private static readonly HashSet SharedContracts = new(StringComparer.OrdinalIgnoreCase) + { + "AuthKit.Plugins.Abstractions", + "Grpc.Core.Api", + "Google.Protobuf", + }; + + private readonly AssemblyDependencyResolver _resolver = new(entryAssemblyPath); + + /// + /// Loads the entry assembly into this context. + /// + public Assembly LoadEntry() => LoadFromAssemblyPath(entryAssemblyPath); + + /// + /// Resolves the assembly: shared when listed, already loaded by default, + /// or shipped with the host; otherwise from the plugin directory, + /// otherwise default fallback. + /// + /// The requested assembly name. + /// The resolved assembly or null to fall back to the default resolution. + protected override Assembly? Load(AssemblyName assemblyName) + { + if (assemblyName.Name is not null + && (SharedContracts.Contains(assemblyName.Name) + || IsLoadedByDefault(assemblyName.Name) + || IsShippedWithHost(assemblyName.Name))) + return null; + + var path = _resolver.ResolveAssemblyToPath(assemblyName); + return path is not null ? LoadFromAssemblyPath(path) : null; + } + + private static bool IsLoadedByDefault(string name) => + Default.Assemblies.Any(candidate => + string.Equals(candidate.GetName().Name, name, StringComparison.Ordinal)); + + /// + /// Host-owned assemblies unify even when the host has not touched them yet + /// (lazy loading): a plugin must see the same Core types the host + /// registers in DI, never a plugin-local copy. + /// + private static bool IsShippedWithHost(string name) => + File.Exists(Path.Combine(AppContext.BaseDirectory, $"{name}.dll")); +} diff --git a/src/Host/Plugins/Loading/PluginLoader.cs b/src/Host/Plugins/Loading/PluginLoader.cs deleted file mode 100644 index 78b8258..0000000 --- a/src/Host/Plugins/Loading/PluginLoader.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Reflection; -using System.Runtime.Loader; -using AuthKit.Plugins.Abstractions; -using AuthKit.Plugins.Abstractions.Contracts; -using AuthKit.Plugins.Abstractions.Models; -using Host.Plugins.Contract; -using Microsoft.Extensions.Logging; -using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; - -namespace Host.Plugins.Loading; - -/// -/// Discovers and loads AuthKit plugins from specified directory during host startup. -/// -/// -/// -/// Plugins are loaded before -/// is called, allowing infrastructure such as Wolverine, Marten, and MVC to discover -/// plugin assemblies while their configuration is being built. -/// -/// -/// Plugins are loaded into rather than an -/// isolated load context. This ensures that shared framework and package types, such -/// as Wolverine IMessageBus, Marten IDocumentSession, and ASP.NET Core -/// MVC types, resolve to the same runtime types on both sides of the plugin boundary. -/// -/// -/// The loader does not support hot unloading or hot swapping of plugins. Plugins are -/// expected to remain loaded for the lifetime of the host process. -/// -/// -public static class PluginLoader -{ - /// - /// Discovers and loads all valid AuthKit plugins from the specified root directory. - /// - /// The root directory containing one subdirectory per plugin. - /// The logger used to report plugin discovery, loading, and validation results. - /// The version of the host application, used to reject plugins that - /// require a newer host. - /// A readonly collection containing all successfully loaded plugins. - /// - /// Each plugin directory is expected to contain an entry assembly whose file name - /// matches the directory name. Directories without matching assembly or assemblies - /// without valid implementation are skipped. - /// - public static IReadOnlyList LoadPlugins( - string pluginsRootPath, - ILogger logger, - SemanticVersion hostVersion) => - LoadPluginsCore(pluginsRootPath, logger, hostVersion); - - /// - /// Discovers and loads all valid AuthKit plugins from the specified root directory. - /// - /// The root directory containing one subdirectory per plugin. - /// The logger used to report plugin discovery, loading, and validation results. - /// A readonly collection containing all successfully loaded plugins. - [Obsolete("Use the overload taking a host version to enforce plugin MinHostVersion compatibility.")] - public static IReadOnlyList LoadPlugins( - string pluginsRootPath, - ILogger logger) => - LoadPluginsCore(pluginsRootPath, logger, hostVersion: null); - - private static IReadOnlyList LoadPluginsCore( - string pluginsRootPath, - ILogger logger, - SemanticVersion? hostVersion) - { - if (!Directory.Exists(pluginsRootPath)) - { - logger.LogWarning("Plugins path '{Path}' does not exist — starting with zero plugins.", pluginsRootPath); - return []; - } - - var loaded = new List(); - - foreach (var pluginDir in Directory.GetDirectories(pluginsRootPath)) - { - var pluginName = Path.GetFileName(pluginDir); - var entryDllPath = Path.Join(pluginDir, $"{pluginName}.dll"); - - if (!File.Exists(entryDllPath)) - { - logger.LogError( - "Skipping plugin folder '{Dir}': expected entry assembly '{Dll}' not found.", - pluginDir, entryDllPath); - continue; - } - - try - { - var resolver = new AssemblyDependencyResolver(entryDllPath); - AssemblyLoadContext.Default.Resolving += (context, name) => - { - var path = resolver.ResolveAssemblyToPath(name); - return path is not null ? context.LoadFromAssemblyPath(path) : null; - }; - - var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(entryDllPath); - - var pluginType = assembly.GetTypes() - .FirstOrDefault(t => t is { IsPublic: true, IsAbstract: false } - && typeof(IAuthKitPlugin).IsAssignableFrom(t) - && t.GetConstructor(Type.EmptyTypes) is not null); - - if (pluginType is null) - { - logger.LogError( - "Skipping plugin assembly '{Dll}': no public, non-abstract IAuthKitPlugin implementation with a parameterless constructor found.", - entryDllPath); - continue; - } - - var plugin = (IAuthKitPlugin)Activator.CreateInstance(pluginType)!; - - if (hostVersion is { } hv && plugin.MinHostVersion is { } minVersion && hv < minVersion) - { - logger.LogError( - "Skipping plugin '{Dir}': host version {HostVersion} is lower than required minimum {MinVersion}.", - pluginDir, hv, minVersion); - continue; - } - - // Validate plugin contract against host capabilities - PluginContractValidator.Validate(plugin, logger); - - loaded.Add(new LoadedPlugin(plugin, assembly, pluginDir)); - - logger.LogInformation("Loaded plugin '{Name}' v{Version} from {Dir}", plugin.Name, plugin.Version, pluginDir); - } - catch (FileNotFoundException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (FileLoadException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (BadImageFormatException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (ReflectionTypeLoadException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (TypeLoadException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (MissingMethodException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (TargetInvocationException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - catch (InvalidCastException ex) - { - logger.LogError(ex, "Failed to load plugin from '{Dir}'.", pluginDir); - } - } - - return loaded; - } -} diff --git a/src/Host/Plugins/Loading/Results/PluginLoadIssue.cs b/src/Host/Plugins/Loading/Results/PluginLoadIssue.cs new file mode 100644 index 0000000..a01ad8b --- /dev/null +++ b/src/Host/Plugins/Loading/Results/PluginLoadIssue.cs @@ -0,0 +1,20 @@ +namespace Host.Plugins.Loading.Results; + +/// +/// One non loaded candidate with its reason, for startup diagnostics. +/// +/// +/// +/// is readable and log ready: it names the plugin +/// (when known) and the failing stage. +/// +/// +/// The discovery location that was rejected or skipped. +/// The manifest ID, when the manifest was readable. +/// The terminal outcome for this candidate. +/// Why the candidate did not load. +public sealed record PluginLoadIssue( + string Location, + string? PluginId, + PluginOutcome Outcome, + string Reason); diff --git a/src/Host/Plugins/Loading/Results/PluginLoadResult.cs b/src/Host/Plugins/Loading/Results/PluginLoadResult.cs new file mode 100644 index 0000000..dc65393 --- /dev/null +++ b/src/Host/Plugins/Loading/Results/PluginLoadResult.cs @@ -0,0 +1,16 @@ +namespace Host.Plugins.Loading.Results; + +/// +/// Host internal loading outcome: accepted plugins plus per candidate issues. +/// For startup logs, diagnostics, health, and admin surfaces. Intentionally not +/// part of the plugin contract. +/// +/// +/// +/// Every discovered candidate appears exactly once: either in +/// or as one entry in . +/// +/// +public sealed record PluginLoadResult( + IReadOnlyList Loaded, + IReadOnlyList Issues); diff --git a/src/Host/Plugins/Loading/Results/PluginOutcome.cs b/src/Host/Plugins/Loading/Results/PluginOutcome.cs new file mode 100644 index 0000000..3de7e37 --- /dev/null +++ b/src/Host/Plugins/Loading/Results/PluginOutcome.cs @@ -0,0 +1,23 @@ +namespace Host.Plugins.Loading.Results; + +/// +/// Outcome of one discovered candidate in the loading pipeline. +/// +/// +/// Outcomes are terminal per candidate: the first failing pipeline stage +/// reports its issue, and later stages never run for that candidate. +/// +public enum PluginOutcome +{ + /// Loaded and accepted. + Loaded, + + /// Skipped before loading: disabled plugin. + SkippedDisabled, + + /// Hard reject: incompatible host or contract violation. + Rejected, + + /// Invalid: unreadable manifest, structural errors, duplicate Id, or load failure. + Invalid, +} diff --git a/src/Host/Program.cs b/src/Host/Program.cs index 82f0ccd..e88f8c1 100644 --- a/src/Host/Program.cs +++ b/src/Host/Program.cs @@ -6,6 +6,7 @@ using Host.Configuration.Restful; using Host.Configuration.Server; using Host.Plugins.Loading; +using Host.Plugins.Loading.Pipeline; using Host.Plugins.Configuration; using Host.Plugins.Lifecycle; using Host.Cli; @@ -26,8 +27,11 @@ var hostVersion = SemanticVersion.Parse(Assembly.GetEntryAssembly()! .GetCustomAttribute()! .InformationalVersion!.Split('+')[0]); +var discoveryCachePath = builder.Configuration["AuthKit:DiscoveryCachePath"] + ?? Path.Combine(Path.GetTempPath(), "authkit-discovery-cache.json"); +var discoveryCache = new FilePluginDiscoveryCache(discoveryCachePath, pluginLogger); -var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger, hostVersion); +var plugins = PluginLoader.LoadPlugins(pluginsPath, pluginLogger, hostVersion, builder.Configuration, discoveryCache); var restfulLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("RestfulConfiguration"); var grpcLogger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger("GrpcConfiguration"); diff --git a/src/Plugins/Abstractions/Contracts/Discovery/DiscoveredPlugin.cs b/src/Plugins/Abstractions/Contracts/Discovery/DiscoveredPlugin.cs new file mode 100644 index 0000000..06fa884 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Discovery/DiscoveredPlugin.cs @@ -0,0 +1,42 @@ +using AuthKit.Plugins.Abstractions.Models; + +namespace AuthKit.Plugins.Abstractions.Contracts.Discovery; + +/// +/// A plugin candidate found by an , before the +/// plugin assembly is loaded or activated. +/// +/// +/// +/// carries the declarative metadata read from +/// plugin.json / manifest.json during discovery, so the host can +/// run preload validation and the compatibility gate first. A manifest is +/// required: candidates without readable manifest are invalid and never load. +/// +/// +/// is set when a manifest file was found but could +/// not be read or validated structurally; such candidates are invalid and must +/// never be loaded. +/// +/// +public sealed record DiscoveredPlugin +{ + /// + /// Manifest read during discovery. Required; null only when + /// is set. + /// + public required PluginManifest? Manifest { get; init; } + + /// + /// Source from which the loader can load the plugin. The format is + /// discovery-source specific (directory, dll, package, uri, ...) and must + /// not be interpreted by the contract. + /// + public required string Location { get; init; } + + /// + /// Structural discovery failure (unreadable/invalid manifest). Null when + /// discovery succeeded. + /// + public string? DiscoveryError { get; init; } +} diff --git a/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoverer.cs b/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoverer.cs new file mode 100644 index 0000000..32e25e6 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoverer.cs @@ -0,0 +1,18 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.Discovery; + +/// +/// Finds candidate plugins and reads their manifests without activating them. +/// +/// +/// Discovery MUST NOT activate plugins: no assembly loading for execution, no +/// instance construction, no runtime behavior. All compatibility decisions +/// (enabled, version gate, validation) happen in the host pipeline around the +/// loader, never inside discovery. +/// +public interface IPluginDiscoverer +{ + /// + /// Streams discovered plugin candidates, manifest included. + /// + IAsyncEnumerable DiscoverAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoveryCache.cs b/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoveryCache.cs new file mode 100644 index 0000000..7a22869 --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Discovery/IPluginDiscoveryCache.cs @@ -0,0 +1,22 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.Discovery; + +/// +/// Host owned discovery cache sitting before . +/// +/// +/// A hit reuses cached entries without calling +/// the discoverer. Only discovery metadata is cached — never load contexts, +/// types, or instances. +/// +public interface IPluginDiscoveryCache +{ + /// + /// Returns cached discovery results or null on cache miss or staleness. + /// + Task?> TryGetAsync(CancellationToken cancellationToken = default); + + /// + /// Stores fresh discovery results for future hits. + /// + Task StoreAsync(IReadOnlyList plugins, CancellationToken cancellationToken = default); +} diff --git a/src/Plugins/Abstractions/Contracts/Discovery/IPluginLoader.cs b/src/Plugins/Abstractions/Contracts/Discovery/IPluginLoader.cs new file mode 100644 index 0000000..83dff2f --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Discovery/IPluginLoader.cs @@ -0,0 +1,26 @@ +namespace AuthKit.Plugins.Abstractions.Contracts.Discovery; + +/// +/// Constructs plugin instances from discovered candidates. +/// +/// +/// The loader receives exactly what the discoverer produced: it does not +/// rediscover, does not decide compatibility (enabled/version/validation), +/// and does not activate runtime behavior. It loads assemblies, constructs +/// instances, and returns them for the host pipeline (validation, consistency, +/// ordering, activation). +/// +/// +/// Echo each candidate's manifest back on the returned entry. The pipeline +/// attributes results by manifest Id, so entries without a manifest cannot +/// be attributed and are ignored. +/// +public interface IPluginLoader +{ + /// + /// Loads and constructs the discovered plugins without activating them. + /// + Task> LoadAsync( + IReadOnlyList plugins, + CancellationToken cancellationToken = default); +} diff --git a/src/Plugins/Abstractions/Contracts/Discovery/LoadedPlugin.cs b/src/Plugins/Abstractions/Contracts/Discovery/LoadedPlugin.cs new file mode 100644 index 0000000..1c3a57a --- /dev/null +++ b/src/Plugins/Abstractions/Contracts/Discovery/LoadedPlugin.cs @@ -0,0 +1,38 @@ +using System.Runtime.Loader; +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Models; + +namespace AuthKit.Plugins.Abstractions.Contracts.Discovery; + +/// +/// A plugin whose assembly was loaded and whose +/// instance was constructed, but whose runtime behavior was not activated. +/// +/// +/// Activation (endpoint mapping, middleware wiring, hosted services) is the +/// host's separate step after validation, consistency checks, and ordering. +/// +public sealed record LoadedPlugin +{ + /// + /// Manifest the plugin was discovered with. Required. + /// + public required PluginManifest Manifest { get; init; } + + /// + /// Concrete plugin implementation type. + /// + public required Type PluginType { get; init; } + + /// + /// Constructed plugin instance. Not activated. + /// + public required IAuthKitPlugin Instance { get; init; } + + /// + /// Isolated load context the plugin was loaded into. Host and framework + /// assemblies are shared; only plugin-private dependencies are isolated. + /// Collectible to enable future unload/hot-reload orchestration. + /// + public required AssemblyLoadContext LoadContext { get; init; } +} diff --git a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Configuration.cs b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Configuration.cs index 0d94f2a..cb5e842 100644 --- a/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Configuration.cs +++ b/src/Plugins/Abstractions/Contracts/PluginContract/IAuthKitPlugin.Configuration.cs @@ -10,8 +10,8 @@ public partial interface IAuthKitPlugin /// /// Registers the plugin's services in the host dependency injection container. /// - /// The host's dependency injection service collection. - /// The host application configuration. + /// The used to register plugin services. + /// Stable plugin context including the plugin-scoped configuration section. /// /// /// This method is called while the host application is being configured, @@ -23,11 +23,9 @@ public partial interface IAuthKitPlugin /// container. /// /// - void ConfigureServices( - IServiceCollection services, - IConfiguration configuration) => + void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => throw new NotSupportedException( - $"Plugin '{GetType().Name}' must implement a supported ConfigureServices overload."); + $"Plugin '{GetType().Name}' must implement ConfigureServices(IServiceCollection, AuthKitPluginContext)."); /// /// Configures plugin services using the host application builder. @@ -36,24 +34,19 @@ void ConfigureServices( /// The application configuration. /// /// This overload is optional. Its default implementation delegates to the - /// legacy service collection overload for existing plugins. + /// service collection overload with a context built from the plugin + /// identity and the scoped configuration section. /// void ConfigureServices( IHostApplicationBuilder builder, IConfiguration configuration) => - ConfigureServices(builder.Services, configuration); - - /// - /// Configures plugin services with stable plugin context information. - /// - /// The service collection used by the host. - /// The context for the plugin being configured. - /// - /// This overload is optional. Its default implementation delegates to the - /// legacy service collection overload for existing plugins. - /// - void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => - ConfigureServices(services, context.Configuration); + ConfigureServices( + builder.Services, + new AuthKitPluginContext( + Id, + Name, + this.GetPluginConfiguration(configuration), + configuration)); /// /// Binds strongly typed plugin options from the plugin configuration section using @@ -77,4 +70,4 @@ void ConfigureServices(IServiceCollection services, AuthKitPluginContext context void BindConfiguration(IServiceCollection services, IConfiguration configuration) where TOptions : class => services.Configure(this.GetPluginConfiguration(configuration)); -} \ No newline at end of file +} diff --git a/src/Plugins/Abstractions/Contracts/PluginValidator.cs b/src/Plugins/Abstractions/Contracts/PluginValidator.cs index 26a60e1..76469f5 100644 --- a/src/Plugins/Abstractions/Contracts/PluginValidator.cs +++ b/src/Plugins/Abstractions/Contracts/PluginValidator.cs @@ -102,6 +102,27 @@ public static void ValidateConsistency(PluginManifest manifest, PluginContract.I { throw new ArgumentNullException(nameof(plugin), "Plugin instance cannot be null."); } + + // Check if Id matches (case-insensitive, like duplicate detection). + if (!string.Equals(manifest.Id, plugin.Id, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Manifest and plugin instance disagree on Id."); + } + + // Check if Name matches. + if (!string.Equals(manifest.Name, plugin.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Manifest and plugin instance disagree on Name."); + } + + // Check if Version matches (SemVer precedence, build metadata ignored). + if (manifest.Version.CompareTo(plugin.Version) != 0) + { + throw new InvalidOperationException( + "Manifest and plugin instance disagree on Version."); + } // Check if IsEnabled matches if (manifest.IsEnabled != plugin.IsEnabled) @@ -117,13 +138,13 @@ public static void ValidateConsistency(PluginManifest manifest, PluginContract.I "Manifest and plugin instance have inconsistent capabilities."); } - // Check if MinHostVersion matches + // Check if MinHostVersion matches if (manifest.MinHostVersion != plugin.MinHostVersion) { throw new InvalidOperationException( "Manifest and plugin instance disagree on MinHostVersion."); } - + // Check if DependsOn matches (case-insensitive comparison) if (manifest.DependsOn.Count != plugin.DependsOn.Count) { diff --git a/src/Plugins/Solutions/ExamplePlugin/Composition/ExampleServices.cs b/src/Plugins/Solutions/ExamplePlugin/Composition/ExampleServices.cs new file mode 100644 index 0000000..e9a408b --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Composition/ExampleServices.cs @@ -0,0 +1,31 @@ +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using ExamplePlugin.Options; +using Microsoft.Extensions.DependencyInjection; +using ExamplePlugin.Middleware; +using AuthKit.Plugins.Abstractions.Contracts; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Registers the plugin's options, services, and hosted services. + /// + /// The used to register plugin services. + /// Stable plugin context including the plugin-scoped configuration section. + /// + /// The plugin binds its options from , which is + /// scoped to Plugins:authkit.example (or the plugin name when the ID section is absent). + /// The same section is available to any plugin services through the options infrastructure. + /// + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) + { + services.Configure(context.Configuration); + + services.AddSingleton(TimeProvider.System); + // Registered so the host can resolve ExampleScopedMiddleware (IAuthKitMiddleware) + // from the request service provider within the single request scope. + services.AddScoped(); + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Endpoints/ExampleEndpoints.cs b/src/Plugins/Solutions/ExamplePlugin/Endpoints/ExampleEndpoints.cs new file mode 100644 index 0000000..de906d3 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Endpoints/ExampleEndpoints.cs @@ -0,0 +1,32 @@ +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using ExamplePlugin.Grpc; +using ExamplePlugin.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Registers the reference endpoints on the application's route builder. + /// + /// The application's endpoint route builder. + /// + /// Endpoints protected by participate in the host's + /// request-aware security resolution. The scheme name must match a key returned by + /// . + /// + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/example/hello", ([FromServices] IOptions options) => + Results.Ok(new { options.Value.Greeting })) + .WithMetadata(new SecuritySchemeAttribute("example-api-key")); + + endpoints.MapGrpcService(); + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs index 56c0c21..51bbf90 100644 --- a/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs +++ b/src/Plugins/Solutions/ExamplePlugin/ExamplePlugin.cs @@ -1,23 +1,4 @@ -using AuthKit.Plugins.Abstractions; -using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; -using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; -using AuthKit.Plugins.Abstractions.Models; -using AuthKit.Plugins.Abstractions.Pipeline; -using ExamplePlugin.Authentication; -using ExamplePlugin.Grpc; -using ExamplePlugin.Hosting; -using ExamplePlugin.Middleware; -using ExamplePlugin.Options; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; namespace ExamplePlugin; @@ -30,15 +11,16 @@ namespace ExamplePlugin; /// This plugin is a living reference: it implements every hook the contract exposes so /// authors can copy the parts their plugin needs. It is intentionally small and has no /// external dependencies beyond ASP.NET Core and the AuthKit abstractions. +/// Each contract area lives in its own file — copy the file, not the class. /// /// -/// Metadata through (identity, capabilities, dependencies). -/// Configuration through ConfigureServices(IServiceCollection, AuthKitPluginContext). -/// Middleware through declarative Middlewares and ConfigureApplication. -/// Endpoints through MapEndpoints. -/// Security schemes, authentication, and authorization. -/// Structured health checks through CheckHealthAsync. -/// Lifecycle hooks and a plugin-owned hosted service. +/// Metadata through (identity, capabilities, dependencies) — this file. +/// Configuration through ConfigureServices(IServiceCollection, AuthKitPluginContext) — Composition/ExampleServices.cs. +/// Middleware through declarative Middlewares, ConfigureApplication and ConfigurePipeline — Pipeline/ExamplePipeline.cs. +/// Endpoints through MapEndpoints — Endpoints/ExampleEndpoints.cs. +/// Security schemes, authentication, and authorization — Security/ExampleSecurity.cs. +/// Structured health checks through CheckHealthAsync — Health/ExampleHealth.cs. +/// Lifecycle hooks and a plugin-owned hosted service — Lifecycle/ExampleLifecycle.cs. /// /// [PluginMetadata( @@ -59,225 +41,6 @@ namespace ExamplePlugin; isEnabled: true, minHostVersion: "0.5.0" )] -public sealed class ExamplePlugin : IAuthKitPlugin +public sealed partial class ExamplePlugin : IAuthKitPlugin { - /// - /// Registers the plugin's options, services, and hosted services. - /// - /// The used to register plugin services. - /// Stable plugin context including the plugin-scoped configuration section. - /// - /// The plugin binds its options from , which is - /// scoped to Plugins:authkit.example (or the plugin name when the ID section is absent). - /// The same section is available to any plugin services through the options infrastructure. - /// - public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) - { - services.Configure(context.Configuration); - - services.AddSingleton(TimeProvider.System); - // Registered so the host can resolve ExampleScopedMiddleware (IAuthKitMiddleware) - // from the request service provider within the single request scope. - services.AddScoped(); - } - - /// - /// Declarative middleware registrations (issue #19, C1–C5). The plugin declares - /// WHAT middleware it needs; the host owns activation, deterministic ordering - /// (Order → stable PluginId → DeclarationIndex), and pipeline insertion. - /// - public IReadOnlyList Middlewares => - [ - // Convention-based middleware, ordered first at its position. - new PluginMiddleware( - typeof(ExampleHeaderMiddleware), - AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeAuthentication, - Order: 0, - IsMiddlewareEnabled: true, - Name: "example-header"), - // DI-aware middleware (scoped services from the request scope). - new PluginMiddleware( - typeof(ExampleScopedMiddleware), - AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.AfterAuthorization, - Order: 10, - IsMiddlewareEnabled: true, - Name: "example-scoped"), - // Disabled entry: host skips it without side effects or ordering impact. - new PluginMiddleware( - typeof(ExampleHeaderMiddleware), - AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, - Order: 0, - IsMiddlewareEnabled: false, - Name: "example-disabled"), - // gRPC interceptor: composed into the host interceptor chain. - new PluginMiddleware( - typeof(ExampleLoggingInterceptor), - AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, - Order: 0, - IsMiddlewareEnabled: true, - Name: "example-grpc-logging", - Transport: AuthKitTransport.Grpc), - ]; - - /// - /// Registers the reference endpoints on the application's route builder. - /// - /// The application's endpoint route builder. - /// - /// Endpoints protected by participate in the host's - /// request-aware security resolution. The scheme name must match a key returned by - /// . - /// - public void MapEndpoints(IEndpointRouteBuilder endpoints) - { - endpoints.MapGet("/example/hello", ([FromServices] IOptions options) => - Results.Ok(new { options.Value.Greeting })) - .WithMetadata(new SecuritySchemeAttribute("example-api-key")); - - endpoints.MapGrpcService(); - } - - /// - /// Configures the plugin application middleware on the actual host application. - /// - /// The application's pipeline builder. - public void ConfigureApplication(IApplicationBuilder application) - { - application.Use(async (HttpContext context, RequestDelegate next) => - { - context.Response.Headers.Append("X-Example-Plugin", "1.0.0"); - await next(context); - }); - } - - /// - /// Demonstrates the plugin pipeline positioning hook. - /// - public PluginPipelinePosition PipelinePosition => PluginPipelinePosition.BeforeAuthentication; - - /// - /// Registers a custom pipeline hook at the selected stage. - /// - public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position) - { - if (position != PluginPipelinePosition.BeforeAuthentication) - return; - - application.Use(async (context, next) => - { - context.Items["example.pipeline.position"] = position; - await next(context); - }); - } - - /// - /// Performs a structured health check of the plugin's dependencies. - /// - /// The root service provider of the host application. - /// A token that can cancel the health check. - /// Structured health results for each checked capability. - /// - /// The reference implementation always reports healthy to keep the example self-contained; - /// real plugins resolve their dependencies from and report - /// degraded or unhealthy states with matching reasons, data, and tags. - /// - public Task> CheckHealthAsync( - IServiceProvider services, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - var timeProvider = services.GetService() ?? TimeProvider.System; - - return Task.FromResult>( - [ - new( - PluginHealthStatus.Healthy, - "ExamplePlugin is operational.", - new Dictionary - { - ["capability"] = "example", - ["utcNow"] = timeProvider.GetUtcNow().ToString("O") - }, - ["example", "readiness"]) - ]); - } - - /// - /// Contributes the plugin's OpenAPI security scheme metadata. - /// - /// - /// A readonly dictionary keyed by security scheme name. Keys must match the - /// descriptor's . - /// - public IReadOnlyDictionary GetSecuritySchemes() => - new Dictionary - { - ["example-api-key"] = new() - { - Name = "example-api-key", - Type = AuthKitSecuritySchemeType.ApiKey, - In = AuthKitApiKeyLocation.Header, - CredentialName = "X-Example-Api-Key", - Description = "Reference API key scheme contributed by ExamplePlugin." - } - }; - - /// - /// Registers the reference API key authentication scheme on the host builder. - /// - /// The host authentication builder. - /// - /// The scheme is registered without changing the host default scheme. See - /// for the minimal handler. - /// - public void ConfigureAuthentication(AuthenticationBuilder builder) - { - builder.AddScheme( - ExampleApiKeyAuthenticationHandler.SchemeName, - _ => { }); - } - - /// - /// Registers a reference authorization policy used by plugin endpoints. - /// - /// The host authorization options. - /// - /// Policy names are globally significant; use namespaced names to avoid collisions - /// with the host or other plugins. - /// - public void ConfigureAuthorization(AuthorizationOptions options) - { - options.AddPolicy( - "example.read", - policy => policy - .RequireAuthenticatedUser() - .AddAuthenticationSchemes(ExampleApiKeyAuthenticationHandler.SchemeName)); - } - - /// - /// Initializes plugin runtime resources before the host is considered started. - /// - public Task OnStartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Notifies the plugin after the host has started successfully. - /// - public Task OnStartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Releases plugin runtime resources during a graceful host shutdown. - /// - public Task OnStoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - /// - /// Returns the plugin-owned hosted services registered in the host DI container. - /// - /// - /// The host registers each service returned here as a singleton - /// and starts and stops it with the application. - /// - public IReadOnlyList GetHostedServices() => [new ExampleBackgroundService( - new Microsoft.Extensions.Logging.Abstractions.NullLogger(), - TimeProvider.System)]; -} \ No newline at end of file +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Health/ExampleHealth.cs b/src/Plugins/Solutions/ExamplePlugin/Health/ExampleHealth.cs new file mode 100644 index 0000000..4e2ef23 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Health/ExampleHealth.cs @@ -0,0 +1,41 @@ +using AuthKit.Plugins.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Performs a structured health check of the plugin's dependencies. + /// + /// The root service provider of the host application. + /// A token that can cancel the health check. + /// Structured health results for each checked capability. + /// + /// The reference implementation always reports healthy to keep the example self-contained; + /// real plugins resolve their dependencies from and report + /// degraded or unhealthy states with matching reasons, data, and tags. + /// + public Task> CheckHealthAsync( + IServiceProvider services, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var timeProvider = services.GetService() ?? TimeProvider.System; + + return Task.FromResult>( + [ + new( + PluginHealthStatus.Healthy, + "ExamplePlugin is operational.", + new Dictionary + { + ["capability"] = "example", + ["utcNow"] = timeProvider.GetUtcNow().ToString("O") + }, + ["example", "readiness"]) + ]); + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Lifecycle/ExampleLifecycle.cs b/src/Plugins/Solutions/ExamplePlugin/Lifecycle/ExampleLifecycle.cs new file mode 100644 index 0000000..cf9dba7 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Lifecycle/ExampleLifecycle.cs @@ -0,0 +1,35 @@ +using ExamplePlugin.Hosting; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Initializes plugin runtime resources before the host is considered started. + /// + public Task OnStartingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Notifies the plugin after the host has started successfully. + /// + public Task OnStartedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Releases plugin runtime resources during a graceful host shutdown. + /// + public Task OnStoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Returns the plugin-owned hosted services registered in the host DI container. + /// + /// + /// The host registers each service returned here as a singleton + /// and starts and stops it with the application. + /// + public IReadOnlyList GetHostedServices() => [new ExampleBackgroundService( + new NullLogger(), + TimeProvider.System)]; +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Pipeline/ExamplePipeline.cs b/src/Plugins/Solutions/ExamplePlugin/Pipeline/ExamplePipeline.cs new file mode 100644 index 0000000..c3b7494 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Pipeline/ExamplePipeline.cs @@ -0,0 +1,82 @@ +using AuthKit.Plugins.Abstractions.Pipeline; +using ExamplePlugin.Grpc; +using ExamplePlugin.Middleware; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Declarative middleware registrations (issue #19, C1–C5). The plugin declares + /// WHAT middleware it needs; the host owns activation, deterministic ordering + /// (Order → stable PluginId → DeclarationIndex), and pipeline insertion. + /// + public IReadOnlyList Middlewares => + [ + // Convention-based middleware, ordered first at its position. + new PluginMiddleware( + typeof(ExampleHeaderMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeAuthentication, + Order: 0, + IsMiddlewareEnabled: true, + Name: "example-header"), + // DI-aware middleware (scoped services from the request scope). + new PluginMiddleware( + typeof(ExampleScopedMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.AfterAuthorization, + Order: 10, + IsMiddlewareEnabled: true, + Name: "example-scoped"), + // Disabled entry: host skips it without side effects or ordering impact. + new PluginMiddleware( + typeof(ExampleHeaderMiddleware), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, + Order: 0, + IsMiddlewareEnabled: false, + Name: "example-disabled"), + // gRPC interceptor: composed into the host interceptor chain. + new PluginMiddleware( + typeof(ExampleLoggingInterceptor), + AuthKit.Plugins.Abstractions.Pipeline.PipelinePosition.BeforeEndpoints, + Order: 0, + IsMiddlewareEnabled: true, + Name: "example-grpc-logging", + Transport: AuthKitTransport.Grpc), + ]; + + /// + /// Configures the plugin application middleware on the actual host application. + /// + /// The application's pipeline builder. + public void ConfigureApplication(IApplicationBuilder application) + { + application.Use(async (HttpContext context, RequestDelegate next) => + { + context.Response.Headers.Append("X-Example-Plugin", "1.0.0"); + await next(context); + }); + } + + /// + /// Demonstrates the plugin pipeline positioning hook. + /// + public PluginPipelinePosition PipelinePosition => PluginPipelinePosition.BeforeAuthentication; + + /// + /// Registers a custom pipeline hook at the selected stage. + /// + public void ConfigurePipeline(IApplicationBuilder application, PluginPipelinePosition position) + { + if (position != PluginPipelinePosition.BeforeAuthentication) + return; + + application.Use(async (context, next) => + { + context.Items["example.pipeline.position"] = position; + await next(context); + }); + } +} diff --git a/src/Plugins/Solutions/ExamplePlugin/Security/ExampleSecurity.cs b/src/Plugins/Solutions/ExamplePlugin/Security/ExampleSecurity.cs new file mode 100644 index 0000000..e40fd95 --- /dev/null +++ b/src/Plugins/Solutions/ExamplePlugin/Security/ExampleSecurity.cs @@ -0,0 +1,64 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; +using ExamplePlugin.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace ExamplePlugin; + +public sealed partial class ExamplePlugin : IAuthKitPlugin +{ + /// + /// Contributes the plugin's OpenAPI security scheme metadata. + /// + /// + /// A readonly dictionary keyed by security scheme name. Keys must match the + /// descriptor's . + /// + public IReadOnlyDictionary GetSecuritySchemes() => + new Dictionary + { + ["example-api-key"] = new() + { + Name = "example-api-key", + Type = AuthKitSecuritySchemeType.ApiKey, + In = AuthKitApiKeyLocation.Header, + CredentialName = "X-Example-Api-Key", + Description = "Reference API key scheme contributed by ExamplePlugin." + } + }; + + /// + /// Registers the reference API key authentication scheme on the host builder. + /// + /// The host authentication builder. + /// + /// The scheme is registered without changing the host default scheme. See + /// for the minimal handler. + /// + public void ConfigureAuthentication(AuthenticationBuilder builder) + { + builder.AddScheme( + ExampleApiKeyAuthenticationHandler.SchemeName, + _ => { }); + } + + /// + /// Registers a reference authorization policy used by plugin endpoints. + /// + /// The host authorization options. + /// + /// Policy names are globally significant; use namespaced names to avoid collisions + /// with the host or other plugins. + /// + public void ConfigureAuthorization(AuthorizationOptions options) + { + options.AddPolicy( + "example.read", + policy => policy + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(ExampleApiKeyAuthenticationHandler.SchemeName)); + } +} diff --git a/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj index 965cc5d..722d4bc 100644 --- a/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj +++ b/tests/Host.IntegrationTests/AuthKit.Host.IntegrationTests.csproj @@ -29,21 +29,23 @@ + - <_DevTokensStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/DevTokens/bin/$(Configuration)/net10.0/DevTokens.dll" /> + <_DevTokensStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/DevTokens/bin/$(Configuration)/net10.0/*.dll" /> <_DevTokensStageFiles Include="$(MSBuildProjectDirectory)/../../src/Plugins/Solutions/DevTokens/bin/$(Configuration)/net10.0/DevTokens.pdb" /> + \ No newline at end of file diff --git a/tests/Host/DependencyGraphTests.cs b/tests/Host/DependencyGraphTests.cs new file mode 100644 index 0000000..eab3106 --- /dev/null +++ b/tests/Host/DependencyGraphTests.cs @@ -0,0 +1,126 @@ +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins.Loading; +using Xunit; + +namespace AuthKit.Host.Tests; + +public sealed class DependencyGraphTests +{ + private static PluginManifest Manifest(string id, int priority = 0, params string[] dependsOn) => + new() + { + Id = id, + Name = id, + Version = new SemanticVersion(1, 0, 0), + Priority = priority, + DependsOn = dependsOn, + Capabilities = new HashSet(StringComparer.OrdinalIgnoreCase), + }; + + private static DiscoveredPlugin Discovered(PluginManifest manifest, string? location = null) => + new() { Manifest = manifest, Location = location ?? $"/plugins/{manifest.Id}" }; + + private static List<(DiscoveredPlugin Candidate, int RegistrationOrder)> Indexed(params DiscoveredPlugin[] candidates) => + candidates.Select((candidate, index) => (candidate, index)).ToList(); + + private static List OrderIds(IReadOnlyList ordered) => + ordered.Select(candidate => candidate.Manifest!.Id).ToList(); + + [Fact] + public void IssueExample_SortsABC() + { + var indexed = Indexed( + Discovered(Manifest("test.a", -100)), + Discovered(Manifest("test.b", -200, "test.a")), + Discovered(Manifest("test.c", 0))); + + DependencyGraph.Validate(indexed, new HashSet(["test.a", "test.b", "test.c"], StringComparer.OrdinalIgnoreCase)); + + Assert.Equal(["test.a", "test.b", "test.c"], OrderIds(DependencyGraph.Sort(indexed))); + } + + [Fact] + public void AltPriorities_SortCAB() + { + var indexed = Indexed( + Discovered(Manifest("test.a", 100)), + Discovered(Manifest("test.b", 0, "test.a")), + Discovered(Manifest("test.c", 0))); + + Assert.Equal(["test.c", "test.a", "test.b"], OrderIds(DependencyGraph.Sort(indexed))); + } + + [Fact] + public void DependenciesComeFirstRegardlessOfDiscoveryOrder() + { + var indexed = Indexed( + Discovered(Manifest("test.b", 0, "test.a")), + Discovered(Manifest("test.a", 0))); + + Assert.Equal(["test.a", "test.b"], OrderIds(DependencyGraph.Sort(indexed))); + } + + [Fact] + public void Cycle_ThrowsStartupError() + { + var indexed = Indexed( + Discovered(Manifest("test.a", 0, "test.b")), + Discovered(Manifest("test.b", 0, "test.a"))); + + var exception = Assert.Throws(() => + DependencyGraph.Validate(indexed, new HashSet(["test.a", "test.b"], StringComparer.OrdinalIgnoreCase))); + + Assert.Contains("cycle", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ThreeCycle_ThrowsStartupError() + { + var indexed = Indexed( + Discovered(Manifest("test.a", 0, "test.b")), + Discovered(Manifest("test.b", 0, "test.c")), + Discovered(Manifest("test.c", 0, "test.a"))); + + var exception = Assert.Throws(() => + DependencyGraph.Validate(indexed, new HashSet(["test.a", "test.b", "test.c"], StringComparer.OrdinalIgnoreCase))); + + Assert.Contains("cycle", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SelfCycle_ThrowsStartupError() + { + var indexed = Indexed(Discovered(Manifest("test.a", 0, "test.a"))); + + Assert.Throws(() => + DependencyGraph.Validate(indexed, new HashSet(["test.a"], StringComparer.OrdinalIgnoreCase))); + } + + [Fact] + public void UnknownDependency_ThrowsStartupError() + { + var indexed = Indexed(Discovered(Manifest("test.a", 0, "test.ghost"))); + + var exception = Assert.Throws(() => + DependencyGraph.Validate(indexed, new HashSet(["test.a"], StringComparer.OrdinalIgnoreCase))); + + Assert.Contains("test.ghost", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Sort_IsDeterministic() + { + var first = OrderIds(DependencyGraph.Sort(Indexed( + Discovered(Manifest("test.b", 0, "test.a")), + Discovered(Manifest("test.c", 0)), + Discovered(Manifest("test.a", 0))))); + var second = OrderIds(DependencyGraph.Sort(Indexed( + Discovered(Manifest("test.b", 0, "test.a")), + Discovered(Manifest("test.c", 0)), + Discovered(Manifest("test.a", 0))))); + + Assert.Equal(first, second); + Assert.Equal(["test.c", "test.a", "test.b"], first); + } +} diff --git a/tests/Host/LifecycleRuleTests.cs b/tests/Host/LifecycleRuleTests.cs new file mode 100644 index 0000000..7a177cb --- /dev/null +++ b/tests/Host/LifecycleRuleTests.cs @@ -0,0 +1,137 @@ +using AuthKit.PluginContractValidator.Rules; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; +using AuthKit.Plugins.Abstractions.Pipeline; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; +using ValidatorLoadedPlugin = AuthKit.PluginContractValidator.Core.LoadedPlugin; + +namespace AuthKit.Host.Tests; + +public sealed class LifecycleRuleTests +{ + private readonly LifecycleRule _rule = new(); + + [Fact] + public void RuleName_IsLifecycle() => Assert.Equal("Lifecycle", _rule.Name); + + [Fact] + public async Task ValidPlugin_IsAccepted() + { + var errors = await ValidateAsync(new HealthyPlugin()); + + Assert.Empty(errors); + } + + [Fact] + public async Task NullHostedServices_AreRejected() + { + var errors = await ValidateAsync(new NullServicesPlugin()); + + Assert.Contains(errors, error => + error.Contains("GetHostedServices", StringComparison.Ordinal) + && error.Contains("null", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task NullServiceEntry_IsRejected() + { + var errors = await ValidateAsync(new NullEntryPlugin()); + + Assert.Contains(errors, error => + error.Contains("GetHostedServices", StringComparison.Ordinal) + && error.Contains("null", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task DuplicateServices_AreRejected() + { + var errors = await ValidateAsync(new DuplicateServicesPlugin()); + + Assert.Contains(errors, error => + error.Contains("GetHostedServices", StringComparison.Ordinal) + && error.Contains("duplicate", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task ThrowingHostedServices_AreReported() + { + var errors = await ValidateAsync(new ThrowingServicesPlugin()); + + Assert.Contains(errors, error => + error.Contains("GetHostedServices", StringComparison.Ordinal) + && error.Contains(nameof(InvalidOperationException), StringComparison.Ordinal)); + } + + [Fact] + public async Task UndefinedPipelinePosition_IsRejected() + { + var errors = await ValidateAsync(new BadPositionPlugin()); + + Assert.Contains(errors, error => + error.Contains("PipelinePosition", StringComparison.Ordinal)); + } + + private async Task> ValidateAsync(IAuthKitPlugin plugin) + { + var loaded = new ValidatorLoadedPlugin(plugin, typeof(LifecycleRuleTests).Assembly); + return await _rule.ValidateAsync(loaded); + } + + [PluginMetadata("healthy-plugin", "1.0.0", [], [], [], name: "Healthy", description: "Test plugin")] + private sealed class HealthyPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + } + + [PluginMetadata("null-services-plugin", "1.0.0", [], [], [], name: "NullServices", description: "Test plugin")] + private sealed class NullServicesPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public IReadOnlyList GetHostedServices() => null!; + } + + [PluginMetadata("null-entry-plugin", "1.0.0", [], [], [], name: "NullEntry", description: "Test plugin")] + private sealed class NullEntryPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public IReadOnlyList GetHostedServices() => [null!]; + } + + [PluginMetadata("duplicate-services-plugin", "1.0.0", [], [], [], name: "Duplicates", description: "Test plugin")] + private sealed class DuplicateServicesPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public IReadOnlyList GetHostedServices() => + [new NoopService(), new NoopService()]; + + private sealed class NoopService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + } + + [PluginMetadata("throwing-services-plugin", "1.0.0", [], [], [], name: "Throwing", description: "Test plugin")] + private sealed class ThrowingServicesPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public IReadOnlyList GetHostedServices() => + throw new InvalidOperationException("boom"); + } + + [PluginMetadata("bad-position-plugin", "1.0.0", [], [], [], name: "BadPosition", description: "Test plugin")] + private sealed class BadPositionPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public PluginPipelinePosition PipelinePosition => (PluginPipelinePosition)999; + } +} diff --git a/tests/Host/PluginConfigurationInvokerTests.cs b/tests/Host/PluginConfigurationInvokerTests.cs index 3667b0a..063b283 100644 --- a/tests/Host/PluginConfigurationInvokerTests.cs +++ b/tests/Host/PluginConfigurationInvokerTests.cs @@ -15,17 +15,6 @@ namespace AuthKit.Host.Tests; /// public sealed class PluginConfigurationInvokerTests { - [Fact] - public void LegacyPlugin_UsesLegacyOverloadOnce() - { - var plugin = new LegacyPlugin(); - var builder = CreateBuilder(); - - PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration); - - Assert.Equal(1, plugin.LegacyCalls); - } - [Fact] public void BuilderPlugin_ReceivesActualBuilderOnce() { @@ -36,7 +25,6 @@ public void BuilderPlugin_ReceivesActualBuilderOnce() Assert.Same(builder.Services, plugin.Services); Assert.Equal(1, plugin.BuilderCalls); - Assert.Equal(0, plugin.LegacyCalls); } [Fact] @@ -68,7 +56,6 @@ public void ContextOverload_HasPriorityAndIsInvokedOnlyOnce() Assert.Equal(1, plugin.ContextCalls); Assert.Equal(0, plugin.BuilderCalls); - Assert.Equal(0, plugin.LegacyCalls); } [Fact] @@ -88,6 +75,18 @@ public void MultiplePlugins_ReceiveIndependentContexts() Assert.NotSame(first.Context.Configuration, second.Context.Configuration); } + [Fact] + public void PluginWithoutOverload_Throws() + { + var plugin = new BarePlugin(); + var builder = CreateBuilder(); + + var exception = Assert.Throws(() => + PluginConfigurationInvoker.Configure(plugin, builder, builder.Configuration)); + + Assert.Contains("bare-plugin", exception.Message, StringComparison.Ordinal); + } + private static HostApplicationBuilder CreateBuilder(params (string Key, string Value)[] values) { var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(); @@ -100,23 +99,12 @@ private static HostApplicationBuilder CreateBuilder(params (string Key, string V return builder; } - [PluginMetadata("legacy-plugin", "1.0.0", [], [], [], name: "Legacy Plugin", description: "Test plugin")] - private sealed class LegacyPlugin : IAuthKitPlugin - { - public int LegacyCalls { get; private set; } - - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; - } - [PluginMetadata("builder-plugin", "1.0.0", [], [], [], name: "Builder Plugin", description: "Test plugin")] private sealed class BuilderPlugin : IAuthKitPlugin { - public int LegacyCalls { get; private set; } public int BuilderCalls { get; private set; } public IServiceCollection? Services { get; private set; } - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; - public void ConfigureServices(IHostApplicationBuilder builder, IConfiguration configuration) { BuilderCalls++; @@ -141,11 +129,9 @@ public void ConfigureServices(IServiceCollection services, AuthKitPluginContext [PluginMetadata("both-plugin", "1.0.0", [], [], [], name: "Both Plugin", description: "Test plugin")] private sealed class ContextAndBuilderPlugin : IAuthKitPlugin { - public int LegacyCalls { get; private set; } public int BuilderCalls { get; private set; } public int ContextCalls { get; private set; } - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) => LegacyCalls++; public void ConfigureServices(IHostApplicationBuilder builder, IConfiguration configuration) => BuilderCalls++; public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => ContextCalls++; } @@ -157,4 +143,9 @@ private sealed class OtherContextPlugin : IAuthKitPlugin public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) => Context = context; } -} \ No newline at end of file + + [PluginMetadata("bare-plugin", "1.0.0", [], [], [], name: "Bare Plugin", description: "Test plugin")] + private sealed class BarePlugin : IAuthKitPlugin + { + } +} diff --git a/tests/Host/PluginContractValidatorTests.cs b/tests/Host/PluginContractValidatorTests.cs index cad070b..14a6e50 100644 --- a/tests/Host/PluginContractValidatorTests.cs +++ b/tests/Host/PluginContractValidatorTests.cs @@ -29,7 +29,7 @@ private sealed class FakePlugin(AuthKitSecuritySchemeDescriptor descriptor) : IA public string Name => "Fake"; public SemanticVersion Version => new(1, 0, 0); - public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { } + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } public IReadOnlyDictionary GetSecuritySchemes() => _schemes; } diff --git a/tests/Host/PluginDiscoveryTests.cs b/tests/Host/PluginDiscoveryTests.cs new file mode 100644 index 0000000..f71e88c --- /dev/null +++ b/tests/Host/PluginDiscoveryTests.cs @@ -0,0 +1,450 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins.Loading; +using Host.Plugins.Loading.Gate; +using Host.Plugins.Loading.Manifest; +using Host.Plugins.Loading.Pipeline; +using Host.Plugins.Loading.Results; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContractLoadedPlugin = AuthKit.Plugins.Abstractions.Contracts.Discovery.LoadedPlugin; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace AuthKit.Host.Tests; + +public sealed class PluginDiscoveryTests : IDisposable +{ + private readonly List _tempDirs = []; + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(root); + _tempDirs.Add(root); + return root; + } + + private static PluginManifest Manifest( + string id = "test.fake", + string version = "1.0.0", + bool enabled = true, + string? minHost = null) => + new() + { + Id = id, + Name = "Fake", + Version = SemanticVersion.Parse(version), + IsEnabled = enabled, + MinHostVersion = minHost is null ? null : SemanticVersion.Parse(minHost), + Capabilities = new HashSet(StringComparer.OrdinalIgnoreCase), + }; + + // ---------- DirectoryPluginDiscoverer ---------- + + [Fact] + public async Task Discoverer_ReadsManifestsWithoutLoadingAssemblies() + { + var root = NewRoot(); + var withJson = Directory.CreateDirectory(Path.Combine(root, "a")).FullName; + var withManifest = Directory.CreateDirectory(Path.Combine(root, "b")).FullName; + await File.WriteAllTextAsync(Path.Combine(withJson, "plugin.json"), + """{"Id":"test.a","Name":"A","Version":"1.0.0"}"""); + await File.WriteAllTextAsync(Path.Combine(withManifest, "manifest.json"), + """{"Id":"test.b","Name":"B","Version":"2.0.0"}"""); + + var discoverer = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + var found = new List(); + await foreach (var candidate in discoverer.DiscoverAsync()) + found.Add(candidate); + + Assert.Equal(2, found.Count); + Assert.Equal("test.a", found.First(c => c.Location == withJson).Manifest!.Id); + Assert.Equal("test.b", found.First(c => c.Location == withManifest).Manifest!.Id); + Assert.All(found, c => Assert.Null(c.DiscoveryError)); + } + + [Fact] + public async Task Discoverer_MissingManifest_IsDiscoveryError() + { + var root = NewRoot(); + Directory.CreateDirectory(Path.Combine(root, "nomani")); + + var discoverer = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + var found = new List(); + await foreach (var candidate in discoverer.DiscoverAsync()) + found.Add(candidate); + + var single = Assert.Single(found); + Assert.NotNull(single.DiscoveryError); + } + + [Fact] + public async Task Discoverer_ReportsCorruptManifestWithoutThrowing() + { + var root = NewRoot(); + var broken = Directory.CreateDirectory(Path.Combine(root, "broken")).FullName; + await File.WriteAllTextAsync(Path.Combine(broken, "manifest.json"), "{not json"); + + var discoverer = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + var found = new List(); + await foreach (var candidate in discoverer.DiscoverAsync()) + found.Add(candidate); + + var single = Assert.Single(found); + Assert.Null(single.Manifest); + Assert.NotNull(single.DiscoveryError); + } + + [Fact] + public async Task Discoverer_MissingRoot_YieldsNothing() + { + var discoverer = new DirectoryPluginDiscoverer( + Path.Combine(NewRoot(), "nope"), NullLogger.Instance); + + var count = 0; + await foreach (var _ in discoverer.DiscoverAsync()) + count++; + + Assert.Equal(0, count); + } + + // ---------- ManifestValidator ---------- + + [Fact] + public void Validator_RejectsEmptyIdAndName() + { + var errors = ManifestValidator.Validate(new PluginManifest { Id = "", Name = " " }); + + Assert.Contains(errors, e => e.Contains("Id")); + Assert.Contains(errors, e => e.Contains("Name")); + } + + [Fact] + public void Validator_RejectsBadCollections() + { + var errors = ManifestValidator.Validate(new PluginManifest + { + Id = "x", + Name = "X", + Tags = ["ok", " "], + DependsOn = ["y", "y", "x"], + }); + + Assert.Contains(errors, e => e.Contains("Tags")); + Assert.Contains(errors, e => e.Contains("duplicates")); + Assert.Contains(errors, e => e.Contains("itself")); + } + + [Fact] + public void Validator_FindsDuplicateIdsCaseInsensitively() + { + var duplicates = ManifestValidator.FindDuplicateIds([ + Manifest("test.a"), Manifest("TEST.A"), Manifest("test.b"), + ]); + + Assert.Equal(["test.a"], duplicates.Select(d => d.ToLowerInvariant()).Distinct()); + } + + // ---------- CompatibilityGate ---------- + + [Theory] + [InlineData("2.3.9", false)] + [InlineData("2.4.0", true)] + [InlineData("2.4.1", true)] + [InlineData("3.0.0", true)] + public void Gate_MinHostVersionMatrix(string host, bool accepted) + { + var (verdict, _) = CompatibilityGate.Check( + Manifest(minHost: "2.4.0"), SemanticVersion.Parse(host)); + + Assert.Equal(accepted ? GateVerdict.Accept : GateVerdict.Reject, verdict); + } + + [Theory] + [InlineData("1.2.3", "1.2.3-alpha", true)] + [InlineData("1.2.3-alpha.1", "1.2.3-alpha", true)] + [InlineData("1.2.3-beta", "1.2.3-alpha.1", true)] + [InlineData("1.2.3-alpha", "1.2.3", false)] + public void Gate_PrereleaseOrdering(string min, string host, bool reject) + { + var (verdict, _) = CompatibilityGate.Check( + Manifest(minHost: min), SemanticVersion.Parse(host)); + + Assert.Equal(reject ? GateVerdict.Reject : GateVerdict.Accept, verdict); + } + + [Fact] + public void Gate_BuildMetadataIgnoredForPrecedence() + { + var (verdict, _) = CompatibilityGate.Check( + Manifest(minHost: "2.4.0+build"), SemanticVersion.Parse("2.4.0")); + + Assert.Equal(GateVerdict.Accept, verdict); + } + + [Fact] + public void Gate_NoMinHostVersion_IsUnaffected() + { + var (verdict, _) = CompatibilityGate.Check(Manifest(), SemanticVersion.Parse("0.0.1")); + + Assert.Equal(GateVerdict.Accept, verdict); + } + + [Fact] + public void Gate_Enabled_IsAccepted() + { + var (verdict, reason) = CompatibilityGate.Check(Manifest(), SemanticVersion.Parse("1.0.0")); + + Assert.Equal(GateVerdict.Accept, verdict); + Assert.Null(reason); + } + + [Fact] + public void Gate_Disabled_Skips() + { + var (verdict, reason) = CompatibilityGate.Check(Manifest(enabled: false), SemanticVersion.Parse("1.0.0")); + + Assert.Equal(GateVerdict.SkipDisabled, verdict); + Assert.NotNull(reason); + } + + // ---------- Pipeline with swappable discovery/loading ---------- + + private sealed class StubDiscoverer(IReadOnlyList items) : IPluginDiscoverer + { + public async IAsyncEnumerable DiscoverAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + } + + private sealed class StubLoader(Func load) : IPluginLoader + { + public Task> LoadAsync( + IReadOnlyList plugins, + CancellationToken cancellationToken = default) => + Task.FromResult>( + plugins.Select(load).Where(p => p is not null).Cast().ToList()); + } + + [PluginMetadata("test.fake", "1.0.0", [], [], [], name: "Fake", description: "Fake plugin")] + private sealed class FakePlugin : IAuthKitPlugin + { + } + + [PluginMetadata("test.fake", "1.0.0", [], [], [], name: "Fake", description: "Fake plugin")] + private sealed class BadSchemePlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + + public IReadOnlyDictionary GetSecuritySchemes() => + new Dictionary + { + ["bad"] = new() + { + Name = "bad", + Type = (AuthKitSecuritySchemeType)999, + In = AuthKitApiKeyLocation.Header, + Description = "Invalid scheme.", + }, + }; + } + + private static DiscoveredPlugin Discovered( + PluginManifest? manifest, + string location = "/plugins/fake", + string? error = null) => + new() { Manifest = manifest, Location = location, DiscoveryError = error }; + + private static ContractLoadedPlugin Loaded(PluginManifest manifest, IAuthKitPlugin? instance = null) => + new() + { + Manifest = manifest, + PluginType = (instance ?? new FakePlugin()).GetType(), + Instance = instance ?? new FakePlugin(), + LoadContext = System.Runtime.Loader.AssemblyLoadContext.Default, + }; + + private static PluginLoadingPipeline Pipeline( + IReadOnlyList discovered, + Func load, + string host = "1.0.0") => + new(new StubDiscoverer(discovered), new StubLoader(load), NullLogger.Instance, SemanticVersion.Parse(host)); + + [Fact] + public async Task Pipeline_AcceptsCompatibleManifestPlugin() + { + var pipeline = Pipeline([Discovered(Manifest())], candidate => Loaded(candidate.Manifest!)); + + var result = await pipeline.RunAsync(); + + var single = Assert.Single(result.Loaded); + Assert.Equal("test.fake", single.Plugin.Id); + Assert.Equal("test.fake", single.Manifest?.Id); + Assert.Empty(result.Issues); + } + + [Fact] + public async Task Pipeline_SkipsDisabledBeforeLoading() + { + var loaderCalls = 0; + var pipeline = Pipeline([Discovered(Manifest(enabled: false))], candidate => + { + loaderCalls++; + return Loaded(candidate.Manifest!); + }); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Equal(0, loaderCalls); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.SkippedDisabled, issue.Outcome); + } + + [Fact] + public async Task Pipeline_RejectsIncompatibleHost() + { + var loaderCalls = 0; + var pipeline = Pipeline([Discovered(Manifest(minHost: "2.0.0"))], candidate => + { + loaderCalls++; + return Loaded(candidate.Manifest!); + }); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Equal(0, loaderCalls); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.Rejected, issue.Outcome); + } + + [Fact] + public async Task Pipeline_RejectsDuplicateIdsDeterministically() + { + var pipeline = Pipeline( + [ + Discovered(Manifest(), "/plugins/b"), + Discovered(Manifest(), "/plugins/a"), + ], + candidate => Loaded(candidate.Manifest!)); + + var result = await pipeline.RunAsync(); + + var single = Assert.Single(result.Loaded); + Assert.Equal("/plugins/a", single.PluginDirectory); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.Invalid, issue.Outcome); + Assert.Contains("uplicate", issue.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Pipeline_RejectsManifestInstanceMismatch() + { + var pipeline = Pipeline( + [Discovered(Manifest(id: "test.other"))], + candidate => Loaded(candidate.Manifest!)); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.Invalid, issue.Outcome); + } + + [Fact] + public async Task Pipeline_RejectsDiscoveryErrorsWithoutLoading() + { + var loaderCalls = 0; + var pipeline = Pipeline([Discovered(null, error: "boom")], candidate => + { + loaderCalls++; + return Loaded(candidate.Manifest!); + }); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Equal(0, loaderCalls); + Assert.Equal(PluginOutcome.Invalid, Assert.Single(result.Issues).Outcome); + } + + [Fact] + public async Task Pipeline_RejectsMissingManifest() + { + var root = NewRoot(); + var dir = Directory.CreateDirectory(Path.Combine(root, "nomani")).FullName; + var discoverer = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + var loader = new DefaultPluginLoader(NullLogger.Instance); + var pipeline = new PluginLoadingPipeline(discoverer, loader, NullLogger.Instance, SemanticVersion.Parse("1.0.0")); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.Invalid, issue.Outcome); + Assert.Equal(dir, issue.Location); + } + + [Fact] + public async Task Pipeline_RejectsContractViolations() + { + var pipeline = Pipeline( + [Discovered(Manifest())], + _ => Loaded(Manifest(), new BadSchemePlugin())); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + var issue = Assert.Single(result.Issues); + Assert.Equal(PluginOutcome.Invalid, issue.Outcome); + } + + [Fact] + public async Task Pipeline_ReportsLoaderFailuresAsInvalid() + { + var pipeline = Pipeline([Discovered(Manifest())], _ => null); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Equal(PluginOutcome.Invalid, Assert.Single(result.Issues).Outcome); + } + + [Fact] + public void LoadContext_SharesContracts() + { + var field = typeof(global::Host.Plugins.Loading.PluginLoadContext).GetField( + "SharedContracts", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + + var shared = Assert.IsAssignableFrom>( + field?.GetValue(null)); + + Assert.Contains("AuthKit.Plugins.Abstractions", shared, StringComparer.OrdinalIgnoreCase); + Assert.Contains("Grpc.Core.Api", shared, StringComparer.OrdinalIgnoreCase); + Assert.Contains("Google.Protobuf", shared, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/tests/Host/PluginPipelineBehaviorTests.cs b/tests/Host/PluginPipelineBehaviorTests.cs new file mode 100644 index 0000000..bb78407 --- /dev/null +++ b/tests/Host/PluginPipelineBehaviorTests.cs @@ -0,0 +1,383 @@ +using AuthKit.Plugins.Abstractions; +using AuthKit.Plugins.Abstractions.Contracts; +using AuthKit.Plugins.Abstractions.Contracts.Discovery; +using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using AuthKit.Plugins.Abstractions.Models; +using Host.Plugins.Loading; +using Host.Plugins.Loading.Gate; +using Host.Plugins.Loading.Pipeline; +using Host.Plugins.Loading.Results; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContractLoadedPlugin = AuthKit.Plugins.Abstractions.Contracts.Discovery.LoadedPlugin; +using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; + +namespace AuthKit.Host.Tests; + +public sealed class PluginPipelineBehaviorTests : IDisposable +{ + private readonly List _tempDirs = []; + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + + private string NewRoot() + { + var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(root); + _tempDirs.Add(root); + return root; + } + + private static PluginManifest Manifest( + string id, + int priority = 0, + string[]? dependsOn = null, + bool enabled = true) => + new() + { + Id = id, + Name = id, + Version = new SemanticVersion(1, 0, 0), + Priority = priority, + DependsOn = dependsOn ?? [], + IsEnabled = enabled, + Capabilities = new HashSet(StringComparer.OrdinalIgnoreCase), + }; + + private sealed class DepPlugin(PluginManifest manifest) : IAuthKitPlugin + { + public string Id => manifest.Id; + public string Name => manifest.Name; + public SemanticVersion Version => manifest.Version; + public IReadOnlyList DependsOn => manifest.DependsOn; + public bool IsEnabled => manifest.IsEnabled; + public SemanticVersion? MinHostVersion => manifest.MinHostVersion; + public IReadOnlySet Capabilities => + System.Collections.Immutable.ImmutableHashSet.CreateRange( + StringComparer.OrdinalIgnoreCase, manifest.Capabilities); + + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + } + + private sealed class StubDiscoverer(IReadOnlyList items) : IPluginDiscoverer + { + public async IAsyncEnumerable DiscoverAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return item; + await Task.Yield(); + } + } + } + + private sealed class RecordingLoader(List loadedOrder) : IPluginLoader + { + public Task> LoadAsync( + IReadOnlyList plugins, + CancellationToken cancellationToken = default) => + Task.FromResult>(plugins + .Select(candidate => + { + loadedOrder.Add(candidate.Manifest!.Id); + return new ContractLoadedPlugin + { + Manifest = candidate.Manifest, + PluginType = typeof(DepPlugin), + Instance = new DepPlugin(candidate.Manifest), + LoadContext = System.Runtime.Loader.AssemblyLoadContext.Default, + }; + }) + .ToList()); + } + + private static DiscoveredPlugin Discovered(PluginManifest manifest, string? location = null) => + new() { Manifest = manifest, Location = location ?? $"/plugins/{manifest.Id}" }; + + private static PluginLoadingPipeline Pipeline( + IReadOnlyList discovered, + List loadedOrder, + IConfiguration? configuration = null) => + new( + new StubDiscoverer(discovered), + new RecordingLoader(loadedOrder), + NullLogger.Instance, + SemanticVersion.Parse("1.0.0"), + configuration); + + private static IConfiguration Config(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.Select(value => + new KeyValuePair(value.Key, value.Value))) + .Build(); + + // ---------- G7: ordering + unavailable propagation ---------- + + [Fact] + public async Task Pipeline_LoadsInDependencyOrder() + { + var loadedOrder = new List(); + var pipeline = Pipeline( + [ + Discovered(Manifest("test.b", -200, ["test.a"])), + Discovered(Manifest("test.c", 0)), + Discovered(Manifest("test.a", -100)), + ], + loadedOrder); + + var result = await pipeline.RunAsync(); + + Assert.Equal(3, result.Loaded.Count); + Assert.Equal(["test.a", "test.b", "test.c"], loadedOrder); + } + + [Fact] + public async Task Pipeline_RejectsTransitiveDependentsOfDisabled() + { + var loadedOrder = new List(); + var pipeline = Pipeline( + [ + Discovered(Manifest("test.a", enabled: false)), + Discovered(Manifest("test.b", dependsOn: ["test.a"])), + Discovered(Manifest("test.c", dependsOn: ["test.b"])), + ], + loadedOrder); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Empty(loadedOrder); + Assert.Equal(3, result.Issues.Count); + Assert.Contains(result.Issues, i => i.PluginId == "test.a" && i.Outcome == PluginOutcome.SkippedDisabled); + Assert.Contains(result.Issues, i => i.PluginId == "test.b" && i.Outcome == PluginOutcome.Rejected && i.Reason.Contains("dependency", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Issues, i => i.PluginId == "test.c" && i.Outcome == PluginOutcome.Rejected); + } + + [Fact] + public async Task Pipeline_UnknownDependency_IsStartupError() + { + var pipeline = Pipeline([Discovered(Manifest("test.a", dependsOn: ["test.ghost"]))], []); + + await Assert.ThrowsAsync(() => pipeline.RunAsync()); + } + + [Fact] + public async Task Pipeline_Cycle_IsStartupError() + { + var pipeline = Pipeline( + [ + Discovered(Manifest("test.a", dependsOn: ["test.b"])), + Discovered(Manifest("test.b", dependsOn: ["test.a"])), + ], + []); + + await Assert.ThrowsAsync(() => pipeline.RunAsync()); + } + + // ---------- G8: EffectiveIsEnabled ---------- + + [Theory] + [InlineData(true, null, true)] + [InlineData(true, "true", true)] + [InlineData(true, "false", false)] + [InlineData(false, null, false)] + [InlineData(false, "true", false)] + public void EffectiveIsEnabled_TruthTable(bool manifest, string? configured, bool expected) + { + var config = configured is null + ? Config() + : Config(("Plugins:test.fake:IsEnabled", configured)); + + Assert.Equal(expected, CompatibilityGate.EffectiveIsEnabled( + new PluginManifest { Id = "test.fake", Name = "Fake", IsEnabled = manifest }, config)); + } + + [Fact] + public async Task Pipeline_HostConfigDisablesWithoutLoading() + { + var loadedOrder = new List(); + var pipeline = Pipeline( + [Discovered(Manifest("test.a"))], + loadedOrder, + Config(("Plugins:test.a:IsEnabled", "false"))); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Empty(loadedOrder); + Assert.Equal(PluginOutcome.SkippedDisabled, Assert.Single(result.Issues).Outcome); + } + + [Fact] + public async Task Pipeline_HostConfigCannotReenable() + { + var loadedOrder = new List(); + var pipeline = Pipeline( + [Discovered(Manifest("test.a", enabled: false))], + loadedOrder, + Config(("Plugins:test.a:IsEnabled", "true"))); + + var result = await pipeline.RunAsync(); + + Assert.Empty(result.Loaded); + Assert.Equal(PluginOutcome.SkippedDisabled, Assert.Single(result.Issues).Outcome); + } + + // ---------- G9: cache ---------- + + private sealed class CountingDiscoverer(IPluginDiscoverer inner, Action onCall) : IPluginDiscoverer + { + public async IAsyncEnumerable DiscoverAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + onCall(); + await foreach (var candidate in inner.DiscoverAsync(cancellationToken)) + yield return candidate; + } + } + + private static string WriteManifest(string dir, string id) => + WriteManifest(dir, id, """{"Id":"__ID__","Name":"N","Version":"1.0.0"}""".Replace("__ID__", id)); + + private static string WriteManifest(string dir, string id, string json) + { + File.WriteAllText(Path.Combine(dir, "manifest.json"), json); + return dir; + } + + [Fact] + public async Task Cache_HitSkipsDiscoverer() + { + var root = NewRoot(); + WriteManifest(Directory.CreateDirectory(Path.Combine(root, "a")).FullName, "test.a"); + var cachePath = Path.Combine(root, "cache.json"); + var calls = 0; + + PluginLoadResult first; + { + var counting = new CountingDiscoverer(new DirectoryPluginDiscoverer(root, NullLogger.Instance), () => calls++); + var cache = new FilePluginDiscoveryCache(cachePath, NullLogger.Instance); + var pipeline = new PluginLoadingPipeline(counting, new RecordingLoader([]), NullLogger.Instance, SemanticVersion.Parse("1.0.0"), null, cache); + first = await pipeline.RunAsync(); + } + + Assert.Equal(1, calls); + Assert.Single(first.Loaded); + + { + var counting = new CountingDiscoverer(new DirectoryPluginDiscoverer(root, NullLogger.Instance), () => calls++); + var cache = new FilePluginDiscoveryCache(cachePath, NullLogger.Instance); + var pipeline = new PluginLoadingPipeline(counting, new RecordingLoader([]), NullLogger.Instance, SemanticVersion.Parse("1.0.0"), null, cache); + var second = await pipeline.RunAsync(); + + Assert.Equal(1, calls); + Assert.Single(second.Loaded); + } + } + + [Fact] + public async Task Cache_StaleFingerprintFallsBackToDiscovery() + { + var root = NewRoot(); + var dir = WriteManifest(Directory.CreateDirectory(Path.Combine(root, "a")).FullName, "test.a"); + var cachePath = Path.Combine(root, "cache.json"); + var cache = new FilePluginDiscoveryCache(cachePath, NullLogger.Instance); + var inner = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + + var discovered = new List(); + await foreach (var candidate in inner.DiscoverAsync()) + discovered.Add(candidate); + await cache.StoreAsync(discovered); + + Assert.NotNull(await cache.TryGetAsync()); + + await File.WriteAllTextAsync(Path.Combine(dir, "manifest.json"), + """{"Id":"test.a","Name":"N","Version":"2.0.0"}"""); + + Assert.Null(await cache.TryGetAsync()); + } + + [Fact] + public async Task Cache_CorruptFileIsMiss() + { + var root = NewRoot(); + var cachePath = Path.Combine(root, "cache.json"); + await File.WriteAllTextAsync(cachePath, "{broken"); + + var cache = new FilePluginDiscoveryCache(cachePath, NullLogger.Instance); + + Assert.Null(await cache.TryGetAsync()); + } + + [Fact] + public async Task Cache_StoresMetadataOnly() + { + var root = NewRoot(); + WriteManifest(Directory.CreateDirectory(Path.Combine(root, "a")).FullName, "test.a"); + var cachePath = Path.Combine(root, "cache.json"); + var inner = new DirectoryPluginDiscoverer(root, NullLogger.Instance); + + var discovered = new List(); + await foreach (var candidate in inner.DiscoverAsync()) + discovered.Add(candidate); + var cache = new FilePluginDiscoveryCache(cachePath, NullLogger.Instance); + await cache.StoreAsync(discovered); + + var json = await File.ReadAllTextAsync(cachePath); + Assert.Contains("test.a", json, StringComparison.Ordinal); + Assert.DoesNotContain("AssemblyLoadContext", json, StringComparison.Ordinal); + Assert.DoesNotContain("PluginType", json, StringComparison.Ordinal); + } + + // ---------- G10: logging ---------- + + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + IDisposable ILogger.BeginScope(TState state) => null!; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } + + [Fact] + public async Task Logging_DiscoveryAndOutcomeAreLogged() + { + var root = NewRoot(); + WriteManifest(Directory.CreateDirectory(Path.Combine(root, "a")).FullName, "test.a"); + var recording = new RecordingLogger(); + var pipeline = new PluginLoadingPipeline( + new DirectoryPluginDiscoverer(root, recording), + new RecordingLoader([]), + recording, + SemanticVersion.Parse("1.0.0")); + + await pipeline.RunAsync(); + + Assert.Contains(recording.Entries, e => + e.Level == LogLevel.Information && e.Message.Contains("test.a", StringComparison.Ordinal)); + Assert.Contains(recording.Entries, e => + e.Message.Contains("Loaded plugin", StringComparison.Ordinal)); + } +} diff --git a/tests/Plugins/Abstractions/IAuthKitPluginCapabilitiesTests.cs b/tests/Plugins/Abstractions/IAuthKitPluginCapabilitiesTests.cs index 2f8ca70..2db0b72 100644 --- a/tests/Plugins/Abstractions/IAuthKitPluginCapabilitiesTests.cs +++ b/tests/Plugins/Abstractions/IAuthKitPluginCapabilitiesTests.cs @@ -1,5 +1,6 @@ using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; +using Microsoft.Extensions.DependencyInjection; using Xunit; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; @@ -31,8 +32,14 @@ public void Capabilities_Comparison_IsCaseInsensitive() } [PluginMetadata("test.first", "1.0.0", [], null, ["auth", "storage"], description: "First test plugin")] - private sealed class FirstPlugin : IAuthKitPlugin; + private sealed class FirstPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + } [PluginMetadata("test.second", "1.0.0", [], null, ["audit", "storage"], description: "Second test plugin")] - private sealed class SecondPlugin : IAuthKitPlugin; + private sealed class SecondPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + } } \ No newline at end of file diff --git a/tests/Plugins/Abstractions/PluginHealthResultTests.cs b/tests/Plugins/Abstractions/PluginHealthResultTests.cs index c5c2de2..ac21bf2 100644 --- a/tests/Plugins/Abstractions/PluginHealthResultTests.cs +++ b/tests/Plugins/Abstractions/PluginHealthResultTests.cs @@ -2,6 +2,7 @@ using AuthKit.Plugins.Abstractions.Contracts; using AuthKit.Plugins.Abstractions.Contracts.Plugins; using AuthKit.Plugins.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; using Xunit; using IAuthKitPlugin = AuthKit.Plugins.Abstractions.Contracts.PluginContract.IAuthKitPlugin; @@ -99,6 +100,8 @@ private sealed class StructuredHealthPlugin : IAuthKitPlugin { public CancellationToken ReceivedToken { get; private set; } + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + public Task> CheckHealthAsync( IServiceProvider services, CancellationToken cancellationToken = default) @@ -113,7 +116,10 @@ public Task> CheckHealthAsync( } [PluginMetadata("default-health", "1.0.0", [], [], [], description: "Default health test")] - private sealed class DefaultHealthPlugin : IAuthKitPlugin; + private sealed class DefaultHealthPlugin : IAuthKitPlugin + { + public void ConfigureServices(IServiceCollection services, AuthKitPluginContext context) { } + } private sealed class ServiceProviderStub : IServiceProvider { diff --git a/tools/AuthKit.PluginContractValidator/Program.cs b/tools/AuthKit.PluginContractValidator/Program.cs index 987e3ca..0f98510 100644 --- a/tools/AuthKit.PluginContractValidator/Program.cs +++ b/tools/AuthKit.PluginContractValidator/Program.cs @@ -18,7 +18,8 @@ new RegistrationRule(), new SecuritySchemesRule(), new MiddlewareRule(), - new HealthRule() + new HealthRule(), + new LifecycleRule() ]); var failures = 0; diff --git a/tools/AuthKit.PluginContractValidator/src/Core/PluginConfigurationInvoker.cs b/tools/AuthKit.PluginContractValidator/src/Core/PluginConfigurationInvoker.cs index 8f6d168..87c5edd 100644 --- a/tools/AuthKit.PluginContractValidator/src/Core/PluginConfigurationInvoker.cs +++ b/tools/AuthKit.PluginContractValidator/src/Core/PluginConfigurationInvoker.cs @@ -62,8 +62,9 @@ public static IServiceCollection Configure( return builder.Services; } - plugin.ConfigureServices(services, configuration); - return services; + throw new InvalidOperationException( + $"Plugin '{plugin.Id}' implements no supported ConfigureServices overload. " + + "Implement ConfigureServices(IServiceCollection, AuthKitPluginContext)."); } /// diff --git a/tools/AuthKit.PluginContractValidator/src/Rules/LifecycleRule.cs b/tools/AuthKit.PluginContractValidator/src/Rules/LifecycleRule.cs new file mode 100644 index 0000000..eddbbda --- /dev/null +++ b/tools/AuthKit.PluginContractValidator/src/Rules/LifecycleRule.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AuthKit.PluginContractValidator.Core; +using AuthKit.Plugins.Abstractions.Contracts.PluginContract; +using AuthKit.Plugins.Abstractions.Pipeline; + +namespace AuthKit.PluginContractValidator.Rules; + +/// +/// Ensures plugin lifecycle hooks are structurally sound: hosted services are +/// valid and the pipeline position is a defined stage. +/// +/// +/// The rule invokes and verifies +/// the result contains no null entries and no duplicates, since the host +/// registers each entry as a singleton IHostedService. It also verifies +/// names a defined +/// value. Every violation names the member. +/// +public sealed class LifecycleRule : IPluginContractRule +{ + /// Gets the rule name ("Lifecycle"). + public string Name => "Lifecycle"; + + /// + /// Validates lifecycle hooks of the loaded plugin. + /// + /// The loaded plugin to validate. + /// A token that can cancel validation. + /// Lifecycle violations; empty when hooks are sound. + public Task> ValidateAsync( + LoadedPlugin plugin, + CancellationToken cancellationToken = default) + { + var errors = new List(); + var instance = plugin.Instance; + var pluginName = instance.Name; + + List? services = null; + try + { + services = instance.GetHostedServices()?.ToList(); + } + catch (Exception ex) + { + errors.Add($"lifecycle: Plugin '{pluginName}' GetHostedServices threw {ex.GetType().Name}: {ex.Message}"); + } + + if (services is null) + { + errors.Add($"lifecycle: Plugin '{pluginName}' GetHostedServices returned null; return an empty list when no hosted services are owned."); + } + else + { + if (services.Any(service => service is null)) + errors.Add($"lifecycle: Plugin '{pluginName}' GetHostedServices contains null entries."); + + var duplicates = services + .Where(service => service is not null) + .GroupBy(service => service!.GetType()) + .Where(group => group.Count() > 1) + .Select(group => group.Key.Name) + .ToArray(); + + if (duplicates.Length > 0) + errors.Add($"lifecycle: Plugin '{pluginName}' GetHostedServices registers duplicate services: {string.Join(", ", duplicates)}."); + } + + if (!Enum.IsDefined(instance.PipelinePosition)) + errors.Add($"lifecycle: Plugin '{pluginName}' PipelinePosition '{(int)instance.PipelinePosition}' is not a defined PluginPipelinePosition value."); + + return Task.FromResult>(errors); + } +}