Skip to content

Implement Plugin Dependency Validation to Avoid Missing Plugins #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
18 changes: 18 additions & 0 deletions src/Core/Configuration/CPluginConfigurationBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ public abstract class CPluginConfigurationBase
/// </remarks>
public abstract IEnumerable<string> GetPluginFiles();

/// <summary>
/// Gets the full path to each plugin file from a configuration source.
/// </summary>
/// <returns>
/// A collection of plugin files that also contains the paths;
/// <para>or</para>
/// Returns an empty enumerable when the plugin files could not be obtained.
/// <para>This method never returns <c>null</c>.</para>
/// </returns>
/// <remarks>
/// Plugin files must be in the <c>plugins</c> directory of the current directory
/// where the host application is running.
/// <para>Each plugin file must have a <c>.dll</c> extension and must be in its own directory.</para>
/// <para>Example:</para>
/// <c>/HostApp/bin/Debug/net7.0/plugins/MyPlugin1/MyPlugin1.dll</c>
/// </remarks>
public abstract IEnumerable<PluginConfig> GetPluginConfigFiles();

/// <summary>
/// Gets the full path of a plugin file.
/// </summary>
Expand Down
38 changes: 33 additions & 5 deletions src/Core/Configuration/CPluginEnvConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace CPlugin.Net;
namespace CPlugin.Net;

/// <summary>
/// Represents a configuration to get the plugin files from an environment variable.
Expand All @@ -11,6 +7,14 @@ namespace CPlugin.Net;
/// The variable must be called <c>PLUGINS</c> and its value must be a string separated by spaces or new lines.
/// <para>Example:</para>
/// <c>PLUGINS=MyPlugin1.dll MyPlugin2.dll</c>
/// <para>if you have plugins with dependencies, you can do this:</para>
/// <c>
/// PLUGINS="
/// MyPlugin1.dll->MyPlugin2.dll,MyPlugin3.dll
/// MyPlugin2.dll
/// MyPlugin3.dll
///"
/// </c>
/// </remarks>
public class CPluginEnvConfiguration : CPluginConfigurationBase
{
Expand All @@ -35,4 +39,28 @@ public override IEnumerable<string> GetPluginFiles()

return pluginFiles;
}

public override IEnumerable<PluginConfig> GetPluginConfigFiles()
{
var retrievedValue = Environment.GetEnvironmentVariable("PLUGINS");
if (retrievedValue is null)
return [];

var pluginFiles = retrievedValue
.Split(s_separator, StringSplitOptions.None)
.Where(pluginFile => !string.IsNullOrWhiteSpace(pluginFile))
.ToList();

return pluginFiles.Select(p =>
{
var str = p.Split("->");
var dependsOn = str.Length == 1 ? [] : str[1].Split(",");

return new PluginConfig
{
Name = GetPluginPath(str[0]),
DependsOn = [.. dependsOn]
};
});
}
}
35 changes: 31 additions & 4 deletions src/Core/Configuration/CPluginJsonConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;

namespace CPlugin.Net;

Expand All @@ -14,6 +11,23 @@ namespace CPlugin.Net;
/// <c>
/// { "Plugins": [ "MyPlugin1.dll", "MyPlugin2.dll" ] }
/// </c>
/// <para>if you have plugins with dependencies, you can do this:</para>
/// <c>
/// {
/// "Plugins": [
/// {
/// "Name": "TestProject.JsonPlugin",
/// "DependsOn": [
/// "TestProject.OldJsonPlugin"
/// ]
/// },
/// {
/// "Name": "TestProject.OldJsonPlugin",
/// "DependsOn": []
/// }
/// ]
/// }
/// </c>
/// </remarks>
public class CPluginJsonConfiguration : CPluginConfigurationBase
{
Expand All @@ -34,6 +48,19 @@ public CPluginJsonConfiguration(IConfiguration configuration)
_configuration = configuration;
}

public override IEnumerable<PluginConfig> GetPluginConfigFiles()
{
var values = _configuration
.GetSection("Plugins")
.Get<PluginConfig[]>();

return values is null ? [] : values.Select(p => new PluginConfig
{
Name = GetPluginPath(p.Name),
DependsOn = p.DependsOn
});
}

/// <inheritdoc />
public override IEnumerable<string> GetPluginFiles()
{
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Configuration/PluginConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CPlugin.Net;
public class PluginConfig
{
public string Name { get; set; } = string.Empty;
public List<string> DependsOn { get; set; } = [];
}
10 changes: 10 additions & 0 deletions src/Core/Exceptions/PluginNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CPlugin.Net.Exceptions;
/// <summary>
/// Represents an exception that is thrown when a plugin is not found.
/// </summary>
/// <param name="missingPlugin"> The missing plugin. </param>
/// <param name="dependentPlugin"> The dependent plugin. </param>
public class PluginNotFoundException(string missingPlugin, string dependentPlugin)
: Exception($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found.")
{
}
47 changes: 46 additions & 1 deletion src/Core/PluginLoader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using CPlugin.Net.Exceptions;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -42,6 +43,50 @@ public static void Load(CPluginConfigurationBase configuration)
}
}

/// <summary>
/// Loads plugins and their dependencies from a specified configuration source.
/// The plugin list can be retrieved from a JSON file, an environment variable (.env), or another configuration source.
/// This method ensures that all required dependencies are resolved before loading a plugin.
/// </summary>
/// <param name="configuration">
/// A configuration source that provides the list of plugin files and their dependencies.
/// </param>
/// <remarks>
/// This method is idempotent, meaning that if it is called multiple times,
/// it will not reload assemblies that have already been loaded.
/// If a plugin depends on another plugin that is missing, a <see cref="PluginNotFoundException"/> is thrown.
/// </remarks>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="configuration"/> is <c>null</c>.
/// </exception>
/// <exception cref="PluginNotFoundException">
/// Thrown when a required plugin dependency is missing.
/// </exception>
public static void LoadPluginsWithDependencies(CPluginConfigurationBase configuration)

{
ArgumentNullException.ThrowIfNull(configuration);
var pluginConfigs = configuration.GetPluginConfigFiles();
foreach (var pluginConfig in pluginConfigs)
{
if (pluginConfig.DependsOn?.Count > 0)
{
foreach (var dependency in pluginConfig.DependsOn)
{
if (!pluginConfigs.Any(pc => pc.Name.Contains(dependency)))
{
string pluginName = Path.GetFileName(pluginConfig.Name);
throw new PluginNotFoundException(dependency, pluginName);
}
}
}

Assembly currentAssembly = FindAssembly(pluginConfig.Name);
if (currentAssembly is null)
LoadAssembly(pluginConfig.Name);
}
}

private static void LoadAssembly(string assemblyFile)
{
var loadContext = new PluginLoadContext(assemblyFile);
Expand Down
32 changes: 32 additions & 0 deletions tests/CPlugin.Net/Core/CPluginEnvConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ public void GetPluginFiles_WhenPluginFilesAreObtainedFromEnvFile_ShouldReturnsFu
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginConfigFiles_WhenPluginFilesAreObtainedFromEnvFile_ShouldReturnsFullPaths()
{
// Arrange
new EnvLoader()
.AllowOverwriteExistingVars()
.EnableFileNotFoundException()
.AddEnvFile("./Resources/testwithdependencies.env")
.Load();
var envConfiguration = new CPluginEnvConfiguration();
var basePath = AppContext.BaseDirectory;
PluginConfig[] expectedPaths =
[
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.OldJsonPlugin", "TestProject.OldJsonPlugin.dll"),
DependsOn = []
},
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.JsonPlugin", "TestProject.JsonPlugin.dll"),
DependsOn = ["TestProject.OldJsonPlugin.dll"]
},
];

// Act
var actual = envConfiguration.GetPluginConfigFiles().ToList();

// Assert
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginFiles_WhenPluginFilesAreNotPresent_ShouldReturnsEmptyEnumerable()
{
Expand Down
28 changes: 28 additions & 0 deletions tests/CPlugin.Net/Core/CPluginJsonConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,34 @@ public void GetPluginFiles_WhenPluginFileDoesNotHaveDllExtension_ShouldBeAddedBy
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void GetPluginConfigFiles_WhenPluginFilesArePresent_ShouldReturnsFullPaths()
{
// Arrange
var configurationRoot = new ConfigurationBuilder()
.AddJsonFile("./Resources/settingsWithDependencies.json")
.Build();
var jsonConfiguration = new CPluginJsonConfiguration(configurationRoot);
var basePath = AppContext.BaseDirectory;
PluginConfig[] expectedPaths =
[
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.OldJsonPlugin", "TestProject.OldJsonPlugin.dll"),
DependsOn = []
},
new PluginConfig
{
Name = Path.Combine(basePath, "plugins", "TestProject.JsonPlugin", "TestProject.JsonPlugin.dll"),
DependsOn = ["TestProject.OldJsonPlugin"]
},
];
// Act
var actual = jsonConfiguration.GetPluginConfigFiles().ToList();
// Assert
actual.Should().BeEquivalentTo(expectedPaths);
}

[Test]
public void Constructor_WhenArgumentIsNull_ShouldThrowArgumentNullException()
{
Expand Down
109 changes: 108 additions & 1 deletion tests/CPlugin.Net/Core/PluginLoaderTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace CPlugin.Net.Tests.Core;
using CPlugin.Net.Exceptions;

namespace CPlugin.Net.Tests.Core;

public class PluginLoaderTests
{
Expand Down Expand Up @@ -79,4 +81,109 @@ public void Load_WhenMethodIsCalledMultipleTimes_ShouldNotLoadSamePluginsIntoMem
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsAreFound_ShouldBeLoadedIntoMemory()
{
// Arrange
var value =
"""
TestProject.OldJsonPlugin.dll->TestProject.JsonPlugin.dll
TestProject.JsonPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 2;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => assembly.GetName().Name == "TestProject.OldJsonPlugin"
|| assembly.GetName().Name == "TestProject.JsonPlugin")
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsAreIndependent_ShouldBeLoadedIntoMemory()
{
// Arrange
var value =
"""
TestProject.OldJsonPlugin.dll
TestProject.JsonPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 2;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => assembly.GetName().Name == "TestProject.OldJsonPlugin"
|| assembly.GetName().Name == "TestProject.JsonPlugin")
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenPluginsHaveMultipleDependencies_ShouldBeLoaded()
{
// Arrange
List<string> plugins =
[
"TestProject.OldJsonPlugin",
"TestProject.JsonPlugin",
"TestProject.HelloPlugin"
];

var value =
"""
TestProject.OldJsonPlugin.dll
TestProject.JsonPlugin.dll->TestProject.OldJsonPlugin.dll,TestProject.HelloPlugin.dll
TestProject.HelloPlugin.dll
""";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();
int expectedAssemblies = 3;

// Act
PluginLoader.LoadPluginsWithDependencies(envConfiguration);

// Assert
AppDomain
.CurrentDomain
.GetAssemblies()
.Where(assembly => plugins.Contains(assembly.GetName().Name))
.Count()
.Should()
.Be(expectedAssemblies);
}

[Test]
public void LoadPluginsWithDependencies_WhenDependencyIsNotFound_ShouldThrowPluginNotFoundException()
{
// Arrange
var dependentPlugin = "TestProject.JsonPlugin.dll";
var missingPlugin = "TestProject.OldJsonPlugin.dll";
var value = $"{dependentPlugin}->{missingPlugin}";
Environment.SetEnvironmentVariable("PLUGINS", value);
var envConfiguration = new CPluginEnvConfiguration();

// Act
Action act = () => PluginLoader.LoadPluginsWithDependencies(envConfiguration);

// Assert
act.Should()
.Throw<PluginNotFoundException>()
.WithMessage($"The plugin '{dependentPlugin}' depends on '{missingPlugin}', but '{missingPlugin}' was not found.");
}
}
Loading