Add project files.

This commit is contained in:
2023-09-15 13:24:16 +02:00
parent 58012f7f41
commit d870e6ce92
22 changed files with 1077 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
* amacocian@yahoo.com
+50
View File
@@ -0,0 +1,50 @@
name: Plumsy CD Pipeline
on:
push:
branches:
- master
jobs:
build:
environment: Default
strategy:
matrix:
targetplatform: [x64]
runs-on: windows-latest
env:
Configuration: Release
Solution_Path: Plumsy.sln
Test_Project_Path: Plumsy.Tests\Plumsy.Tests.csproj
Source_Project_Path: Plumsy\Plumsy.csproj
Actions_Allow_Unsecure_Commands: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: '7.x'
- name: Setup MSBuild.exe
uses: microsoft/setup-msbuild@v1.0.1
- name: Restore project
run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier
env:
RuntimeIdentifier: win-${{ matrix.targetplatform }}
- name: Build Plumsy project
run: dotnet build Plumsy -c $env:Configuration
- name: Package Plumsy
run: dotnet pack -c Release -o . $env:Source_Project_Path
- name: Publish
run: dotnet nuget push *.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
+59
View File
@@ -0,0 +1,59 @@
name: Plumsy CI Pipeline
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
strategy:
matrix:
targetplatform: [x64]
runs-on: windows-latest
env:
Solution_Path: Plumsy.sln
Test_Project_Path: Plumsy.Tests\Plumsy.Tests.csproj
Test_Plugin_Project_Path: Plumsy.Tests.SimplePlugin\Plumsy.Tests.SimplePlugin.csproj
Source_Project_Path: Plumsy\Plumsy.csproj
Actions_Allow_Unsecure_Commands: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Install .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: '6.x'
- name: Setup MSBuild.exe
uses: microsoft/setup-msbuild@v1.0.1
- name: Build SimplePlugin project
run: dotnet build $env:Test_Plugin_Project_Path
- name: Prepare plugin files # Needs to be done manually in this pipeline
run: |
mkdir ${{ github.workspace }}\SimplePlugin
cp ${{ github.workspace }}\Plumsy.Tests.SimplePlugin\bin\Debug\net6.0\Plumsy.Tests.SimplePlugin.dll ${{ github.workspace }}\SimplePlugin\Plumsy.Tests.SimplePlugin.dll
cp ${{ github.workspace }}\Plumsy.Tests.SimplePlugin\bin\Debug\net6.0\SystemExtensions.NetStandard.dll ${{ github.workspace }}\SimplePlugin\SystemExtensions.NetStandard.dll
- name: Echo solution path
run: echo /p:SolutionDir=${{ github.workspace }}\
- name: Execute Plumsy Unit Tests
run: dotnet test $env:Test_Project_Path /p:SolutionDir=${{ github.workspace }}\ --logger:"console;verbosity=normal" # Need to manually set the SolutionDir, otherwise it's unspecified
- name: Restore Project
run: msbuild $env:Solution_Path /t:Restore /p:Configuration=$env:Configuration /p:RuntimeIdentifier=$env:RuntimeIdentifier
env:
Configuration: Debug
RuntimeIdentifier: win-${{ matrix.targetplatform }}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Macocian Alexandru Victor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
using System.Extensions;
namespace Plum.Net.Tests.SimplePlugin;
public sealed class Main
{
public Main()
{
this.ThrowIfNull("this");
}
public bool ReturnTrue() => true;
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<PropertyGroup>
<DestinationFolder>$(SolutionDir)SimplePlugin</DestinationFolder>
</PropertyGroup>
<Target Name="CopyAssembliesToSolution" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(OutputPath)$(AssemblyName).dll" DestinationFolder="$(DestinationFolder)" />
<Copy SourceFiles="$(OutputPath)SystemExtensions.NetStandard.dll" DestinationFolder="$(DestinationFolder)" />
</Target>
<ItemGroup>
<PackageReference Include="SystemExtensions.NetStandard" Version="1.6.2" />
</ItemGroup>
</Project>
+209
View File
@@ -0,0 +1,209 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using Plum.Net.Models;
using Plum.Net.Tests.Resolvers;
using Plum.Net.Tests.SimplePlugin;
using Plum.Net.Validators;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.Loader;
namespace Plum.Net.Tests;
[TestClass]
public class PluginManagerTests
{
private readonly string pluginDirectory;
private readonly PluginManager pluginManager;
public PluginManagerTests()
{
this.pluginDirectory = Path.Combine(Environment.CurrentDirectory, "Plugins");
this.pluginManager = new(this.pluginDirectory);
this.pluginManager.WithDependencyResolver(new SimpleDependencyResolver());
}
[TestInitialize]
public void TestInitialize()
{
var assemblyLoadEventHandlerField = typeof(AssemblyLoadContext).GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic).First(f => f.Name == "AssemblyLoad");
assemblyLoadEventHandlerField.SetValue(AssemblyLoadContext.Default, null);
var resolveDependencyEventHandlerField = typeof(AssemblyLoadContext).GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic).First(f => f.Name == "AssemblyResolve");
resolveDependencyEventHandlerField.SetValue(AssemblyLoadContext.Default, null);
}
[TestMethod]
public void PluginManager_MissingPluginDirectory_ShouldReturnNoPlugins()
{
var pluginManager = new PluginManager(Path.Combine(this.pluginDirectory, Guid.NewGuid().ToString()));
var plugins = pluginManager.GetAvailablePlugins();
plugins.Should().HaveCount(0);
}
[TestMethod]
public void PluginManager_ExistingPlugin_ShouldReturnExpectedPlugin()
{
var plugins = this.pluginManager.GetAvailablePlugins();
plugins.Should().HaveCount(1);
plugins.First().Name.Should().Be("Plum.Net.Tests.SimplePlugin");
}
[TestMethod]
public void PluginManager_VersionValidator_ValidatesVersion()
{
var called = false;
var validator = Substitute.For<IEnvironmentVersionValidator>();
this.pluginManager.WithEnvironmentVersionValidator(validator);
validator.Validate(Arg.Any<Version>(), Arg.Any<Version>()).Returns(callinfo =>
{
called = true;
return true;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
called.Should().BeTrue();
}
[TestMethod]
public void PluginManager_VersionValidator_CurrentVersionMatchesEnvironment()
{
var validator = Substitute.For<IEnvironmentVersionValidator>();
this.pluginManager.WithEnvironmentVersionValidator(validator);
validator.Validate(Arg.Any<Version>(), Arg.Any<Version>()).Returns(callinfo =>
{
var currentVersion = callinfo.ArgAt<Version>(0);
currentVersion.Should().Be(Environment.Version);
return true;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
}
[TestMethod]
public void PluginManager_VersionValidatorDenies_ReturnsNoPlugins()
{
var validator = Substitute.For<IEnvironmentVersionValidator>();
this.pluginManager.WithEnvironmentVersionValidator(validator);
validator.Validate(Arg.Any<Version>(), Arg.Any<Version>()).Returns(callinfo =>
{
return false;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
plugins.Should().HaveCount(0);
}
[TestMethod]
public void PluginManager_TypeDefinitionsValidator_CallsValidator()
{
var called = false;
var validator = Substitute.For<ITypeDefinitionsValidator>();
this.pluginManager.WithTypeDefinitionsValidator(validator);
validator.Validate(Arg.Any<IEnumerable<TypeDefinition>>(), Arg.Any<MetadataReader>()).Returns(callinfo =>
{
called = true;
return true;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
called.Should().BeTrue();
}
[TestMethod]
public void PluginManager_TypeDefinitionsValidator_ReceivesMainType()
{
var validator = Substitute.For<ITypeDefinitionsValidator>();
this.pluginManager.WithTypeDefinitionsValidator(validator);
validator.Validate(Arg.Any<IEnumerable<TypeDefinition>>(), Arg.Any<MetadataReader>()).Returns(callinfo =>
{
var typeDefinitions = callinfo.ArgAt<IEnumerable<TypeDefinition>>(0);
var metadataReader = callinfo.ArgAt<MetadataReader>(1);
typeDefinitions.Select(t => metadataReader.GetString(t.Name)).Any(t => t == "Main").Should().BeTrue();
return true;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
}
[TestMethod]
public void PluginManager_MetadataValidator_CallsValidator()
{
var called = false;
var validator = Substitute.For<IMetadataValidator>();
this.pluginManager.WithMetadataValidator(validator);
validator.Validate(Arg.Any<MetadataReader>()).Returns(callinfo =>
{
var metadataReader = callinfo.ArgAt<MetadataReader>(0);
metadataReader.Should().NotBeNull();
called = true;
return true;
});
var plugins = this.pluginManager.GetAvailablePlugins().ToList();
called.Should().BeTrue();
}
[TestMethod]
public void PluginManager_LoadPlugins_ShouldSucceed()
{
var plugins = this.pluginManager.GetAvailablePlugins();
var results = this.pluginManager.LoadPlugins(plugins);
results.Should().HaveCount(1);
results.First().Should().BeOfType<PluginLoadOperation.Success>();
}
[TestMethod]
public void PluginManager_LoadPlugins_ReturnsExpectedAssembly()
{
this.pluginManager.WithForceLoadDependencies(true);
var plugins = this.pluginManager.GetAvailablePlugins();
var results = this.pluginManager.LoadPlugins(plugins);
var assembly = results.OfType<PluginLoadOperation.Success>().First().Plugin.Assembly;
var mainType = assembly.GetTypes().First(t => t.Name == nameof(Main));
var main = Activator.CreateInstance(mainType).As<Main>();
main.Should().NotBeNull();
main.ReturnTrue().Should().BeTrue();
}
[TestMethod]
[Ignore("This test fails when run together with the other tests, as the dependent assembly is loaded into the AppDomain by other tests")]
public void PluginManager_LoadPlugins_WithNoResolver_FailsToResolveAssembly()
{
var pluginManager = new PluginManager(this.pluginDirectory);
pluginManager.WithForceLoadDependencies(true);
var plugins = pluginManager.GetAvailablePlugins();
var results = pluginManager.LoadPlugins(plugins);
results.First().Should().BeOfType<PluginLoadOperation.ExceptionEncountered>();
}
[TestMethod]
public void PluginManager_LoadPlugins_WithNoResolverAndNoForcedDependencyResolve_Succeeds()
{
var pluginManager = new PluginManager(this.pluginDirectory);
pluginManager.WithForceLoadDependencies(false);
var plugins = pluginManager.GetAvailablePlugins();
var results = pluginManager.LoadPlugins(plugins);
results.First().Should().BeOfType<PluginLoadOperation.Success>();
}
}
+39
View File
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<Target Name="PreparePluginTestSetup" BeforeTargets="PrepareForBuild">
<Copy SourceFiles="$(SolutionDir)SimplePlugin\Plum.Net.Tests.SimplePlugin.dll" DestinationFolder="$(OutputPath)Plugins" />
<Copy SourceFiles="$(SolutionDir)SimplePlugin\SystemExtensions.NetStandard.dll" DestinationFolder="$(OutputPath)Dependencies" />
</Target>
<Target Name="RemoveUnwantedDependency" AfterTargets="Build">
<!-- We need to delete SystemExtensions.NetStandard.dll from the output path, to make the assembly resolver have to search for the dependency where we point it to -->
<Delete Files="$(OutputPath)SystemExtensions.NetStandard.dll" />
</Target>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
<PackageReference Include="coverlet.collector" Version="3.2.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.0.16">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Plum.Net.Tests.SimplePlugin\Plum.Net.Tests.SimplePlugin.csproj" />
<ProjectReference Include="..\Plum.Net\Plum.Net.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,16 @@
using Plum.Net.Resolvers;
using System.IO;
using System.Linq;
using System.Reflection;
namespace Plum.Net.Tests.Resolvers;
public sealed class SimpleDependencyResolver : IDependencyResolver
{
public bool TryResolveDependency(Assembly? requestingAssembly, string dependencyName, out string? path)
{
var dllName = dependencyName.Split(',').First();
path = Path.Combine(Path.GetFullPath("Dependencies"), $"{dllName}.dll");
return true;
}
}
+46
View File
@@ -0,0 +1,46 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.7.34024.191
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy", "Plumsy\Plumsy.csproj", "{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy.Tests.SimplePlugin", "Plumsy.Tests.SimplePlugin\Plumsy.Tests.SimplePlugin.csproj", "{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Plumsy.Tests", "Plumsy.Tests\Plumsy.Tests.csproj", "{0C18CB86-2495-4713-A27C-C06321CC8539}"
ProjectSection(ProjectDependencies) = postProject
{93D702F9-320D-4490-AE7B-8F6D70A1B2DB} = {93D702F9-320D-4490-AE7B-8F6D70A1B2DB}
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipelines", "Pipelines", "{EB205ABC-DAFB-4F8D-A7E0-E83EC03A2678}"
ProjectSection(SolutionItems) = preProject
.github\workflows\cd.yaml = .github\workflows\cd.yaml
.github\workflows\ci.yaml = .github\workflows\ci.yaml
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9C9E517-90BE-4B5D-8B06-491DF4F125EF}.Release|Any CPU.Build.0 = Release|Any CPU
{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{93D702F9-320D-4490-AE7B-8F6D70A1B2DB}.Release|Any CPU.Build.0 = Release|Any CPU
{0C18CB86-2495-4713-A27C-C06321CC8539}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C18CB86-2495-4713-A27C-C06321CC8539}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C18CB86-2495-4713-A27C-C06321CC8539}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C18CB86-2495-4713-A27C-C06321CC8539}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D3A216E7-9DC1-445B-8C7F-F080CC413731}
EndGlobalSection
EndGlobal
@@ -0,0 +1,8 @@
using System.Reflection;
namespace Plum.Net.Callbacks;
public interface IAssemblyLoadedCallback
{
void AssemblyLoaded(Assembly assembly);
}
+13
View File
@@ -0,0 +1,13 @@
using Plum.Net.Models;
namespace Plum.Net.Exceptions;
public sealed class PluginLoadException : Exception
{
public PluginEntry Plugin { get; }
public PluginLoadException(string? message, Exception? innerException, PluginEntry plugin) : base(message, innerException)
{
this.Plugin = plugin;
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.Reflection;
namespace Plum.Net.Models;
public sealed class Plugin
{
public PluginEntry PluginEntry { get; init; } = default!;
public Assembly Assembly { get; init; } = default!;
internal Plugin()
{
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Plum.Net.Models;
public sealed class PluginEntry
{
public string Name { get; init; }
public string Path { get; init; }
internal PluginEntry()
{
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace Plum.Net.Models;
public abstract class PluginLoadOperation
{
public PluginEntry PluginEntry { get; init; } = default!;
public abstract string Description { get; }
public sealed class Success : PluginLoadOperation
{
public Plugin Plugin { get; init; } = default!;
public override string Description => "Plugin loaded successfully";
internal Success()
{
}
}
public sealed class NullEntry : PluginLoadOperation
{
public override string Description => $"Provided {nameof(PluginEntry)} is null";
internal NullEntry()
{
}
}
public sealed class FileNotFound : PluginLoadOperation
{
public string Path { get; init; } = default!;
public override string Description => $"Failed to load plugin. Check {nameof(Path)} property for details";
internal FileNotFound()
{
}
}
public sealed class ExceptionEncountered : PluginLoadOperation
{
public override string Description => "Exception encountered while loading plugin";
public Exception Exception { get; init; } = default!;
internal ExceptionEncountered()
{
}
}
public sealed class UnexpectedErrorOccurred : PluginLoadOperation
{
public override string Description { get; }
internal UnexpectedErrorOccurred(string description)
{
this.Description = description;
}
}
}
+361
View File
@@ -0,0 +1,361 @@
using Plum.Net.Callbacks;
using Plum.Net.Exceptions;
using Plum.Net.Models;
using Plum.Net.Resolvers;
using Plum.Net.Validators;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Runtime.CompilerServices;
namespace Plum.Net;
public sealed class PluginManager
{
private static readonly object Lock = new();
private readonly string pluginsBaseDirectory;
private readonly List<IEnvironmentVersionValidator> environmentVersionValidators = new();
private readonly List<IMetadataValidator> metadataValidators = new();
private readonly List<ITypeDefinitionsValidator> typeDefinitionsValidators = new();
private readonly List<IAssemblyLoadedCallback> assemblyLoadCallbacks = new();
private readonly List<IDependencyResolver> dependencyResolvers = new();
private bool locked = false;
private bool attachedDomainEvents = false;
private bool forceLoadDependencies = true;
public PluginManager(string pluginsBaseDirectory)
{
this.pluginsBaseDirectory = Path.GetFullPath(pluginsBaseDirectory);
}
public PluginManager WithEnvironmentVersionValidator(IEnvironmentVersionValidator environmentVersionValidator)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.environmentVersionValidators.Add(environmentVersionValidator);
return this;
}
public PluginManager WithMetadataValidator(IMetadataValidator metadataValidator)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.metadataValidators.Add(metadataValidator);
return this;
}
public PluginManager WithTypeDefinitionsValidator(ITypeDefinitionsValidator typeDefinitionsValidator)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.typeDefinitionsValidators.Add(typeDefinitionsValidator);
return this;
}
public PluginManager WithAssemblyLoadedCallback(IAssemblyLoadedCallback assemblyLoadedCallback)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.assemblyLoadCallbacks.Add(assemblyLoadedCallback);
return this;
}
/// <summary>
/// Add a <see cref="IDependencyResolver"/> to the <see cref="PluginManager"/>. To ensure that this is called,
/// call <see cref="WithForceLoadDependencies(bool)"/> with <see cref="true"/> parameter.
/// </summary>
/// <param name="dependencyResolver"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public PluginManager WithDependencyResolver(IDependencyResolver dependencyResolver)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.dependencyResolvers.Add(dependencyResolver);
return this;
}
/// <summary>
/// When false, loading plugins might not trigger dependency resolvers, as the runtime loads dependencies lazily.
/// By default, this value is true.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
public PluginManager WithForceLoadDependencies(bool forceLoadDependencies)
{
if (this.locked)
{
throw new InvalidOperationException($"{nameof(PluginManager)} is in locked state and cannot be modified anymore");
}
this.forceLoadDependencies = forceLoadDependencies;
return this;
}
/// <summary>
/// Enumerates over all the plugins found in the configured directory.
/// </summary>
/// <returns>Validated list of plugin entries that can be loaded by the plugin manager.</returns>
public IEnumerable<PluginEntry> GetAvailablePlugins()
{
if (Directory.Exists(pluginsBaseDirectory))
{
foreach (var potentialPluginPath in Directory.GetFiles(pluginsBaseDirectory, "*.dll", SearchOption.AllDirectories))
{
if (this.ValidatePlugin(potentialPluginPath, out var plugin))
{
yield return plugin!;
}
}
}
}
/// <summary>
/// Loads a list of plugins. This operation locks the <see cref="PluginManager"/> and no more validators/callbacks/resolvers can be added anymore.
/// </summary>
/// <returns>List of <see cref="PluginLoadOperation"/> results in the same order as the provided plugins.</returns>
/// <exception cref="ArgumentNullException">Throws when provided plugins are null.</exception>
public IEnumerable<PluginLoadOperation> LoadPlugins(IEnumerable<PluginEntry> plugins)
{
_ = plugins ?? throw new ArgumentNullException(nameof(plugins));
try
{
while (!Monitor.TryEnter(Lock)) { }
this.locked = true;
if (!this.attachedDomainEvents)
{
AppDomain.CurrentDomain.AssemblyLoad += this.AssemblyLoaded;
AppDomain.CurrentDomain.AssemblyResolve += this.AssemblyResolve;
this.attachedDomainEvents = true;
}
var results = this.LoadPluginsInternal(plugins).ToList();
return results;
}
finally
{
Monitor.Exit(Lock);
}
}
private IEnumerable<PluginLoadOperation> LoadPluginsInternal(IEnumerable<PluginEntry> plugins)
{
foreach (var plugin in plugins)
{
if (plugin is null)
{
yield return new PluginLoadOperation.NullEntry();
continue;
}
if (!File.Exists(plugin.Path))
{
yield return new PluginLoadOperation.FileNotFound { PluginEntry = plugin, Path = plugin.Path };
continue;
}
var success = false;
PluginLoadException pluginLoadException = default!;
Assembly assembly = default!;
try
{
assembly = Assembly.LoadFrom(plugin.Path);
if (this.forceLoadDependencies)
{
ForceLoadDependenciesOfLoadedAssembly(assembly);
}
success = true;
}
catch (Exception e)
{
success = false;
pluginLoadException = new PluginLoadException("Failed to load plugin. Check inner exception for details", e, plugin);
}
if (success)
{
if (assembly is not null)
{
yield return new PluginLoadOperation.Success { Plugin = new Plugin { Assembly = assembly, PluginEntry = plugin }, PluginEntry = plugin };
}
else
{
yield return new PluginLoadOperation.UnexpectedErrorOccurred($"Plugin loaded without any error but no Assembly was obtained from {nameof(Assembly.LoadFrom)}") { PluginEntry = plugin };
}
}
else
{
yield return new PluginLoadOperation.ExceptionEncountered { Exception = pluginLoadException!, PluginEntry = plugin };
}
}
}
private void AssemblyLoaded(object? _, AssemblyLoadEventArgs assemblyLoadEventArgs)
{
foreach (var callback in this.assemblyLoadCallbacks)
{
callback.AssemblyLoaded(assemblyLoadEventArgs.LoadedAssembly);
}
}
private Assembly AssemblyResolve(object? _, ResolveEventArgs resolveEventArgs)
{
foreach (var resolver in this.dependencyResolvers)
{
if (resolver.TryResolveDependency(resolveEventArgs.RequestingAssembly, resolveEventArgs.Name, out var assemblyPath) &&
assemblyPath is string resolvedPath)
{
return Assembly.LoadFrom(resolvedPath);
}
}
throw new InvalidOperationException($"Unable to resolve dependency {resolveEventArgs.Name}. Requesting assembly: {resolveEventArgs.RequestingAssembly}");
}
private bool ValidatePlugin(string path, out PluginEntry? plugin)
{
if (!File.Exists(path))
{
plugin = default;
return false;
}
var pluginName = Path.GetFileNameWithoutExtension(path);
plugin = new PluginEntry { Name = pluginName, Path = path };
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
using var peReader = new PEReader(stream);
var metadataReader = peReader.GetMetadataReader();
if (!this.IsValidPlugin(metadataReader))
{
return false;
}
if (!this.IsTargetingCorrectDotnetVersion(metadataReader))
{
return false;
}
var typeDefinitions = metadataReader.TypeDefinitions.Select(metadataReader.GetTypeDefinition);
if (!this.HasValidTypeDefinitions(typeDefinitions, metadataReader))
{
return false;
}
return true;
}
private bool IsTargetingCorrectDotnetVersion(MetadataReader metadataReader)
{
foreach (var referenceHandle in metadataReader.AssemblyReferences)
{
var reference = metadataReader.GetAssemblyReference(referenceHandle);
var name = metadataReader.GetString(reference.Name);
if (name.Equals("System.Runtime", StringComparison.OrdinalIgnoreCase))
{
/*
* If any validator rejects the plugin version, reject the plugin.
* Otherwise, accept the plugin.
*/
var pluginVersion = reference.Version;
var currentVersion = Environment.Version;
foreach(var validator in this.environmentVersionValidators)
{
if (!validator.Validate(currentVersion, pluginVersion))
{
return false;
}
}
return true;
}
}
return false;
}
private bool HasValidTypeDefinitions(IEnumerable<TypeDefinition> typeDefinitions, MetadataReader metadataReader)
{
foreach(var validator in this.typeDefinitionsValidators)
{
if (!validator.Validate(typeDefinitions, metadataReader))
{
return false;
}
}
return true;
}
private bool IsValidPlugin(MetadataReader metadataReader)
{
foreach(var validator in this.metadataValidators)
{
if (!validator.Validate(metadataReader))
{
return false;
}
}
return true;
}
private static void ForceLoadDependenciesOfLoadedAssembly(Assembly assembly)
{
foreach (var type in assembly.GetTypes())
{
// We'll force JIT compilation of all the methods, constructors and property setter/getter, which in turn forces dependencies to load
foreach (var method in type.GetMethods(BindingFlags.DeclaredOnly |
BindingFlags.Public |
BindingFlags.NonPublic |
BindingFlags.Instance |
BindingFlags.Static))
{
RuntimeHelpers.PrepareMethod(method.MethodHandle);
}
foreach(var constructor in type.GetConstructors())
{
RuntimeHelpers.PrepareMethod(constructor.MethodHandle);
}
foreach (var property in type.GetProperties(BindingFlags.Public |
BindingFlags.NonPublic |
BindingFlags.Instance |
BindingFlags.Static))
{
MethodInfo? getMethod = property.GetGetMethod(nonPublic: true);
MethodInfo? setMethod = property.GetSetMethod(nonPublic: true);
if (getMethod is not null)
{
RuntimeHelpers.PrepareMethod(getMethod.MethodHandle);
}
if (setMethod is not null)
{
RuntimeHelpers.PrepareMethod(setMethod.MethodHandle);
}
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<LangVersion>latest</LangVersion>
<Authors>Alexandru Macocian</Authors>
<Company />
<Product></Product>
<Description>Plugin management library.</Description>
<AssemblyVersion>1.0</AssemblyVersion>
<FileVersion>1.0</FileVersion>
<PackageProjectUrl>https://github.com/AlexMacocian/Plum</PackageProjectUrl>
<Version>1.0</Version>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<PackageRequireLicenseAcceptance>true</PackageRequireLicenseAcceptance>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Reflection.Metadata" Version="7.0.2" />
</ItemGroup>
<ItemGroup>
<None Include="..\LICENSE">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
<None Include="..\README.md" Link="README.md" />
</ItemGroup>
</Project>
+8
View File
@@ -0,0 +1,8 @@
using System.Reflection;
namespace Plum.Net.Resolvers;
public interface IDependencyResolver
{
bool TryResolveDependency(Assembly? requestingAssembly, string dependencyName, out string? path);
}
@@ -0,0 +1,12 @@
namespace Plum.Net.Validators;
public interface IEnvironmentVersionValidator
{
/// <summary>
/// Validate the version of .net of the plugin in comparison to the version of .net of the running assembly.
/// </summary>
/// <param name="currentVersion">.net version of the currently running assembly</param>
/// <param name="pluginVersion">.net version of the plugin</param>
/// <returns></returns>
bool Validate(Version currentVersion, Version pluginVersion);
}
+11
View File
@@ -0,0 +1,11 @@
using System.Reflection.Metadata;
namespace Plum.Net.Validators;
/// <summary>
/// A general validator that validates a plugin based on the entire metadata
/// </summary>
public interface IMetadataValidator
{
bool Validate(MetadataReader metadataReader);
}
@@ -0,0 +1,14 @@
using System.Reflection.Metadata;
namespace Plum.Net.Validators;
/// <summary>
/// A validator that validates a plugin based on the TypeDefinitions it contains.
/// </summary>
/// <remarks>
/// Useful when you want to validate if the plugin contains a certain type that may implement a base type, in order to determine the entry point of the plugin.
/// </remarks>
public interface ITypeDefinitionsValidator
{
bool Validate(IEnumerable<TypeDefinition> typeDefinitions, MetadataReader metadataReader);
}
+61
View File
@@ -0,0 +1,61 @@
# Plumsy
## Plugin management library
Plumsy is a library for plugin management in C#. It is capable of inspecting and validating dlls, before loading them into the current AppDomain.
Plumsy offers a set of extensions and callbacks to allow the caller to configure what plugins to load, how to handle plugin dependencies and runtime differences.
## Examples
### Create an instance of PluginManager
```C#
var pluginManager = new PluginManager(pathToPlugins);
````
### Retrieve a list of available plugins
```C#
var availablePlugins = pluginManager.GetAvailablePlugins();
```
Plumsy will call the validators for each of the dlls found in the `pathToPlugins` and validate the metadata of those dlls. If the dlls pass the validation, they are marked as available and returned by the above call.
### Load plugins into the current AppDomain
```C#
var results = pluginManager.LoadPlugins(availablePlugins);
```
Plumsy will attempt to load the provided list of plugin entries into the current AppDomain. `results` is a list of `PluginLoadOperation`, the type depending on the result of the load operation.
- `PluginLoadOperation.Success` contains a reference to the loaded assembly and implies that the plugin has been loaded successfully.
- `PluginLoadOperation.NullEntry` implies that the plugin entry was null.
- `PluginLoadOperation.FileNotFound` implies that the plugin path was not found.
- `PluginLoadOperation.ExceptionEncountered` implies that the plugin failed to load due to an exception. Check the inner `Exception` property for details.
- `PluginLoadOperation.UnexpectedErrorOccurred` implies that an unexpected error occurred. This happens when the `Assembly.Load` call succeeds but the returned assembly is `null`.
### Setup validators
```C#
pluginManager
.WithEnvironmentVersionValidator(environmentVersionValidator)
.WithMetadataValidator(metadataValidator)
.WithTypeDefinitionsValidator(typeDefinitionsValidator);
```
- `environmentVersionValidator` is of type `IEnvironmentVersionValidator`. This validator is used to validate the `Version` of the current runtime in comparison to the plugin target runtime.
- `metadataValidator` is of type `IMetadataValidator`. This validator receives a reference to the `MetadataReader` and can perform general validations over the plugin.
- `typeDefinitionsValidator` is of type `ITypeDefinitionsValidator`. This validator receives a reference to the `MetadataReader` as well as a list of `Type`s in order to perform validations over the plugin. This is useful when wanting to validate that the plugin contains a specific entry point class of a known type.
### Callbacks
```C#
pluginManager
.WithAssemblyLoadedCallback(assemblyLoadedCallback);
```
- `assemblyLoadedCallback` is of type `IAssemblyLoadedCallback`. This callback is called after an assembly has been loaded into the current AppDomain.
### Dependency Resolving
```C#
pluginManager
.WithDependencyResolver(dependencyResolver);
```
- `dependencyResolver` is of type `IDependencyResolver`. The dependency solver is called when the runtime is unable to find some dependencies of the currently loading plugin. *!! Due to the runtime lazily loading types, dependencies might only be resolved when calling specific methods !!*
```C#
pluginManager
.WithForceLoadDependencies(true|false);
```
- When forcing the Plumsy to load dependencies, Plumsy will attempt to manually cause JIT-ing of the methods in the loaded assembly. This would in turn cause the runtime to load its dependencies and would trigger `IDependencyResolver.TryResolveDependency` in cases where a dependency is not found.
- By default, Plumsy forces dependencies to load on assembly load. If you want instead to lazily load dependencies, call `pluginManager.WithForceLoadDependencies(false)`.