diff --git a/SquidStd.slnx b/SquidStd.slnx index 9e3564e7..3dfb51c6 100644 --- a/SquidStd.slnx +++ b/SquidStd.slnx @@ -23,6 +23,7 @@ + diff --git a/docs/articles/plugin.md b/docs/articles/plugin.md new file mode 100644 index 00000000..589956b6 --- /dev/null +++ b/docs/articles/plugin.md @@ -0,0 +1 @@ +[!include[](../../src/SquidStd.Plugin/README.md)] diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 14ced565..ba8e7e9d 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -46,6 +46,8 @@ href: network.md - name: SquidStd.Plugin.Abstractions href: plugin-abstractions.md +- name: SquidStd.Plugin + href: plugin.md - name: SquidStd.Database.Abstractions href: database-abstractions.md - name: SquidStd.Database diff --git a/docs/tutorials/plugins.md b/docs/tutorials/plugins.md index 1cff0815..24244d78 100644 --- a/docs/tutorials/plugins.md +++ b/docs/tutorials/plugins.md @@ -52,3 +52,4 @@ one plugin by hand so the contract is fully runnable without external assemblies ## See also - [SquidStd.Plugin.Abstractions reference](../articles/plugin-abstractions.md) +- [SquidStd.Plugin reference](../articles/plugin.md) - the loader that discovers and configures plugins in dependency order diff --git a/samples/SquidStd.Samples.Plugins/Program.cs b/samples/SquidStd.Samples.Plugins/Program.cs index 5822c920..56610617 100644 --- a/samples/SquidStd.Samples.Plugins/Program.cs +++ b/samples/SquidStd.Samples.Plugins/Program.cs @@ -1,6 +1,9 @@ using DryIoc; +using SquidStd.Core.Data.Bootstrap; using SquidStd.Plugin.Abstractions.Data; using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; namespace SquidStd.Samples.Plugins; @@ -41,16 +44,16 @@ private static void Main() { #region step-2 - var container = new Container(); - var context = new PluginContext(); - context.Data["startedAt"] = DateTimeOffset.UtcNow; - - ISquidStdPlugin plugin = new WeatherPlugin(); + var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugins-sample", RootDirectory = Directory.GetCurrentDirectory() } + ); + var plugin = new WeatherPlugin(); Console.WriteLine($"Loading {plugin.Metadata.Name} v{plugin.Metadata.Version} by {plugin.Metadata.Author}"); - plugin.Configure(container, context); - var greeter = container.Resolve(); + bootstrap.UsePlugins(plugins => plugins.Add(plugin)); + + var greeter = bootstrap.Resolve(); Console.WriteLine(greeter.Greet("squid")); #endregion diff --git a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj index 900e30a0..665d155e 100644 --- a/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj +++ b/samples/SquidStd.Samples.Plugins/SquidStd.Samples.Plugins.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs index ebaa0bdc..1e6639f2 100644 --- a/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs +++ b/src/SquidStd.Core/Interfaces/Bootstrap/ISquidStdBootstrap.cs @@ -1,6 +1,7 @@ using DryIoc; using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; namespace SquidStd.Core.Interfaces.Bootstrap; @@ -19,6 +20,12 @@ public interface ISquidStdBootstrap : IAsyncDisposable /// IContainer Container { get; } + /// + /// Gets the current lifecycle state of the bootstrap. Reads are lock-free and intended for + /// pre-start guards, not for synchronizing concurrent lifecycle transitions. + /// + BootstrapStateType State { get; } + /// /// Applies custom service registrations before the bootstrap lifecycle starts. /// diff --git a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs similarity index 79% rename from src/SquidStd.Services.Core/Types/BootstrapStateType.cs rename to src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs index 2b1d72ff..1b58d254 100644 --- a/src/SquidStd.Services.Core/Types/BootstrapStateType.cs +++ b/src/SquidStd.Core/Types/Bootstrap/BootstrapStateType.cs @@ -1,9 +1,9 @@ -namespace SquidStd.Services.Core.Types; +namespace SquidStd.Core.Types.Bootstrap; /// /// Lifecycle state of the SquidStd bootstrapper. /// -internal enum BootstrapStateType +public enum BootstrapStateType { /// Created but not started. Created, diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs new file mode 100644 index 00000000..b05d486d --- /dev/null +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginContextKeys.cs @@ -0,0 +1,18 @@ +namespace SquidStd.Plugin.Abstractions.Data; + +/// +/// Well-known keys the SquidStd plugin loader populates in . +/// +public static class PluginContextKeys +{ + /// + /// Application root directory (string): the bootstrap RootDirectory option. + /// + public const string RootDirectory = "squidstd:rootDirectory"; + + /// + /// Application name (string): the bootstrap AppName option when set, otherwise the entry + /// assembly name, otherwise the bootstrap ConfigName. + /// + public const string AppName = "squidstd:appName"; +} diff --git a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs index eeffa2f3..b365bddc 100644 --- a/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs +++ b/src/SquidStd.Plugin.Abstractions/Data/PluginMetadata.cs @@ -1,11 +1,11 @@ namespace SquidStd.Plugin.Abstractions.Data; /// -/// Describes a Moongate plugin. This is the source of truth for plugin identity. +/// Describes a SquidStd plugin. This is the source of truth for plugin identity. /// public sealed class PluginMetadata { - /// Stable lowercase dotted plugin identifier, for example moongate.weather. + /// Stable lowercase dotted plugin identifier, for example myapp.weather. public required string Id { get; init; } /// Human-readable plugin name. diff --git a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs index d761f5a0..f09c6227 100644 --- a/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs +++ b/src/SquidStd.Plugin.Abstractions/Interfaces/Plugins/ISquidStdPlugin.cs @@ -4,7 +4,7 @@ namespace SquidStd.Plugin.Abstractions.Interfaces.Plugins; /// -/// Implemented by trusted .NET plugins loaded by Moongate during server startup. +/// Implemented by trusted .NET plugins loaded by the SquidStd plugin loader during application startup. /// public interface ISquidStdPlugin { diff --git a/src/SquidStd.Plugin.Abstractions/README.md b/src/SquidStd.Plugin.Abstractions/README.md index 4506a924..09eb5adc 100644 --- a/src/SquidStd.Plugin.Abstractions/README.md +++ b/src/SquidStd.Plugin.Abstractions/README.md @@ -45,6 +45,7 @@ public sealed class MyPlugin : ISquidStdPlugin ## Related - Tutorial: [Plugins](https://tgiachi.github.io/squid-std/tutorials/plugins.html) +- Loader: these contracts are loaded by the [SquidStd.Plugin](https://tgiachi.github.io/squid-std/articles/plugin.html) package via `UsePlugins`. ## License diff --git a/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs new file mode 100644 index 00000000..88628cec --- /dev/null +++ b/src/SquidStd.Plugin/Exceptions/PluginLoadException.cs @@ -0,0 +1,15 @@ +namespace SquidStd.Plugin.Exceptions; + +/// +/// Raised when the plugin loader cannot discover, order, or instantiate plugins. +/// +public class PluginLoadException : Exception +{ + /// Initializes the exception with a message describing the failure. + public PluginLoadException(string message) + : base(message) { } + + /// Initializes the exception with a message and the underlying cause. + public PluginLoadException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs new file mode 100644 index 00000000..9de57327 --- /dev/null +++ b/src/SquidStd.Plugin/Extensions/SquidStdBootstrapPluginExtensions.cs @@ -0,0 +1,87 @@ +using System.Reflection; +using Serilog; +using SquidStd.Core.Interfaces.Bootstrap; +using SquidStd.Core.Types.Bootstrap; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Internal; + +namespace SquidStd.Plugin.Extensions; + +/// +/// Connects the plugin loader to the SquidStd bootstrap. +/// +public static class SquidStdBootstrapPluginExtensions +{ + /// + /// Collects internal plugins and external plugin directories, resolves the dependency order + /// across the whole set, and invokes for each plugin + /// in order against the bootstrap container. Must be called before the bootstrap starts, so + /// plugins can register configuration sections before the configuration is loaded. Relative + /// directories are resolved against the bootstrap root directory. Any failure aborts startup: + /// loader problems raise and plugin exceptions + /// propagate unchanged. Plugin assemblies load into the default AssemblyLoadContext and are + /// fully trusted: there is no unloading and no version isolation. Note that plugin load + /// logging is emitted before the bootstrap configures Serilog, so it is only visible when + /// a logger is configured beforehand. + /// + /// The bootstrap to configure. + /// Callback that registers plugins and directories. + /// The same bootstrap for chaining. + public static ISquidStdBootstrap UsePlugins( + this ISquidStdBootstrap bootstrap, + Action configure + ) + { + ArgumentNullException.ThrowIfNull(bootstrap); + ArgumentNullException.ThrowIfNull(configure); + + if (bootstrap.State != BootstrapStateType.Created) + { + throw new InvalidOperationException( + "Plugins must be registered before the bootstrap starts." + ); + } + + var logger = Log.ForContext(typeof(SquidStdBootstrapPluginExtensions)); + var builder = new PluginCollectionBuilder(); + + configure(builder); + + var plugins = new List(builder.Plugins); + + foreach (var directory in builder.Directories) + { + var resolved = Path.IsPathRooted(directory) + ? directory + : Path.Combine(bootstrap.Options.RootDirectory, directory); + + plugins.AddRange(PluginAssemblyScanner.Scan(resolved)); + } + + var ordered = PluginDependencyResolver.Sort(plugins); + var appName = !string.IsNullOrWhiteSpace(bootstrap.Options.AppName) + ? bootstrap.Options.AppName + : Assembly.GetEntryAssembly()?.GetName().Name ?? bootstrap.Options.ConfigName; + + foreach (var plugin in ordered) + { + var context = new PluginContext(); + context.Data[PluginContextKeys.RootDirectory] = bootstrap.Options.RootDirectory; + context.Data[PluginContextKeys.AppName] = appName; + + plugin.Configure(bootstrap.Container, context); + + logger.Information( + "Loaded plugin {PluginId:l} v{PluginVersion} by {PluginAuthor:l}", + plugin.Metadata.Id, + plugin.Metadata.Version, + plugin.Metadata.Author + ); + } + + logger.Information("{PluginCount} plugin(s) loaded", ordered.Count); + + return bootstrap; + } +} diff --git a/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs new file mode 100644 index 00000000..c3a5d8a0 --- /dev/null +++ b/src/SquidStd.Plugin/Internal/PluginAssemblyScanner.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using Serilog; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Exceptions; + +namespace SquidStd.Plugin.Internal; + +/// +/// Scans a directory for external plugin assemblies, loads each into the default +/// AssemblyLoadContext, and instantiates every concrete found. +/// +internal static class PluginAssemblyScanner +{ + private static readonly ILogger Logger = Log.ForContext(typeof(PluginAssemblyScanner)); + + /// + /// Scans for *.dll files and returns every concrete + /// instantiated from them. + /// + /// The directory to scan for plugin assemblies. + /// + /// The directory does not exist, an assembly failed to load, or a plugin type failed to instantiate. + /// + public static IReadOnlyList Scan(string directory) + { + if (!Directory.Exists(directory)) + { + throw new PluginLoadException($"Plugin directory '{directory}' does not exist."); + } + + var plugins = new List(); + var dllPaths = Directory.EnumerateFiles(directory, "*.dll") + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + + foreach (var dllPath in dllPaths) + { + List pluginTypes; + + try + { + // Default AssemblyLoadContext: plugins are trusted and share type identity + // with the host (ISquidStdPlugin, DryIoc). No unload, no version isolation. + var assembly = Assembly.LoadFrom(dllPath); + + pluginTypes = assembly.GetTypes() + .Where(type => typeof(ISquidStdPlugin).IsAssignableFrom(type) && + type is { IsClass: true, IsAbstract: false }) + .ToList(); + } + catch (Exception ex) + { + throw new PluginLoadException($"Failed to load plugin assembly '{dllPath}'.", ex); + } + + if (pluginTypes.Count == 0) + { + Logger.Debug("No plugins found in {AssemblyPath:l}", dllPath); + + continue; + } + + foreach (var pluginType in pluginTypes) + { + try + { + plugins.Add((ISquidStdPlugin)Activator.CreateInstance(pluginType)!); + } + catch (Exception ex) + { + throw new PluginLoadException( + $"Failed to instantiate plugin type '{pluginType.FullName}' from '{dllPath}'.", + ex + ); + } + } + } + + return plugins; + } +} diff --git a/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs new file mode 100644 index 00000000..ac3a8aa0 --- /dev/null +++ b/src/SquidStd.Plugin/Internal/PluginDependencyResolver.cs @@ -0,0 +1,81 @@ +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Exceptions; + +namespace SquidStd.Plugin.Internal; + +/// +/// Orders plugins by dependency: a plugin always appears after every plugin it depends on. +/// Plugin ids are compared case-insensitively. Fails fast on duplicate ids, missing +/// dependencies, and dependency cycles. +/// +internal static class PluginDependencyResolver +{ + public static IReadOnlyList Sort(IReadOnlyList plugins) + { + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var plugin in plugins) + { + if (!byId.TryAdd(plugin.Metadata.Id, plugin)) + { + throw new PluginLoadException($"Duplicate plugin id '{plugin.Metadata.Id}'."); + } + } + + var sorted = new List(plugins.Count); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var path = new List(); + + foreach (var plugin in plugins) + { + Visit(plugin, byId, visiting, visited, path, sorted); + } + + return sorted; + } + + private static void Visit( + ISquidStdPlugin plugin, + Dictionary byId, + HashSet visiting, + HashSet visited, + List path, + List sorted + ) + { + var id = plugin.Metadata.Id; + + if (visited.Contains(id)) + { + return; + } + + if (!visiting.Add(id)) + { + var start = path.FindIndex(p => string.Equals(p, id, StringComparison.OrdinalIgnoreCase)); + var cycle = string.Join(" -> ", path.Skip(start).Append(id)); + + throw new PluginLoadException($"Plugin dependency cycle detected: {cycle}."); + } + + path.Add(id); + + foreach (var dependencyId in plugin.Metadata.Dependencies) + { + if (!byId.TryGetValue(dependencyId, out var dependency)) + { + throw new PluginLoadException( + $"Plugin '{id}' depends on '{dependencyId}', which is not registered." + ); + } + + Visit(dependency, byId, visiting, visited, path, sorted); + } + + path.RemoveAt(path.Count - 1); + visiting.Remove(id); + visited.Add(id); + sorted.Add(plugin); + } +} diff --git a/src/SquidStd.Plugin/PluginCollectionBuilder.cs b/src/SquidStd.Plugin/PluginCollectionBuilder.cs new file mode 100644 index 00000000..324601cb --- /dev/null +++ b/src/SquidStd.Plugin/PluginCollectionBuilder.cs @@ -0,0 +1,58 @@ +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Plugin; + +/// +/// Collects internal plugin registrations and external plugin directories for the loader. +/// Collection only: nothing is loaded or validated until the loader runs. +/// +public sealed class PluginCollectionBuilder +{ + private readonly List _plugins = []; + private readonly List _directories = []; + + internal IReadOnlyList Plugins => _plugins; + + internal IReadOnlyList Directories => _directories; + + /// + /// Registers an internal plugin by type; it is instantiated immediately via the public + /// parameterless constructor. + /// + /// The plugin type. + /// The same builder for chaining. + public PluginCollectionBuilder Add() + where TPlugin : ISquidStdPlugin, new() + { + _plugins.Add(new TPlugin()); + + return this; + } + + /// + /// Registers an internal plugin instance. + /// + /// The plugin instance. + /// The same builder for chaining. + public PluginCollectionBuilder Add(ISquidStdPlugin plugin) + { + ArgumentNullException.ThrowIfNull(plugin); + _plugins.Add(plugin); + + return this; + } + + /// + /// Adds a directory to scan for external plugin assemblies (*.dll, non-recursive). + /// Relative paths are resolved against the bootstrap root directory. + /// + /// Absolute or root-relative directory path. + /// The same builder for chaining. + public PluginCollectionBuilder FromDirectory(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _directories.Add(path); + + return this; + } +} diff --git a/src/SquidStd.Plugin/README.md b/src/SquidStd.Plugin/README.md new file mode 100644 index 00000000..ec54bac8 --- /dev/null +++ b/src/SquidStd.Plugin/README.md @@ -0,0 +1,64 @@ +

SquidStd.Plugin

+ +Loader for SquidStd plugins. Wires internal and external `ISquidStdPlugin` implementations into the +bootstrap: it collects them, resolves the dependency order across the whole set, and calls `Configure` +on each one before the bootstrap starts. + +## Install + +```bash +dotnet add package SquidStd.Plugin +``` + +## Usage + +```csharp +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; + +var bootstrap = SquidStdBootstrap.Create(new SquidStdOptions +{ + ConfigName = "myapp", + RootDirectory = "./data" +}); + +bootstrap.UsePlugins(plugins => +{ + plugins.Add(); // internal, by type + plugins.Add(new MetricsPlugin()); // internal, by instance + plugins.FromDirectory("plugins"); // external assemblies (*.dll) +}); + +await bootstrap.RunAsync(); +``` + +## How loading works + +- Plugins are ordered by dependency: `PluginMetadata.Dependencies` (plugin ids, compared + case-insensitively) is resolved across internal and external plugins together, so an external plugin + can depend on an internal one and vice versa. +- `Configure` runs before the configuration load, so plugins can register their own configuration + sections and services ahead of time. +- Each plugin receives a `PluginContext` populated with the standard keys: `PluginContextKeys.RootDirectory` + (the bootstrap root directory) and `PluginContextKeys.AppName` (the bootstrap app name). + +## Trusted plugins + +External assemblies load into the default `AssemblyLoadContext`: there is no unloading and no +per-plugin version isolation, so plugins are expected to be trusted. A failing plugin stops startup, +either with a `PluginLoadException` (discovery, ordering, or instantiation failure) or with the +plugin's own exception from `Configure`. + +Call `UsePlugins` before `ConfigureLogging()` or any start method: the bootstrap loads its +configuration during startup, and config sections registered by plugins after that point are never +loaded. + +## Related + +- Contracts: [SquidStd.Plugin.Abstractions](https://tgiachi.github.io/squid-std/articles/plugin-abstractions.html) +- [SquidStd.Core](https://tgiachi.github.io/squid-std/articles/core.html) + +## License + +MIT - part of [SquidStd](https://github.com/tgiachi/squid-std). diff --git a/src/SquidStd.Plugin/SquidStd.Plugin.csproj b/src/SquidStd.Plugin/SquidStd.Plugin.csproj new file mode 100644 index 00000000..84d93eb2 --- /dev/null +++ b/src/SquidStd.Plugin/SquidStd.Plugin.csproj @@ -0,0 +1,19 @@ + + + + true + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs index 031f1c22..e082a909 100644 --- a/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs +++ b/src/SquidStd.Services.Core/Services/Bootstrap/SquidStdBootstrap.cs @@ -14,10 +14,10 @@ using SquidStd.Core.Interfaces.Events; using SquidStd.Core.Interfaces.Lifecycle; using SquidStd.Core.Types; +using SquidStd.Core.Types.Bootstrap; using SquidStd.Services.Core.Extensions; using SquidStd.Services.Core.Extensions.Logger; using SquidStd.Services.Core.Services.Lifecycle; -using SquidStd.Services.Core.Types; namespace SquidStd.Services.Core.Services.Bootstrap; @@ -43,6 +43,9 @@ public sealed class SquidStdBootstrap : ISquidStdBootstrap /// public IContainer Container { get; } + /// + public BootstrapStateType State => _state; + /// /// Initializes a bootstrapper with default options. /// diff --git a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs index 38d4380a..817fcfee 100644 --- a/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs +++ b/tests/SquidStd.Tests/AspNetCore/FakeSquidStdBootstrap.cs @@ -2,6 +2,7 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; namespace SquidStd.Tests.AspNetCore; @@ -17,6 +18,8 @@ internal sealed class FakeSquidStdBootstrap : ISquidStdBootstrap public IContainer Container { get; } = new Container(); + public BootstrapStateType State { get; set; } = BootstrapStateType.Created; + public ISquidStdBootstrap ConfigureService(Func configure) => ConfigureServices(configure); @@ -64,6 +67,7 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); StartCount++; + State = BootstrapStateType.Started; return ValueTask.CompletedTask; } @@ -72,6 +76,7 @@ public ValueTask StopAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); StopCount++; + State = BootstrapStateType.Stopped; return ValueTask.CompletedTask; } diff --git a/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs new file mode 100644 index 00000000..2a14fa3a --- /dev/null +++ b/tests/SquidStd.Tests/Bootstrap/SquidStdBootstrapStateTests.cs @@ -0,0 +1,25 @@ +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Core.Types.Bootstrap; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Bootstrap; + +public class SquidStdBootstrapStateTests +{ + [Fact] + public async Task State_TransitionsAcrossLifecycle() + { + using var temp = new TempDirectory(); + await using var bootstrap = + SquidStdBootstrap.Create(new SquidStdOptions { ConfigName = "statetest", RootDirectory = temp.Path }); + + Assert.Equal(BootstrapStateType.Created, bootstrap.State); + + await bootstrap.StartAsync(); + Assert.Equal(BootstrapStateType.Started, bootstrap.State); + + await bootstrap.StopAsync(); + Assert.Equal(BootstrapStateType.Stopped, bootstrap.State); + } +} diff --git a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs index 303a21a5..233e02a2 100644 --- a/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs +++ b/tests/SquidStd.Tests/ConsoleCommands/BuiltinCommandsTests.cs @@ -4,6 +4,7 @@ using SquidStd.Core.Data.Bootstrap; using SquidStd.Core.Interfaces.Bootstrap; using SquidStd.Core.Interfaces.Config; +using SquidStd.Core.Types.Bootstrap; using SquidStd.Services.Core.Services.Lifecycle; namespace SquidStd.Tests.ConsoleCommands; @@ -83,6 +84,8 @@ private sealed class StopSpyBootstrap : ISquidStdBootstrap public IContainer Container { get; } = new Container(); + public BootstrapStateType State { get; private set; } = BootstrapStateType.Created; + public StopSpyBootstrap(Action onStop) { _onStop = onStop; @@ -111,10 +114,15 @@ public Task RunAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public ValueTask StartAsync(CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; + { + State = BootstrapStateType.Started; + + return ValueTask.CompletedTask; + } public ValueTask StopAsync(CancellationToken cancellationToken = default) { + State = BootstrapStateType.Stopped; _onStop(); return ValueTask.CompletedTask; diff --git a/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs new file mode 100644 index 00000000..d017450e --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginAssemblyScannerTests.cs @@ -0,0 +1,74 @@ +using SquidStd.Plugin.Exceptions; +using SquidStd.Plugin.Internal; +using SquidStd.Tests.Plugin.Support; + +namespace SquidStd.Tests.Plugin; + +public class PluginAssemblyScannerTests +{ + [Fact] + public void Scan_MissingDirectory_Throws() + { + var missing = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + var ex = Assert.Throws(() => PluginAssemblyScanner.Scan(missing)); + + Assert.Contains(missing, ex.Message); + } + + [Fact] + public void Scan_DirectoryWithPluginAssembly_LoadsAndInstantiates() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + PluginAssemblyFactory.CompilePluginAssembly(directory.FullName, "tests.generated"); + + var plugins = PluginAssemblyScanner.Scan(directory.FullName); + + Assert.Single(plugins); + Assert.Equal("tests.generated", plugins[0].Metadata.Id); + } + finally + { + directory.Delete(true); + } + } + + [Fact] + public void Scan_AssemblyWithoutPlugins_IsSkipped() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + PluginAssemblyFactory.CompileNonPluginAssembly(directory.FullName); + + var plugins = PluginAssemblyScanner.Scan(directory.FullName); + + Assert.Empty(plugins); + } + finally + { + directory.Delete(true); + } + } + + [Fact] + public void Scan_CorruptDll_Throws() + { + var directory = Directory.CreateTempSubdirectory(); + + try + { + File.WriteAllBytes(Path.Combine(directory.FullName, "broken.dll"), [1, 2, 3, 4]); + + Assert.Throws(() => PluginAssemblyScanner.Scan(directory.FullName)); + } + finally + { + directory.Delete(true); + } + } +} diff --git a/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs new file mode 100644 index 00000000..4fb3fbd6 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginCollectionBuilderTests.cs @@ -0,0 +1,52 @@ +using DryIoc; +using SquidStd.Plugin; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Tests.Plugin; + +public class PluginCollectionBuilderTests +{ + private sealed class AlphaPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "tests.alpha", + Name = "Alpha", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) { } + } + + [Fact] + public void Add_ByTypeAndInstance_CollectsInOrder() + { + var builder = new PluginCollectionBuilder(); + var instance = new AlphaPlugin(); + + builder.Add().Add(instance); + + Assert.Equal(2, builder.Plugins.Count); + Assert.Same(instance, builder.Plugins[1]); + } + + [Fact] + public void Add_NullInstance_Throws() + => Assert.Throws(() => new PluginCollectionBuilder().Add(null!)); + + [Fact] + public void FromDirectory_CollectsPaths() + { + var builder = new PluginCollectionBuilder(); + + builder.FromDirectory("plugins").FromDirectory("extra"); + + Assert.Equal(["plugins", "extra"], builder.Directories); + } + + [Fact] + public void FromDirectory_BlankPath_Throws() + => Assert.Throws(() => new PluginCollectionBuilder().FromDirectory(" ")); +} diff --git a/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs new file mode 100644 index 00000000..bb15fd12 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/PluginDependencyResolverTests.cs @@ -0,0 +1,80 @@ +using SquidStd.Plugin.Exceptions; +using SquidStd.Plugin.Internal; +using SquidStd.Tests.Plugin.Support; + +namespace SquidStd.Tests.Plugin; + +public class PluginDependencyResolverTests +{ + private static string[] Ids(IEnumerable plugins) + => plugins.Select(p => p.Metadata.Id).ToArray(); + + [Fact] + public void Sort_NoDependencies_KeepsRegistrationOrder() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("c"), new StubPlugin("a"), new StubPlugin("b")]); + + Assert.Equal(["c", "a", "b"], Ids(sorted)); + } + + [Fact] + public void Sort_DependencyComesFirst() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("app", "core"), new StubPlugin("core")]); + + Assert.Equal(["core", "app"], Ids(sorted)); + } + + [Fact] + public void Sort_TransitiveChain_IsOrdered() + { + var sorted = PluginDependencyResolver.Sort( + [new StubPlugin("web", "app"), new StubPlugin("app", "core"), new StubPlugin("core")] + ); + + Assert.Equal(["core", "app", "web"], Ids(sorted)); + } + + [Fact] + public void Sort_DependencyIdsAreCaseInsensitive() + { + var sorted = PluginDependencyResolver.Sort([new StubPlugin("App", "CORE"), new StubPlugin("core")]); + + Assert.Equal(["core", "App"], Ids(sorted)); + } + + [Fact] + public void Sort_DuplicateId_Throws() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("dup"), new StubPlugin("DUP")]) + ); + + Assert.Contains("dup", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Sort_MissingDependency_ThrowsNamingBoth() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("app", "ghost")]) + ); + + Assert.Contains("app", ex.Message); + Assert.Contains("ghost", ex.Message); + } + + [Fact] + public void Sort_Cycle_Throws() + { + var ex = Assert.Throws( + () => PluginDependencyResolver.Sort([new StubPlugin("a", "b"), new StubPlugin("b", "a")]) + ); + + Assert.Contains("cycle", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Sort_SelfDependency_Throws() + => Assert.Throws(() => PluginDependencyResolver.Sort([new StubPlugin("a", "a")])); +} diff --git a/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs new file mode 100644 index 00000000..f9e159f9 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/SquidStdBootstrapPluginExtensionsTests.cs @@ -0,0 +1,130 @@ +using DryIoc; +using SquidStd.Core.Data.Bootstrap; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; +using SquidStd.Plugin.Extensions; +using SquidStd.Services.Core.Services.Bootstrap; +using SquidStd.Tests.Plugin.Support; +using SquidStd.Tests.Support; + +namespace SquidStd.Tests.Plugin; + +public class SquidStdBootstrapPluginExtensionsTests +{ + private sealed class RecorderPlugin : ISquidStdPlugin + { + private readonly List _order; + + public PluginMetadata Metadata { get; } + + public IContainer? SeenContainer { get; private set; } + + public PluginContext? SeenContext { get; private set; } + + public RecorderPlugin(string id, List order, params string[] dependencies) + { + _order = order; + Metadata = new() + { + Id = id, + Name = id, + Version = new(1, 0, 0), + Author = "Tests", + Dependencies = dependencies + }; + } + + public void Configure(IContainer container, PluginContext context) + { + SeenContainer = container; + SeenContext = context; + _order.Add(Metadata.Id); + } + } + + private sealed class ThrowingPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "tests.throwing", + Name = "Throwing", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) + => throw new InvalidOperationException("boom"); + } + + [Fact] + public async Task UsePlugins_ConfiguresInDependencyOrder_WithBootstrapContainerAndContext() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path, AppName = "PluginHost" } + ); + + var order = new List(); + var app = new RecorderPlugin("tests.app", order, "tests.core"); + var core = new RecorderPlugin("tests.core", order); + + bootstrap.UsePlugins(plugins => plugins.Add(app).Add(core)); + + Assert.Equal(["tests.core", "tests.app"], order); + Assert.Same(bootstrap.Container, app.SeenContainer); + Assert.Equal(root.Path, core.SeenContext!.GetData(PluginContextKeys.RootDirectory)); + Assert.Equal("PluginHost", core.SeenContext!.GetData(PluginContextKeys.AppName)); + } + + [Fact] + public async Task UsePlugins_LoadsExternalPluginsFromRelativeDirectory() + { + using var root = new TempDirectory(); + var pluginsDir = Directory.CreateDirectory(root.Combine("plugins")); + PluginAssemblyFactory.CompilePluginAssembly(pluginsDir.FullName, "tests.external"); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + bootstrap.UsePlugins(plugins => plugins.FromDirectory("plugins")); + + Assert.Equal("tests.external", bootstrap.Container.Resolve(serviceKey: "plugin-marker")); + } + + [Fact] + public async Task UsePlugins_AfterStart_Throws() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + await bootstrap.StartAsync(); + + try + { + Assert.Throws(() => bootstrap.UsePlugins(_ => { })); + } + finally + { + await bootstrap.StopAsync(); + } + } + + [Fact] + public async Task UsePlugins_ConfigureException_Propagates() + { + using var root = new TempDirectory(); + + await using var bootstrap = SquidStdBootstrap.Create( + new SquidStdOptions { ConfigName = "plugintest", RootDirectory = root.Path } + ); + + Assert.Throws( + () => bootstrap.UsePlugins(plugins => plugins.Add(new ThrowingPlugin())) + ); + } +} diff --git a/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs new file mode 100644 index 00000000..ee3444d4 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/Support/PluginAssemblyFactory.cs @@ -0,0 +1,68 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace SquidStd.Tests.Plugin.Support; + +internal static class PluginAssemblyFactory +{ + public static string CompilePluginAssembly(string directory, string pluginId) + { + var source = $$""" + using DryIoc; + using SquidStd.Plugin.Abstractions.Data; + using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + + public sealed class GeneratedPlugin : ISquidStdPlugin + { + public PluginMetadata Metadata { get; } = new() + { + Id = "{{pluginId}}", + Name = "Generated Plugin", + Version = new(1, 0, 0), + Author = "Tests" + }; + + public void Configure(IContainer container, PluginContext context) + => container.RegisterInstance("{{pluginId}}", serviceKey: "plugin-marker"); + } + """; + + return Emit(directory, "generated-plugin.dll", source); + } + + public static string CompileNonPluginAssembly(string directory) + => Emit(directory, "not-a-plugin.dll", "public sealed class NotAPlugin { }"); + + private static string Emit(string directory, string fileName, string source) + { + var references = AppDomain.CurrentDomain.GetAssemblies() + // Earlier tests in this run may have loaded plugin assemblies from temp + // directories that have since been deleted (Scan uses Assembly.LoadFrom into + // the default ALC, which never unloads). Skip those so a stale Location doesn't + // blow up MetadataReference.CreateFromFile for unrelated tests. + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location) && + File.Exists(assembly.Location)) + .Select(assembly => MetadataReference.CreateFromFile(assembly.Location)) + .Cast() + .ToList(); + + var compilation = CSharpCompilation.Create( + $"GeneratedPluginAssembly_{Guid.NewGuid():N}", + [CSharpSyntaxTree.ParseText(source)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var path = Path.Combine(directory, fileName); + var result = compilation.Emit(path); + + if (!result.Success) + { + throw new InvalidOperationException( + string.Join(Environment.NewLine, result.Diagnostics.Select(d => d.ToString())) + ); + } + + return path; + } +} diff --git a/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs new file mode 100644 index 00000000..abaa5633 --- /dev/null +++ b/tests/SquidStd.Tests/Plugin/Support/StubPlugin.cs @@ -0,0 +1,24 @@ +using DryIoc; +using SquidStd.Plugin.Abstractions.Data; +using SquidStd.Plugin.Abstractions.Interfaces.Plugins; + +namespace SquidStd.Tests.Plugin.Support; + +internal sealed class StubPlugin : ISquidStdPlugin +{ + public PluginMetadata Metadata { get; } + + public StubPlugin(string id, params string[] dependencies) + { + Metadata = new() + { + Id = id, + Name = id, + Version = new(1, 0, 0), + Author = "Tests", + Dependencies = dependencies + }; + } + + public void Configure(IContainer container, PluginContext context) { } +} diff --git a/tests/SquidStd.Tests/SquidStd.Tests.csproj b/tests/SquidStd.Tests/SquidStd.Tests.csproj index e926d6f9..657604cc 100644 --- a/tests/SquidStd.Tests/SquidStd.Tests.csproj +++ b/tests/SquidStd.Tests/SquidStd.Tests.csproj @@ -43,6 +43,7 @@ +