diff options
author | vnugent <public@vaughnnugent.com> | 2023-01-09 15:09:13 -0500 |
---|---|---|
committer | vnugent <public@vaughnnugent.com> | 2023-01-09 15:09:13 -0500 |
commit | a46c3bf452d287b50b2e7dd5a24f5995c9fd26f6 (patch) | |
tree | 3a978b2dd2887b5c0e25f595516594a647d8e880 /lib/VNLib.Plugins.Extensions.Loading/src | |
parent | 189c6714057bf45553847eaeb9ce97eb7272eb8c (diff) |
Restructure
Diffstat (limited to 'lib/VNLib.Plugins.Extensions.Loading/src')
16 files changed, 2235 insertions, 0 deletions
diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs b/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs new file mode 100644 index 0000000..5baf123 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/AssemblyLoader.cs @@ -0,0 +1,162 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: AssemblyLoader.cs +* +* AssemblyLoader.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Threading; +using System.Reflection; +using System.Runtime.Loader; +using System.Collections.Generic; + +using McMaster.NETCore.Plugins; + +using VNLib.Utils.Resources; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// <summary> + /// <para> + /// Represents a disposable assembly loader wrapper for + /// exporting a signle type from a loaded assembly + /// </para> + /// <para> + /// If the loaded type implements <see cref="IDisposable"/> the + /// dispose method is called when the loader is disposed + /// </para> + /// </summary> + /// <typeparam name="T">The exported type to manage</typeparam> + public class AssemblyLoader<T> : OpenResourceHandle<T> + { + private readonly PluginLoader _loader; + private readonly CancellationTokenRegistration _reg; + private readonly Lazy<T> _instance; + + /// <summary> + /// The instance of the loaded type + /// </summary> + public override T Resource => _instance.Value; + + private AssemblyLoader(PluginLoader loader, in CancellationToken unloadToken) + { + _loader = loader; + //Init lazy loader + _instance = new(LoadAndGetExportedType, LazyThreadSafetyMode.PublicationOnly); + //Register dispose + _reg = unloadToken.Register(Dispose); + } + + /// <summary> + /// Loads the default assembly and gets the expected export type, + /// creates a new instance, and calls its parameterless constructor + /// </summary> + /// <returns>The desired type instance</returns> + /// <exception cref="EntryPointNotFoundException"></exception> + private T LoadAndGetExportedType() + { + //Load the assembly + Assembly asm = _loader.LoadDefaultAssembly(); + + Type resourceType = typeof(T); + + //See if the type is exported + Type exp = (from type in asm.GetExportedTypes() + where resourceType.IsAssignableFrom(type) + select type) + .FirstOrDefault() + ?? throw new EntryPointNotFoundException($"Imported assembly does not export desired type {resourceType.FullName}"); + //Create instance + return (T)Activator.CreateInstance(exp)!; + } + + /// <summary> + /// Creates a method delegate for the given method name from + /// the instance wrapped by the current loader + /// </summary> + /// <typeparam name="TDelegate"></typeparam> + /// <param name="methodName">The name of the method to recover</param> + /// <returns>The delegate method wrapper if found, null otherwise</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="AmbiguousMatchException"></exception> + public TDelegate? TryGetMethod<TDelegate>(string methodName) where TDelegate : Delegate + { + //get the type info of the actual resource + return Resource!.GetType() + .GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance) + ?.CreateDelegate<TDelegate>(Resource); + } + + ///<inheritdoc/> + protected override void Free() + { + //If the instance is disposable, call its dispose method on unload + if (_instance.IsValueCreated && _instance.Value is IDisposable) + { + (_instance.Value as IDisposable)?.Dispose(); + } + _loader.Dispose(); + _reg.Dispose(); + } + + /// <summary> + /// Creates a new assembly loader for the specified type and + /// </summary> + /// <param name="assemblyName">The name of the assmbly within the current plugin directory</param> + /// <param name="unloadToken">The plugin unload token</param> + internal static AssemblyLoader<T> Load(string assemblyName, CancellationToken unloadToken) + { + Assembly executingAsm = Assembly.GetExecutingAssembly(); + AssemblyLoadContext currentCtx = AssemblyLoadContext.GetLoadContext(executingAsm) ?? throw new InvalidOperationException("Could not get default assembly load context"); + + List<Type> shared = new () + { + typeof(T), + typeof(PluginBase), + }; + + //Share all VNLib internal libraries + shared.AddRange(currentCtx.Assemblies.Where(static s => s.FullName.Contains("VNLib", StringComparison.OrdinalIgnoreCase)).SelectMany(static s => s.GetExportedTypes())); + + PluginLoader loader = PluginLoader.CreateFromAssemblyFile(assemblyName, + currentCtx.IsCollectible, + shared.ToArray(), + conf => + { + + /* + * Load context is required to be set to the executing assembly's load context + * because it is controlled by the host, so this loader should be considered a + * a "child" collection of assemblies + */ + conf.DefaultContext = currentCtx; + + conf.PreferSharedTypes = true; + + //Share utils asm + conf.SharedAssemblies.Add(typeof(Utils.Memory.Memory).Assembly.GetName()); + }); + + return new(loader, in unloadToken); + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs b/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs new file mode 100644 index 0000000..18df8e0 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/ConfigurationExtensions.cs @@ -0,0 +1,199 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: ConfigurationExtensions.cs +* +* ConfigurationExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text.Json; +using System.Reflection; +using System.Collections.Generic; + +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// <summary> + /// Specifies a configuration variable name in the plugin's configuration + /// containing data specific to the type + /// </summary> + [AttributeUsage(AttributeTargets.Class)] + public sealed class ConfigurationNameAttribute : Attribute + { + /// <summary> + /// + /// </summary> + public string ConfigVarName { get; } + + /// <summary> + /// Initializes a new <see cref="ConfigurationNameAttribute"/> + /// </summary> + /// <param name="configVarName">The name of the configuration variable for the class</param> + public ConfigurationNameAttribute(string configVarName) + { + ConfigVarName = configVarName; + } + } + + /// <summary> + /// Contains extensions for plugin configuration specifc extensions + /// </summary> + public static class ConfigurationExtensions + { + public const string S3_CONFIG = "s3_config"; + public const string S3_SECRET_KEY = "s3_secret"; + + /// <summary> + /// Retrieves a top level configuration dictionary of elements for the specified type. + /// The type must contain a <see cref="ConfigurationNameAttribute"/> + /// </summary> + /// <typeparam name="T">The type to get the configuration of</typeparam> + /// <param name="plugin"></param> + /// <returns>A <see cref="Dictionary{TKey, TValue}"/> of top level configuration elements for the type</returns> + /// <exception cref="ObjectDisposedException"></exception> + public static IReadOnlyDictionary<string, JsonElement> GetConfigForType<T>(this PluginBase plugin) + { + Type t = typeof(T); + return plugin.GetConfigForType(t); + } + /// <summary> + /// Retrieves a top level configuration dictionary of elements with the specified property name, + /// from the plugin config first, or falls back to the host config file + /// </summary> + /// <param name="plugin"></param> + /// <param name="propName">The config property name to retrieve</param> + /// <returns>A <see cref="Dictionary{TKey, TValue}"/> of top level configuration elements for the type</returns> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static IReadOnlyDictionary<string, JsonElement> GetConfig(this PluginBase plugin, string propName) + { + plugin.ThrowIfUnloaded(); + try + { + //Try to get the element from the plugin config first + if (!plugin.PluginConfig.TryGetProperty(propName, out JsonElement el)) + { + //Fallback to the host config + el = plugin.HostConfig.GetProperty(propName); + } + //Get the top level config as a dictionary + return el.EnumerateObject().ToDictionary(static k => k.Name, static k => k.Value); + } + catch(KeyNotFoundException) + { + throw new KeyNotFoundException($"Missing required top level configuration object '{propName}', in host/plugin configuration files"); + } + } + /// <summary> + /// Retrieves a top level configuration dictionary of elements with the specified property name, + /// from the plugin config first, or falls back to the host config file + /// </summary> + /// <param name="plugin"></param> + /// <param name="propName">The config property name to retrieve</param> + /// <returns>A <see cref="Dictionary{TKey, TValue}"/> of top level configuration elements for the type</returns> + /// <exception cref="ObjectDisposedException"></exception> + public static IReadOnlyDictionary<string, JsonElement>? TryGetConfig(this PluginBase plugin, string propName) + { + plugin.ThrowIfUnloaded(); + //Try to get the element from the plugin config first, or fallback to host + if (plugin.PluginConfig.TryGetProperty(propName, out JsonElement el) || plugin.HostConfig.TryGetProperty(propName, out el)) + { + //Get the top level config as a dictionary + return el.EnumerateObject().ToDictionary(static k => k.Name, static k => k.Value); + } + //No config found + return null; + } + + /// <summary> + /// Retrieves a top level configuration dictionary of elements for the specified type. + /// The type must contain a <see cref="ConfigurationNameAttribute"/> + /// </summary> + /// <param name="plugin"></param> + /// <param name="type">The type to get configuration data for</param> + /// <returns>A <see cref="Dictionary{TKey, TValue}"/> of top level configuration elements for the type</returns> + /// <exception cref="ObjectDisposedException"></exception> + public static IReadOnlyDictionary<string, JsonElement> GetConfigForType(this PluginBase plugin, Type type) + { + //Get config name attribute from plugin type + ConfigurationNameAttribute? configName = type.GetCustomAttribute<ConfigurationNameAttribute>(); + return configName?.ConfigVarName == null + ? throw new KeyNotFoundException("No configuration attribute set") + : plugin.GetConfig(configName.ConfigVarName); + } + + /// <summary> + /// Shortcut extension for <see cref="GetConfigForType{T}(PluginBase)"/> to get + /// config of current class + /// </summary> + /// <param name="obj">The object that a configuration can be retrieved for</param> + /// <param name="plugin">The plugin containing configuration variables</param> + /// <returns>A <see cref="Dictionary{TKey, TValue}"/> of top level configuration elements for the type</returns> + /// <exception cref="ObjectDisposedException"></exception> + public static IReadOnlyDictionary<string, JsonElement> GetConfig(this PluginBase plugin, object obj) + { + Type t = obj.GetType(); + return plugin.GetConfigForType(t); + } + + /// <summary> + /// Determines if the current plugin configuration contains the require properties to initialize + /// the type + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="plugin"></param> + /// <returns>True if the plugin config contains the require configuration property</returns> + public static bool HasConfigForType<T>(this PluginBase plugin) + { + Type type = typeof(T); + ConfigurationNameAttribute? configName = type.GetCustomAttribute<ConfigurationNameAttribute>(); + //See if the plugin contains a configuration varables + return configName != null && plugin.PluginConfig.TryGetProperty(configName.ConfigVarName, out _); + } + + /// <summary> + /// Attempts to load the basic S3 configuration variables required + /// for S3 client access + /// </summary> + /// <param name="plugin"></param> + /// <returns>The S3 configuration object found in the plugin/host configuration</returns> + public static S3Config? TryGetS3Config(this PluginBase plugin) + { + //Try get the config + IReadOnlyDictionary<string, JsonElement>? s3conf = plugin.TryGetConfig(S3_CONFIG); + if(s3conf == null) + { + return null; + } + + //Try get the elements + return new() + { + BaseBucket = s3conf.GetPropString("bucket"), + ClientId = s3conf.GetPropString("access_key"), + ServerAddress = s3conf.GetPropString("server_address"), + UseSsl = s3conf.TryGetValue("use_ssl", out JsonElement el) && el.GetBoolean(), + Region = s3conf.GetPropString("region"), + }; + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Events/AsyncIntervalAttribute.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Events/AsyncIntervalAttribute.cs new file mode 100644 index 0000000..85b0b6d --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Events/AsyncIntervalAttribute.cs @@ -0,0 +1,47 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: AsyncIntervalAttribute.cs +* +* AsyncIntervalAttribute.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +namespace VNLib.Plugins.Extensions.Loading.Events +{ + /// <summary> + /// When added to a method schedules it as a callback on a specified interval when + /// the plugin is loaded, and stops when unloaded + /// </summary> + [AttributeUsage(AttributeTargets.Method)] + public sealed class AsyncIntervalAttribute : Attribute + { + internal readonly TimeSpan Interval; + + /// <summary> + /// Intializes the <see cref="AsyncIntervalAttribute"/> with the specified timeout in milliseconds + /// </summary> + /// <param name="milliseconds">The interval in milliseconds</param> + public AsyncIntervalAttribute(int milliseconds) + { + Interval = TimeSpan.FromMilliseconds(milliseconds); + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Events/ConfigurableAsyncIntervalAttribute.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Events/ConfigurableAsyncIntervalAttribute.cs new file mode 100644 index 0000000..12c5ec4 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Events/ConfigurableAsyncIntervalAttribute.cs @@ -0,0 +1,51 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: ConfigurableAsyncIntervalAttribute.cs +* +* ConfigurableAsyncIntervalAttribute.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +namespace VNLib.Plugins.Extensions.Loading.Events +{ + /// <summary> + /// When added to a method schedules it as a callback on a specified interval when + /// the plugin is loaded, and stops when unloaded + /// </summary> + [AttributeUsage(AttributeTargets.Method)] + public sealed class ConfigurableAsyncIntervalAttribute : Attribute + { + internal readonly string IntervalPropertyName; + internal readonly IntervalResultionType Resolution; + + /// <summary> + /// Initializes a <see cref="ConfigurableAsyncIntervalAttribute"/> with the specified + /// interval property name + /// </summary> + /// <param name="configPropName">The configuration property name for the event interval</param> + /// <param name="resolution">The time resoltion for the event interval</param> + public ConfigurableAsyncIntervalAttribute(string configPropName, IntervalResultionType resolution) + { + IntervalPropertyName = configPropName; + Resolution = resolution; + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Events/EventManagment.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Events/EventManagment.cs new file mode 100644 index 0000000..f671b07 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Events/EventManagment.cs @@ -0,0 +1,124 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: EventManagment.cs +* +* EventManagment.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Utils.Logging; + +namespace VNLib.Plugins.Extensions.Loading.Events +{ + + /// <summary> + /// A deletage to form a method signature for shedulable interval callbacks + /// </summary> + /// <param name="log">The plugin's default log provider</param> + /// <param name="pluginExitToken">The plugin's exit token</param> + /// <returns>A task the represents the asynchronous work</returns> + public delegate Task AsyncSchedulableCallback(ILogProvider log, CancellationToken pluginExitToken); + + /// <summary> + /// Provides event schedueling extensions for plugins + /// </summary> + public static class EventManagment + { + /// <summary> + /// Schedules an asynchronous event interval for the current plugin, that is active until canceled or until the plugin unloads + /// </summary> + /// <param name="plugin"></param> + /// <param name="asyncCallback">An asyncrhonous callback method.</param> + /// <param name="interval">The event interval</param> + /// <param name="immediate">A value that indicates if the callback should be run as soon as possible</param> + /// <exception cref="ObjectDisposedException"></exception> + /// <remarks>If exceptions are raised during callback execution, they are written to the plugin's default log provider</remarks> + public static void ScheduleInterval(this PluginBase plugin, AsyncSchedulableCallback asyncCallback, TimeSpan interval, bool immediate = false) + { + plugin.ThrowIfUnloaded(); + + plugin.Log.Verbose("Interval for {t} scheduled", interval); + + //Run interval on plugins bg scheduler + _ = plugin.DeferTask(() => RunIntervalOnPluginScheduler(plugin, asyncCallback, interval, immediate)); + } + + private static async Task RunIntervalOnPluginScheduler(PluginBase plugin, AsyncSchedulableCallback callback, TimeSpan interval, bool immediate) + { + + static async Task RunCallbackAsync(PluginBase plugin, AsyncSchedulableCallback callback) + { + try + { + //invoke interval callback + await callback(plugin.Log, plugin.UnloadToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + //unloaded + plugin.Log.Verbose("Interval callback canceled due to plugin unload or other event cancellation"); + } + catch (Exception ex) + { + plugin.Log.Error(ex, "Unhandled exception raised during timer callback"); + } + } + + //Run callback immediatly if requested + if (immediate) + { + await RunCallbackAsync(plugin, callback); + } + + //Timer loop + while (true) + { + try + { + //await delay and wait for plugin cancellation + await Task.Delay(interval, plugin.UnloadToken); + } + catch (TaskCanceledException) + { + //Unload token canceled, exit loop + break; + } + + await RunCallbackAsync(plugin, callback); + } + } + + /// <summary> + /// Registers an <see cref="IIntervalScheduleable"/> type's event handler for + /// raising timed interval events + /// </summary> + /// <param name="plugin"></param> + /// <param name="scheduleable">The instance to schedule for timeouts</param> + /// <param name="interval">The timeout interval</param> + /// <param name="immediate">A value that indicates if the callback should be run as soon as possible</param> + /// <exception cref="ObjectDisposedException"></exception> + /// <remarks>If exceptions are raised during callback execution, they are written to the plugin's default log provider</remarks> + public static void ScheduleInterval(this PluginBase plugin, IIntervalScheduleable scheduleable, TimeSpan interval, bool immediate = false) => + ScheduleInterval(plugin, scheduleable.OnIntervalAsync, interval, immediate); + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Events/IIntervalScheduleable.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Events/IIntervalScheduleable.cs new file mode 100644 index 0000000..5ff40f4 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Events/IIntervalScheduleable.cs @@ -0,0 +1,45 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: IIntervalScheduleable.cs +* +* IIntervalScheduleable.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Utils.Logging; + +namespace VNLib.Plugins.Extensions.Loading.Events +{ + /// <summary> + /// Exposes a type for asynchronous event schelueling + /// </summary> + public interface IIntervalScheduleable + { + /// <summary> + /// A method that is called when the interval time has elapsed + /// </summary> + /// <param name="log">The plugin default log provider</param> + /// <param name="cancellationToken">A token that may cancel an operations if the plugin becomes unloaded</param> + /// <returns>A task that resolves when the async operation completes</returns> + Task OnIntervalAsync(ILogProvider log, CancellationToken cancellationToken); + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/Events/IntervalResultionType.cs b/lib/VNLib.Plugins.Extensions.Loading/src/Events/IntervalResultionType.cs new file mode 100644 index 0000000..d82efc4 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/Events/IntervalResultionType.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: IntervalResultionType.cs +* +* IntervalResultionType.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Extensions.Loading.Events +{ + /// <summary> + /// The configurable event interval resulution type + /// </summary> + public enum IntervalResultionType + { + /// <summary> + /// Specifies event interval resolution in milliseconds + /// </summary> + Milliseconds, + /// <summary> + /// Specifies event interval resolution in seconds + /// </summary> + Seconds, + /// <summary> + /// Specifies event interval resolution in minutes + /// </summary> + Minutes, + /// <summary> + /// Specifies event interval resolution in hours + /// </summary> + Hours + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs b/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs new file mode 100644 index 0000000..c23f5e2 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/LoadingExtensions.cs @@ -0,0 +1,334 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: LoadingExtensions.cs +* +* LoadingExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Runtime.CompilerServices; + +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Accounts; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// <summary> + /// Provides common loading (and unloading when required) extensions for plugins + /// </summary> + public static class LoadingExtensions + { + public const string DEBUG_CONFIG_KEY = "debug"; + public const string SECRETS_CONFIG_KEY = "secrets"; + public const string PASSWORD_HASHING_KEY = "passwords"; + + /* + * Plugin local cache used for storing singletons for a plugin instance + */ + private static readonly ConditionalWeakTable<PluginBase, PluginLocalCache> _localCache = new(); + + /// <summary> + /// Gets a previously cached service singleton for the desired plugin + /// </summary> + /// <param name="serviceType">The service instance type</param> + /// <param name="plugin">The plugin to obtain or build the singleton for</param> + /// <param name="serviceFactory">The method to produce the singleton</param> + /// <returns>The cached or newly created singleton</returns> + public static object GetOrCreateSingleton(PluginBase plugin, Type serviceType, Func<PluginBase, object> serviceFactory) + { + Lazy<object>? service; + //Get local cache + PluginLocalCache pc = _localCache.GetValue(plugin, PluginLocalCache.Create); + //Hold lock while get/set the singleton + lock (pc.SyncRoot) + { + //Check if service already exists + service = pc.GetService(serviceType); + //publish the service if it isnt loaded yet + service ??= pc.AddService(serviceType, serviceFactory); + } + //Deferred load of the service + return service.Value; + } + + /// <summary> + /// Gets a previously cached service singleton for the desired plugin + /// or creates a new singleton instance for the plugin + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="plugin">The plugin to obtain or build the singleton for</param> + /// <param name="serviceFactory">The method to produce the singleton</param> + /// <returns>The cached or newly created singleton</returns> + public static T GetOrCreateSingleton<T>(PluginBase plugin, Func<PluginBase, T> serviceFactory) + => (T)GetOrCreateSingleton(plugin, typeof(T), p => serviceFactory(p)!); + + + /// <summary> + /// Gets the plugins ambient <see cref="PasswordHashing"/> if loaded, or loads it if required. This class will + /// be unloaded when the plugin us unloaded. + /// </summary> + /// <param name="plugin"></param> + /// <returns>The ambient <see cref="PasswordHashing"/></returns> + /// <exception cref="OverflowException"></exception> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static PasswordHashing GetPasswords(this PluginBase plugin) + { + plugin.ThrowIfUnloaded(); + //Get/load the passwords one time only + return GetOrCreateSingleton(plugin, LoadPasswords); + } + + private static PasswordHashing LoadPasswords(PluginBase plugin) + { + PasswordHashing Passwords; + //Get the global password system secret (pepper) + byte[] pepper = plugin.TryGetSecretAsync(PASSWORD_HASHING_KEY) + .ToBase64Bytes().Result ?? throw new KeyNotFoundException($"Missing required key '{PASSWORD_HASHING_KEY}' in secrets"); + + ERRNO cb(Span<byte> buffer) + { + //No longer valid peper if plugin is unloaded as its set to zero, so we need to protect it + plugin.ThrowIfUnloaded(); + + pepper.CopyTo(buffer); + return pepper.Length; + } + + //See hashing params are defined + IReadOnlyDictionary<string, JsonElement>? hashingArgs = plugin.TryGetConfig(PASSWORD_HASHING_KEY); + if (hashingArgs != null) + { + //Get hashing arguments + uint saltLen = hashingArgs["salt_len"].GetUInt32(); + uint hashLen = hashingArgs["hash_len"].GetUInt32(); + uint timeCost = hashingArgs["time_cost"].GetUInt32(); + uint memoryCost = hashingArgs["memory_cost"].GetUInt32(); + uint parallelism = hashingArgs["parallelism"].GetUInt32(); + //Load passwords + Passwords = new(cb, pepper.Length, (int)saltLen, timeCost, memoryCost, parallelism, hashLen); + } + else + { + //Init default password hashing + Passwords = new(cb, pepper.Length); + } + + //Register event to cleanup the password class + _ = plugin.RegisterForUnload(() => + { + //Zero the pepper + CryptographicOperations.ZeroMemory(pepper); + }); + //return + return Passwords; + } + + + /// <summary> + /// Loads an assembly into the current plugins AppDomain and will unload when disposed + /// or the plugin is unloaded from the host application. + /// </summary> + /// <typeparam name="T">The desired exported type to load from the assembly</typeparam> + /// <param name="plugin"></param> + /// <param name="assemblyName">The name of the assembly (ex: 'file.dll') to search for</param> + /// <param name="dirSearchOption">Directory/file search option</param> + /// <returns>The <see cref="AssemblyLoader{T}"/> managing the loaded assmbly in the current AppDomain</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="FileNotFoundException"></exception> + /// <exception cref="EntryPointNotFoundException"></exception> + public static AssemblyLoader<T> LoadAssembly<T>(this PluginBase plugin, string assemblyName, SearchOption dirSearchOption = SearchOption.AllDirectories) + { + plugin.ThrowIfUnloaded(); + _ = assemblyName ?? throw new ArgumentNullException(nameof(assemblyName)); + + //get plugin directory from config + IReadOnlyDictionary<string, JsonElement> config = plugin.GetConfig("plugins"); + string? pluginsBaseDir = config["path"].GetString(); + + /* + * This should never happen since this method can only be called from a + * plugin context, which means this path was used to load the current plugin + */ + _ = pluginsBaseDir ?? throw new ArgumentNullException("path", "No plugin path is defined for the current host configuration, this is likely a bug"); + + //Get the first file that matches the search file + string? asmFile = Directory.EnumerateFiles(pluginsBaseDir, assemblyName, dirSearchOption).FirstOrDefault(); + _ = asmFile ?? throw new FileNotFoundException($"Failed to load custom assembly {assemblyName} from plugin directory"); + + //Load the assembly + return AssemblyLoader<T>.Load(asmFile, plugin.UnloadToken); + } + + + /// <summary> + /// Determintes if the current plugin config has a debug propety set + /// </summary> + /// <param name="plugin"></param> + /// <returns>True if debug mode is enabled, false otherwise</returns> + /// <exception cref="ObjectDisposedException"></exception> + public static bool IsDebug(this PluginBase plugin) + { + plugin.ThrowIfUnloaded(); + //Check for debug element + return plugin.PluginConfig.TryGetProperty(DEBUG_CONFIG_KEY, out JsonElement dbgEl) && dbgEl.GetBoolean(); + } + + /// <summary> + /// Internal exception helper to raise <see cref="ObjectDisposedException"/> if the plugin has been unlaoded + /// </summary> + /// <param name="plugin"></param> + /// <exception cref="ObjectDisposedException"></exception> + public static void ThrowIfUnloaded(this PluginBase plugin) + { + //See if the plugin was unlaoded + if (plugin.UnloadToken.IsCancellationRequested) + { + throw new ObjectDisposedException("The plugin has been unloaded"); + } + } + + /// <summary> + /// Schedules an asynchronous callback function to run and its results will be observed + /// when the operation completes, or when the plugin is unloading + /// </summary> + /// <param name="plugin"></param> + /// <param name="asyncTask">The asynchronous operation to observe</param> + /// <param name="delayMs">An optional startup delay for the operation</param> + /// <returns>A task that completes when the deferred task completes </returns> + /// <exception cref="ObjectDisposedException"></exception> + public static async Task DeferTask(this PluginBase plugin, Func<Task> asyncTask, int delayMs = 0) + { + /* + * Motivation: + * Sometimes during plugin loading, a plugin may want to asynchronously load + * data, where the results are not required to be observed during loading, but + * should not be pending after the plugin is unloaded, as the assembly may be + * unloaded and referrences collected by the GC. + * + * So we can use the plugin's unload cancellation token to observe the results + * of a pending async operation + */ + + //Test status + plugin.ThrowIfUnloaded(); + + //Optional delay + await Task.Delay(delayMs); + + //Run on ts + Task deferred = Task.Run(asyncTask); + + //Add task to deferred list + plugin.ObserveTask(deferred); + try + { + //Await the task results + await deferred.ConfigureAwait(false); + } + catch(Exception ex) + { + //Log errors + plugin.Log.Error(ex, "Error occured while observing deferred task"); + } + finally + { + //Remove task when complete + plugin.RemoveObservedTask(deferred); + } + } + + /// <summary> + /// Registers an event to occur when the plugin is unloaded on a background thread + /// and will cause the Plugin.Unload() method to block until the event completes + /// </summary> + /// <param name="pbase"></param> + /// <param name="callback">The method to call when the plugin is unloaded</param> + /// <returns>A task that represents the registered work</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static Task RegisterForUnload(this PluginBase pbase, Action callback) + { + //Test status + pbase.ThrowIfUnloaded(); + _ = callback ?? throw new ArgumentNullException(nameof(callback)); + + //Wait method + static async Task WaitForUnload(PluginBase pb, Action callback) + { + //Wait for unload as a task on the threadpool to avoid deadlocks + await pb.UnloadToken.WaitHandle.WaitAsync() + .ConfigureAwait(false); + + callback(); + } + + //Registaer the task to cause the plugin to wait + return pbase.DeferTask(() => WaitForUnload(pbase, callback)); + } + + + private sealed class PluginLocalCache + { + private readonly PluginBase _plugin; + + private readonly Dictionary<Type, Lazy<object>> _store; + + public object SyncRoot { get; } = new(); + + private PluginLocalCache(PluginBase plugin) + { + _plugin = plugin; + _store = new(); + //Register cleanup on unload + _ = _plugin.RegisterForUnload(() => _store.Clear()); + } + + public static PluginLocalCache Create(PluginBase plugin) => new(plugin); + + + public Lazy<object>? GetService(Type serviceType) + { + Lazy<object>? t = _store.Where(t => t.Key.IsAssignableTo(serviceType)) + .Select(static tk => tk.Value) + .FirstOrDefault(); + return t; + } + + public Lazy<object> AddService(Type serviceType, Func<PluginBase, object> factory) + { + //Get lazy loader to invoke factory outside of cache lock + Lazy<object> lazyFactory = new(() => factory(_plugin), true); + //Store lazy factory + _store.Add(serviceType, lazyFactory); + //Pass the lazy factory back + return lazyFactory; + } + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs b/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs new file mode 100644 index 0000000..336f6a4 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/PrivateKey.cs @@ -0,0 +1,102 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: PrivateKey.cs +* +* PrivateKey.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Text; +using System.Security.Cryptography; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// <summary> + /// A container for a PKSC#8 encoed private key + /// </summary> + public sealed class PrivateKey : VnDisposeable + { + private readonly byte[] _utf8RawData; + + /// <summary> + /// Decodes the PKCS#8 encoded private key from a secret, as an EC private key + /// and recovers the ECDsa algorithm from the key + /// </summary> + /// <returns>The <see cref="ECDsa"/> algoritm from the private key</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="CryptographicException"></exception> + public ECDsa GetECDsa() + { + //Alloc buffer + using IMemoryHandle<byte> buffer = Memory.SafeAlloc<byte>(_utf8RawData.Length); + //Get base64 bytes from utf8 + ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); + //Parse the private key + ECDsa alg = ECDsa.Create(); + alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); + //Wipe the buffer + Memory.InitializeBlock(buffer.Span); + return alg; + } + + /// <summary> + /// Decodes the PKCS#8 encoded private key from a secret, as an RSA private key + /// </summary> + /// <returns>The <see cref="RSA"/> algorithm from the private key</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="CryptographicException"></exception> + public RSA GetRSA() + { + //Alloc buffer + using IMemoryHandle<byte> buffer = Memory.SafeAlloc<byte>(_utf8RawData.Length); + //Get base64 bytes from utf8 + ERRNO count = VnEncoding.Base64UrlDecode(_utf8RawData, buffer.Span); + //Parse the private key + RSA alg = RSA.Create(); + alg.ImportPkcs8PrivateKey(buffer.Span[..(int)count], out _); + //Wipe the buffer + Memory.InitializeBlock(buffer.Span); + return alg; + } + + internal PrivateKey(SecretResult secret) + { + //Alloc and get utf8 + byte[] buffer = new byte[secret.Result.Length]; + int count = Encoding.UTF8.GetBytes(secret.Result, buffer); + //Verify length + if(count != buffer.Length) + { + throw new FormatException("UTF8 deocde failed"); + } + //Store + _utf8RawData = buffer; + } + + protected override void Free() + { + Memory.InitializeBlock(_utf8RawData.AsSpan()); + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/RoutingExtensions.cs b/lib/VNLib.Plugins.Extensions.Loading/src/RoutingExtensions.cs new file mode 100644 index 0000000..9242522 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/RoutingExtensions.cs @@ -0,0 +1,161 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: RoutingExtensions.cs +* +* RoutingExtensions.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text.Json; +using System.Reflection; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +using VNLib.Plugins.Extensions.Loading.Events; + +namespace VNLib.Plugins.Extensions.Loading.Routing +{ + /// <summary> + /// Provides advanced QOL features to plugin loading + /// </summary> + public static class RoutingExtensions + { + private static readonly ConditionalWeakTable<IEndpoint, PluginBase?> _pluginRefs = new(); + + /// <summary> + /// Constructs and routes the specific endpoint type for the current plugin + /// </summary> + /// <typeparam name="T">The <see cref="IEndpoint"/> type</typeparam> + /// <param name="plugin"></param> + /// <param name="pluginConfigPathName">The path to the plugin sepcific configuration property</param> + /// <exception cref="TargetInvocationException"></exception> + public static T Route<T>(this PluginBase plugin, string? pluginConfigPathName) where T : IEndpoint + { + Type endpointType = typeof(T); + //If the config attribute is not set, then ignore the config variables + if (string.IsNullOrWhiteSpace(pluginConfigPathName)) + { + ConstructorInfo? constructor = endpointType.GetConstructor(new Type[] { typeof(PluginBase) }); + _ = constructor ?? throw new EntryPointNotFoundException($"No constructor found for {endpointType.Name}"); + //Create the new endpoint and pass the plugin instance + T endpoint = (T)constructor.Invoke(new object[] { plugin }); + //Register event handlers for the endpoint + ScheduleIntervals(plugin, endpoint, endpointType, null); + //Route the endpoint + plugin.Route(endpoint); + + //Store ref to plugin for endpoint + _pluginRefs.Add(endpoint, plugin); + + return endpoint; + } + else + { + ConstructorInfo? constructor = endpointType.GetConstructor(new Type[] { typeof(PluginBase), typeof(IReadOnlyDictionary<string, JsonElement>) }); + //Make sure the constructor exists + _ = constructor ?? throw new EntryPointNotFoundException($"No constructor found for {endpointType.Name}"); + //Get config variables for the endpoint + IReadOnlyDictionary<string, JsonElement> conf = plugin.GetConfig(pluginConfigPathName); + //Create the new endpoint and pass the plugin instance along with the configuration object + T endpoint = (T)constructor.Invoke(new object[] { plugin, conf }); + //Register event handlers for the endpoint + ScheduleIntervals(plugin, endpoint, endpointType, conf); + //Route the endpoint + plugin.Route(endpoint); + + //Store ref to plugin for endpoint + _pluginRefs.Add(endpoint, plugin); + + return endpoint; + } + } + + /// <summary> + /// Constructs and routes the specific endpoint type for the current plugin + /// </summary> + /// <typeparam name="T">The <see cref="IEndpoint"/> type</typeparam> + /// <param name="plugin"></param> + /// <exception cref="TargetInvocationException"></exception> + public static T Route<T>(this PluginBase plugin) where T : IEndpoint + { + Type endpointType = typeof(T); + //Get config name attribute + ConfigurationNameAttribute? configAttr = endpointType.GetCustomAttribute<ConfigurationNameAttribute>(); + //Route using attribute + return plugin.Route<T>(configAttr?.ConfigVarName); + } + + /// <summary> + /// Gets the plugin that loaded the current endpoint + /// </summary> + /// <param name="ep"></param> + /// <returns>The plugin that loaded the current endpoint</returns> + /// <exception cref="InvalidOperationException"></exception> + public static PluginBase GetPlugin(this IEndpoint ep) + { + _ = _pluginRefs.TryGetValue(ep, out PluginBase? pBase); + return pBase ?? throw new InvalidOperationException("Endpoint was not dynamically routed"); + } + + private static void ScheduleIntervals<T>(PluginBase plugin, T endpointInstance, Type epType, IReadOnlyDictionary<string, JsonElement>? endpointLocalConfig) where T : IEndpoint + { + //Get all methods that have the configureable async interval attribute specified + IEnumerable<Tuple<ConfigurableAsyncIntervalAttribute, AsyncSchedulableCallback>> confIntervals = epType.GetMethods() + .Where(m => m.GetCustomAttribute<ConfigurableAsyncIntervalAttribute>() != null) + .Select(m => new Tuple<ConfigurableAsyncIntervalAttribute, AsyncSchedulableCallback> + (m.GetCustomAttribute<ConfigurableAsyncIntervalAttribute>()!, m.CreateDelegate<AsyncSchedulableCallback>(endpointInstance))); + + //If the endpoint has a local config, then use it to find the interval + if (endpointLocalConfig != null) + { + + //Schedule event handlers on the current plugin + foreach (Tuple<ConfigurableAsyncIntervalAttribute, AsyncSchedulableCallback> interval in confIntervals) + { + int value = endpointLocalConfig[interval.Item1.IntervalPropertyName].GetInt32(); + //Get the timeout from its resolution variable + TimeSpan timeout = interval.Item1.Resolution switch + { + IntervalResultionType.Seconds => TimeSpan.FromSeconds(value), + IntervalResultionType.Minutes => TimeSpan.FromMinutes(value), + IntervalResultionType.Hours => TimeSpan.FromHours(value), + _ => TimeSpan.FromMilliseconds(value), + }; + //Schedule + plugin.ScheduleInterval(interval.Item2, timeout); + } + } + + //Get all methods that have the async interval attribute specified + IEnumerable<Tuple<AsyncIntervalAttribute, AsyncSchedulableCallback>> intervals = epType.GetMethods() + .Where(m => m.GetCustomAttribute<AsyncIntervalAttribute>() != null) + .Select(m => new Tuple<AsyncIntervalAttribute, AsyncSchedulableCallback>( + m.GetCustomAttribute<AsyncIntervalAttribute>()!, m.CreateDelegate<AsyncSchedulableCallback>(endpointInstance)) + ); + + //Schedule event handlers on the current plugin + foreach (Tuple<AsyncIntervalAttribute, AsyncSchedulableCallback> interval in intervals) + { + plugin.ScheduleInterval(interval.Item2, interval.Item1.Interval); + } + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/S3Config.cs b/lib/VNLib.Plugins.Extensions.Loading/src/S3Config.cs new file mode 100644 index 0000000..76c2bc2 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/S3Config.cs @@ -0,0 +1,35 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: S3Config.cs +* +* S3Config.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +namespace VNLib.Plugins.Extensions.Loading +{ + public sealed class S3Config + { + public string? ServerAddress { get; init; } + public string? ClientId { get; init; } + public string? BaseBucket { get; init; } + public bool? UseSsl { get; init; } + public string? Region { get; init; } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs b/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs new file mode 100644 index 0000000..15323f3 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/SecretResult.cs @@ -0,0 +1,61 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: SecretResult.cs +* +* SecretResult.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +using VNLib.Utils; +using VNLib.Utils.Extensions; +using VNLib.Utils.Memory; + +namespace VNLib.Plugins.Extensions.Loading +{ + /// <summary> + /// The result of a secret fetch operation + /// </summary> + public sealed class SecretResult : VnDisposeable + { + private readonly char[] _secretChars; + + /// <summary> + /// The protected raw result value + /// </summary> + public ReadOnlySpan<char> Result => _secretChars; + + + internal SecretResult(ReadOnlySpan<char> value) => _secretChars = value.ToArray(); + + ///<inheritdoc/> + protected override void Free() + { + Memory.InitializeBlock(_secretChars.AsSpan()); + } + + internal static SecretResult ToSecret(string? result) + { + SecretResult res = new(result.AsSpan()); + Memory.UnsafeZeroMemory<char>(result); + return res; + } + } +} diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/UserLoading.cs b/lib/VNLib.Plugins.Extensions.Loading/src/UserLoading.cs new file mode 100644 index 0000000..da090ec --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/UserLoading.cs @@ -0,0 +1,93 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: UserLoading.cs +* +* UserLoading.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Collections.Generic; + +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Plugins.Essentials.Users; + +namespace VNLib.Plugins.Extensions.Loading.Users +{ + /// <summary> + /// Contains extension methods for plugins to load the "users" system + /// </summary> + public static class UserLoading + { + public const string USER_CUSTOM_ASSEMBLY = "user_custom_asm"; + public const string DEFAULT_USER_ASM = "VNLib.Plugins.Essentials.Users.dll"; + public const string ONLOAD_METHOD_NAME = "OnPluginLoading"; + + + /// <summary> + /// Gets or loads the plugin's ambient <see cref="IUserManager"/>, with the specified user-table name, + /// or the default table name + /// </summary> + /// <param name="plugin"></param> + /// <returns>The ambient <see cref="IUserManager"/> for the current plugin</returns> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static IUserManager GetUserManager(this PluginBase plugin) + { + plugin.ThrowIfUnloaded(); + //Get stored or load + return LoadingExtensions.GetOrCreateSingleton(plugin, LoadUsers); + } + + private static IUserManager LoadUsers(PluginBase pbase) + { + //Try to load a custom user assembly for exporting IUserManager + string? customAsm = pbase.PluginConfig.GetPropString(USER_CUSTOM_ASSEMBLY); + //See if host config defined the path + customAsm ??= pbase.HostConfig.GetPropString(USER_CUSTOM_ASSEMBLY); + //Finally default + customAsm ??= DEFAULT_USER_ASM; + + //Try to load a custom assembly + AssemblyLoader<IUserManager> loader = pbase.LoadAssembly<IUserManager>(customAsm); + try + { + //Try to get the onload method + Action<object>? onLoadMethod = loader.TryGetMethod<Action<object>>(ONLOAD_METHOD_NAME); + + //Call the onplugin load method + onLoadMethod?.Invoke(pbase); + + if (pbase.IsDebug()) + { + pbase.Log.Verbose("Loading user manager from assembly {name}", loader.Resource.GetType().AssemblyQualifiedName); + } + + //Return the loaded instance (may raise exception) + return loader.Resource; + } + catch + { + loader.Dispose(); + throw; + } + } + } +}
\ No newline at end of file diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.csproj b/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.csproj new file mode 100644 index 0000000..15bb15e --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.csproj @@ -0,0 +1,39 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net6.0</TargetFramework> + <Copyright>Copyright © 2022 Vaughn Nugent</Copyright> + <Authors>Vaughn Nugent</Authors> + <Version>1.0.1.1</Version> + + <GenerateDocumentationFile>True</GenerateDocumentationFile> + <Nullable>enable</Nullable> + <SignAssembly>True</SignAssembly> + <AssemblyOriginatorKeyFile>\\vaughnnugent.com\Internal\Folder Redirection\vman\Documents\Programming\Software\StrongNameingKey.snk</AssemblyOriginatorKeyFile> + </PropertyGroup> + + <PropertyGroup> + <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> + <PackageProjectUrl>https://www.vaughnnugent.com/resources</PackageProjectUrl> + <AnalysisLevel>latest-all</AnalysisLevel> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="ErrorProne.NET.Structs" Version="0.1.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="McMaster.NETCore.Plugins" Version="1.4.0" /> + <PackageReference Include="VaultSharp" Version="1.7.1" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\VNLib\Essentials\src\VNLib.Plugins.Essentials.csproj" /> + <ProjectReference Include="..\..\PluginBase\VNLib.Plugins.PluginBase.csproj" /> + </ItemGroup> + +</Project> diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.xml b/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.xml new file mode 100644 index 0000000..963f506 --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/VNLib.Plugins.Extensions.Loading.xml @@ -0,0 +1,260 @@ +<?xml version="1.0"?> +<!-- +Copyright (c) 2022 Vaughn Nugent +--> +<doc> + <assembly> + <name>VNLib.Plugins.Extensions.Loading</name> + </assembly> + <members> + <member name="T:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute"> + <summary> + Specifies a configuration variable name in the plugin's configuration + containing data specific to the type + </summary> + </member> + <member name="F:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute.ConfigVarName"> + <summary> + + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute.#ctor(System.String)"> + <summary> + Initializes a new <see cref="T:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute"/> + </summary> + <param name="configVarName">The name of the configuration variable for the class</param> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions"> + <summary> + Contains extensions for plugin configuration specifc extensions + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.GetConfigForType``1(VNLib.Plugins.PluginBase)"> + <summary> + Retrieves a top level configuration dictionary of elements for the specified type. + The type must contain a <see cref="T:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute"/> + </summary> + <typeparam name="T">The type to get the configuration of</typeparam> + <param name="plugin"></param> + <returns>A <see cref="T:System.Collections.Generic.Dictionary`2"/> of top level configuration elements for the type</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.GetConfig(VNLib.Plugins.PluginBase,System.String)"> + <summary> + Retrieves a top level configuration dictionary of elements with the specified property name, + from the plugin config first, or falls back to the host config file + </summary> + <param name="plugin"></param> + <param name="propName">The config property name to retrieve</param> + <returns>A <see cref="T:System.Collections.Generic.Dictionary`2"/> of top level configuration elements for the type</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.TryGetConfig(VNLib.Plugins.PluginBase,System.String)"> + <summary> + Retrieves a top level configuration dictionary of elements with the specified property name, + from the plugin config first, or falls back to the host config file + </summary> + <param name="plugin"></param> + <param name="propName">The config property name to retrieve</param> + <returns>A <see cref="T:System.Collections.Generic.Dictionary`2"/> of top level configuration elements for the type</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.GetConfigForType(VNLib.Plugins.PluginBase,System.Type)"> + <summary> + Retrieves a top level configuration dictionary of elements for the specified type. + The type must contain a <see cref="T:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationNameAttribute"/> + </summary> + <param name="plugin"></param> + <param name="type">The type to get configuration data for</param> + <returns>A <see cref="T:System.Collections.Generic.Dictionary`2"/> of top level configuration elements for the type</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.GetConfig(VNLib.Plugins.PluginBase,System.Object)"> + <summary> + Shortcut extension for <see cref="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.GetConfigForType``1(VNLib.Plugins.PluginBase)"/> to get + config of current class + </summary> + <param name="obj">The object that a configuration can be retrieved for</param> + <param name="plugin">The plugin containing configuration variables</param> + <returns>A <see cref="T:System.Collections.Generic.Dictionary`2"/> of top level configuration elements for the type</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Configuration.ConfigurationExtensions.HasConfigForType``1(VNLib.Plugins.PluginBase)"> + <summary> + Determines if the current plugin configuration contains the require properties to initialize + the type + </summary> + <typeparam name="T"></typeparam> + <param name="plugin"></param> + <returns>True if the plugin config contains the require configuration property</returns> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Events.AsyncIntervalAttribute"> + <summary> + When added to a method schedules it as a callback on a specified interval when + the plugin is loaded, and stops when unloaded + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Events.AsyncIntervalAttribute.#ctor(System.Int32)"> + <summary> + Intializes the <see cref="T:VNLib.Plugins.Extensions.Loading.Events.AsyncIntervalAttribute"/> with the specified timeout in milliseconds + </summary> + <param name="milliseconds">The interval in milliseconds</param> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType"> + <summary> + The configurable event interval resulution type + </summary> + </member> + <member name="F:VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType.Milliseconds"> + <summary> + Specifies event interval resolution in milliseconds + </summary> + </member> + <member name="F:VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType.Seconds"> + <summary> + Specifies event interval resolution in seconds + </summary> + </member> + <member name="F:VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType.Minutes"> + <summary> + Specifies event interval resolution in minutes + </summary> + </member> + <member name="F:VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType.Hours"> + <summary> + Specifies event interval resolution in hours + </summary> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Events.ConfigurableAsyncIntervalAttribute"> + <summary> + When added to a method schedules it as a callback on a specified interval when + the plugin is loaded, and stops when unloaded + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Events.ConfigurableAsyncIntervalAttribute.#ctor(System.String,VNLib.Plugins.Extensions.Loading.Events.IntervalResultionType)"> + <summary> + Initializes a <see cref="T:VNLib.Plugins.Extensions.Loading.Events.ConfigurableAsyncIntervalAttribute"/> with the specified + interval property name + </summary> + <param name="configPropName">The configuration property name for the event interval</param> + <param name="resolution">The time resoltion for the event interval</param> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Events.EventHandle"> + <summary> + Represents a handle to a scheduled event interval that is managed by the plugin but may be cancled by disposing the instance + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Events.EventHandle.Pause"> + <summary> + Pauses the event timer until the <see cref="T:VNLib.Utils.OpenHandle"/> is released or disposed + then resumes to the inital interval period + </summary> + <returns>A <see cref="T:VNLib.Utils.OpenHandle"/> that restores the timer to its initial state when disposed</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Events.EventHandle.Free"> + <inheritdoc/> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Events.EventManagment"> + <summary> + Provides event schedueling extensions for plugins + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Events.EventManagment.ScheduleInterval``1(VNLib.Plugins.PluginBase,System.Func{``0,System.Threading.Tasks.Task},``0,System.TimeSpan)"> + <summary> + Schedules an asynchronous event interval for the current plugin, that is active until canceled or until the plugin unloads + </summary> + <typeparam name="TState">Stateful event argument</typeparam> + <param name="plugin"></param> + <param name="asyncCallback">An asyncrhonous callback method.</param> + <param name="state"></param> + <param name="interval">The event interval</param> + <returns>An <see cref="T:VNLib.Plugins.Extensions.Loading.Events.EventHandle"/> that can manage the interval state</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + <remarks>If exceptions are raised during callback execution, they are written to the plugin's default log provider</remarks> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.LoadingExtensions"> + <summary> + Provides common loading (and unloading when required) extensions for plugins + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.LoadingExtensions.GetPasswords(VNLib.Plugins.PluginBase)"> + <summary> + Gets the plugins ambient <see cref="T:VNLib.Plugins.Essentials.Accounts.PasswordHashing"/> if loaded, or loads it if required. This class will + be unloaded when the plugin us unloaded. + </summary> + <param name="plugin"></param> + <returns>The ambient <see cref="T:VNLib.Plugins.Essentials.Accounts.PasswordHashing"/></returns> + <exception cref="T:System.OverflowException"></exception> + <exception cref="T:System.Collections.Generic.KeyNotFoundException"></exception> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.LoadingExtensions.GetUserManager(VNLib.Plugins.PluginBase)"> + <summary> + Gets or loads the plugin's ambient <see cref="T:VNLib.Plugins.Essentials.Users.UserManager"/>, with the specified user-table name, + or the default table name + </summary> + <param name="plugin"></param> + <returns>The ambient <see cref="T:VNLib.Plugins.Essentials.Users.UserManager"/> for the current plugin</returns> + <exception cref="T:System.Collections.Generic.KeyNotFoundException"></exception> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.LoadingExtensions.IsDebug(VNLib.Plugins.PluginBase)"> + <summary> + Determintes if the current plugin config has a debug propety set + </summary> + <param name="plugin"></param> + <returns>True if debug mode is enabled, false otherwise</returns> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.LoadingExtensions.ThrowIfUnloaded(VNLib.Plugins.PluginBase)"> + <summary> + Internal exception helper to raise <see cref="T:System.ObjectDisposedException"/> if the plugin has been unlaoded + </summary> + <param name="plugin"></param> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Routing.RoutingExtensions.Route``1(VNLib.Plugins.PluginBase,System.String)"> + <summary> + Constructs and routes the specific endpoint type for the current plugin + </summary> + <typeparam name="T">The <see cref="T:VNLib.Plugins.IEndpoint"/> type</typeparam> + <param name="plugin"></param> + <param name="pluginConfigPathName">The path to the plugin sepcific configuration property</param> + <exception cref="T:System.Reflection.TargetInvocationException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Routing.RoutingExtensions.Route``1(VNLib.Plugins.PluginBase)"> + <summary> + Constructs and routes the specific endpoint type for the current plugin + </summary> + <typeparam name="T">The <see cref="T:VNLib.Plugins.IEndpoint"/> type</typeparam> + <param name="plugin"></param> + <exception cref="T:System.Reflection.TargetInvocationException"></exception> + </member> + <member name="T:VNLib.Plugins.Extensions.Loading.Sql.SqlDbConnectionLoader"> + <summary> + Provides common basic SQL loading extensions for plugins + </summary> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Sql.SqlDbConnectionLoader.GetConnectionFactory(VNLib.Plugins.PluginBase)"> + <summary> + Gets (or loads) the ambient sql connection factory for the current plugin + </summary> + <param name="plugin"></param> + <returns>The ambient <see cref="T:System.Data.Common.DbConnection"/> factory</returns> + <exception cref="T:System.Collections.Generic.KeyNotFoundException"></exception> + <exception cref="T:System.ObjectDisposedException"></exception> + </member> + <member name="M:VNLib.Plugins.Extensions.Loading.Sql.SqlDbConnectionLoader.GetContextOptions(VNLib.Plugins.PluginBase)"> + <summary> + Gets (or loads) the ambient <see cref="T:Microsoft.EntityFrameworkCore.DbContextOptions"/> configured from + the ambient sql factory + </summary> + <param name="plugin"></param> + <returns>The ambient <see cref="T:Microsoft.EntityFrameworkCore.DbContextOptions"/> for the current plugin</returns> + <exception cref="T:System.Collections.Generic.KeyNotFoundException"></exception> + <exception cref="T:System.ObjectDisposedException"></exception> + <remarks>If plugin is in debug mode, writes log data to the default log</remarks> + </member> + </members> +</doc> diff --git a/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs b/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs new file mode 100644 index 0000000..898d64c --- /dev/null +++ b/lib/VNLib.Plugins.Extensions.Loading/src/VaultSecrets.cs @@ -0,0 +1,473 @@ +/* +* Copyright (c) 2022 Vaughn Nugent +* +* Library: VNLib +* Package: VNLib.Plugins.Extensions.Loading +* File: VaultSecrets.cs +* +* VaultSecrets.cs is part of VNLib.Plugins.Extensions.Loading which is part of the larger +* VNLib collection of libraries and utilities. +* +* VNLib.Plugins.Extensions.Loading is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* VNLib.Plugins.Extensions.Loading is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with VNLib.Plugins.Extensions.Loading. If not, see http://www.gnu.org/licenses/. +*/ + +using System; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; + +using VaultSharp; +using VaultSharp.V1.Commons; +using VaultSharp.V1.AuthMethods; +using VaultSharp.V1.AuthMethods.Token; +using VaultSharp.V1.AuthMethods.AppRole; +using VaultSharp.V1.SecretsEngines.PKI; + +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Logging; +using VNLib.Utils.Extensions; +using VNLib.Hashing.IdentityUtility; +using System.Threading; + +namespace VNLib.Plugins.Extensions.Loading +{ + + /// <summary> + /// Adds loading extensions for secure/centralized configuration secrets + /// </summary> + public static class PluginSecretLoading + { + public const string VAULT_OBJECT_NAME = "hashicorp_vault"; + public const string SECRETS_CONFIG_KEY = "secrets"; + public const string VAULT_TOKEN_KEY = "token"; + public const string VAULT_ROLE_KEY = "role"; + public const string VAULT_SECRET_KEY = "secret"; + + public const string VAULT_URL_KEY = "url"; + + public const string VAULT_URL_SCHEME = "vault://"; + + + /// <summary> + /// <para> + /// Gets a secret from the "secrets" element. + /// </para> + /// <para> + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// </para> + /// </summary> + /// <param name="plugin"></param> + /// <param name="secretName">The name of the secret propery to get</param> + /// <returns>The element from the configuration file with the given name, or null if the configuration or property does not exist</returns> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static Task<SecretResult?> TryGetSecretAsync(this PluginBase plugin, string secretName) + { + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + if (rawSecret == null) + { + return Task.FromResult<SecretResult?>(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult<SecretResult?>(new(rawSecret.AsSpan())); + } + return GetSecretFromVaultAsync(plugin, rawSecret); + } + + /// <summary> + /// Gets a secret at the given vault url (in the form of "vault://[mount-name]/[secret-path]?secret=[secret_name]") + /// </summary> + /// <param name="plugin"></param> + /// <param name="vaultPath">The raw vault url to lookup</param> + /// <returns>The string of the object at the specified vault path</returns> + /// <exception cref="UriFormatException"></exception> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static Task<SecretResult?> GetSecretFromVaultAsync(this PluginBase plugin, ReadOnlySpan<char> vaultPath) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving secret {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan<char> paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan<char> path = paq.SliceBeforeParam('?'); + ReadOnlySpan<char> query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + //Get the secret + string secretTableKey = query.SliceAfterParam("secret=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task<SecretResult?> execute() + { + //Try load client + IVaultClient? client = plugin.GetVault(); + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + //run read async + Secret<SecretData> result = await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path:secret, mountPoint:mount); + //Read the secret + return SecretResult.ToSecret(result.Data.Data[secretTableKey].ToString()); + } + + return Task.Run(execute); + } + + /// <summary> + /// <para> + /// Gets a Certicate from the "secrets" element. + /// </para> + /// <para> + /// Secrets elements are merged from the host config and plugin local config 'secrets' element. + /// before searching. The plugin config takes precedence over the host config. + /// </para> + /// </summary> + /// <param name="plugin"></param> + /// <param name="secretName">The name of the secret propery to get</param> + /// <returns>The element from the configuration file with the given name, or null if the configuration or property does not exist</returns> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static Task<X509Certificate?> TryGetCertificateAsync(this PluginBase plugin, string secretName) + { + //Get the secret from the config file raw + string? rawSecret = TryGetSecretInternal(plugin, secretName); + if (rawSecret == null) + { + return Task.FromResult<X509Certificate?>(null); + } + + //Secret is a vault path, or return the raw value + if (!rawSecret.StartsWith(VAULT_URL_SCHEME, StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult<X509Certificate?>(new (rawSecret)); + } + return GetCertFromVaultAsync(plugin, rawSecret); + } + + public static Task<X509Certificate?> GetCertFromVaultAsync(this PluginBase plugin, ReadOnlySpan<char> vaultPath, CertificateCredentialsRequestOptions? options = null) + { + //print the path for debug + if (plugin.IsDebug()) + { + plugin.Log.Debug("Retrieving certificate {s} from vault", vaultPath.ToString()); + } + + //Slice off path + ReadOnlySpan<char> paq = vaultPath.SliceAfterParam(VAULT_URL_SCHEME); + ReadOnlySpan<char> path = paq.SliceBeforeParam('?'); + ReadOnlySpan<char> query = paq.SliceAfterParam('?'); + + if (paq.IsEmpty) + { + throw new UriFormatException("Vault secret location not valid/empty "); + } + + //Get the secret + string role = query.SliceAfterParam("role=").SliceBeforeParam('&').ToString(); + string vaultType = query.SliceBeforeParam("vault_type=").SliceBeforeParam('&').ToString(); + string commonName = query.SliceBeforeParam("cn=").SliceBeforeParam('&').ToString(); + + //get mount and path + int lastSep = path.IndexOf('/'); + string mount = path[..lastSep].ToString(); + string secret = path[(lastSep + 1)..].ToString(); + + async Task<X509Certificate?> execute() + { + //Try load client + IVaultClient? client = plugin.GetVault(); + + _ = client ?? throw new KeyNotFoundException("Vault client not found"); + + options ??= new() + { + CertificateFormat = CertificateFormat.pem, + PrivateKeyFormat = PrivateKeyFormat.pkcs8, + CommonName = commonName, + }; + + //run read async + Secret<CertificateCredentials> result = await client.V1.Secrets.PKI.GetCredentialsAsync(pkiRoleName:secret, certificateCredentialRequestOptions:options, pkiBackendMountPoint:mount); + //Read the secret + byte[] pemCertData = Encoding.UTF8.GetBytes(result.Data.CertificateContent); + + return new (pemCertData); + } + + return Task.Run(execute); + } + + /// <summary> + /// Gets the ambient vault client for the current plugin + /// if the configuration is loaded, null otherwise + /// </summary> + /// <param name="plugin"></param> + /// <returns>The ambient <see cref="IVaultClient"/> if loaded, null otherwise</returns> + /// <exception cref="KeyNotFoundException"></exception> + /// <exception cref="ObjectDisposedException"></exception> + public static IVaultClient? GetVault(this PluginBase plugin) => LoadingExtensions.GetOrCreateSingleton(plugin, TryGetVaultLoader); + + private static string? TryGetSecretInternal(PluginBase plugin, string secretName) + { + bool local = plugin.PluginConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement localEl); + bool host = plugin.HostConfig.TryGetProperty(SECRETS_CONFIG_KEY, out JsonElement hostEl); + + //total config + IReadOnlyDictionary<string, JsonElement>? conf; + + if (local && host) + { + //Load both config objects to dict + Dictionary<string, JsonElement> localConf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + Dictionary<string, JsonElement> hostConf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + + //merge the two configs + foreach(KeyValuePair<string, JsonElement> lc in localConf) + { + //Overwrite any host config keys, plugin conf takes priority + hostConf[lc.Key] = lc.Value; + } + //set the merged config + conf = hostConf; + } + else if(local) + { + //Store only local config + conf = localEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else if(host) + { + //store only host config + conf = hostEl.EnumerateObject().ToDictionary(x => x.Name, x => x.Value); + } + else + { + conf = null; + } + + //Get the value or default json element + return conf != null && conf.TryGetValue(secretName, out JsonElement el) ? el.GetString() : null; + } + + private static IVaultClient? TryGetVaultLoader(PluginBase pbase) + { + //Get vault config + IReadOnlyDictionary<string, JsonElement>? conf = pbase.TryGetConfig(VAULT_OBJECT_NAME); + + if (conf == null) + { + return null; + } + + //try get servre address creds from config + string? serverAddress = conf[VAULT_URL_KEY].GetString() ?? throw new KeyNotFoundException($"Failed to load the key {VAULT_URL_KEY} from object {VAULT_OBJECT_NAME}"); + + IAuthMethodInfo authMethod; + + //Get authentication method from config + if (conf.TryGetValue(VAULT_TOKEN_KEY, out JsonElement tokenEl)) + { + //Init token + authMethod = new TokenAuthMethodInfo(tokenEl.GetString()); + } + else if (conf.TryGetValue(VAULT_ROLE_KEY, out JsonElement roleEl) && conf.TryGetValue(VAULT_SECRET_KEY, out JsonElement secretEl)) + { + authMethod = new AppRoleAuthMethodInfo(roleEl.GetString(), secretEl.GetString()); + } + else + { + throw new KeyNotFoundException($"Failed to load the vault authentication method from {VAULT_OBJECT_NAME}"); + } + + //Settings + VaultClientSettings settings = new(serverAddress, authMethod); + + //create vault client + return new VaultClient(settings); + } + + /// <summary> + /// Gets the Secret value as a byte buffer + /// </summary> + /// <param name="secret"></param> + /// <returns>The base64 decoded secret as a byte[]</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="InternalBufferTooSmallException"></exception> + public static byte[] GetFromBase64(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + + //Temp buffer + using UnsafeMemoryHandle<byte> buffer = Memory.UnsafeAlloc<byte>(secret.Result.Length); + + //Get base64 + if(Convert.TryFromBase64Chars(secret.Result, buffer, out int count)) + { + //Copy to array + byte[] value = buffer.Span[..count].ToArray(); + //Clear block before returning + Memory.InitializeBlock<byte>(buffer); + + return value; + } + + throw new InternalBufferTooSmallException("internal buffer too small"); + } + + /// <summary> + /// Converts the secret recovery task to + /// </summary> + /// <param name="secret"></param> + /// <returns>A task whos result the base64 decoded secret as a byte[]</returns> + /// <exception cref="ArgumentNullException"></exception> + /// <exception cref="InternalBufferTooSmallException"></exception> + public static async Task<byte[]?> ToBase64Bytes(this Task<SecretResult?> secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + using SecretResult? sec = await secret.ConfigureAwait(false); + return sec?.GetFromBase64(); + } + + /// <summary> + /// Recovers a certificate from a PEM encoded secret + /// </summary> + /// <param name="secret"></param> + /// <returns>The <see cref="X509Certificate2"/> parsed from the PEM encoded data</returns> + /// <exception cref="ArgumentNullException"></exception> + public static X509Certificate2 GetCertificate(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + return X509Certificate2.CreateFromPem(secret.Result); + } + + /// <summary> + /// Gets the secret value as a secret result + /// </summary> + /// <param name="secret"></param> + /// <returns>The document parsed from the secret value</returns> + public static JsonDocument GetJsonDocument(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + //Alloc buffer, utf8 so 1 byte per char + using IMemoryHandle<byte> buffer = Memory.SafeAlloc<byte>(secret.Result.Length); + //Get utf8 bytes + int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); + //Reader and parse + Utf8JsonReader reader = new(buffer.Span[..count]); + return JsonDocument.ParseValue(ref reader); + } + + /// <summary> + /// Gets a SPKI encoded public key from a secret + /// </summary> + /// <param name="secret"></param> + /// <returns>The <see cref="PublicKey"/> parsed from the SPKI public key</returns> + /// <exception cref="ArgumentNullException"></exception> + public static PublicKey GetPublicKey(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + //Alloc buffer, base64 is larger than binary value so char len is large enough + using IMemoryHandle<byte> buffer = Memory.SafeAlloc<byte>(secret.Result.Length); + //Get base64 bytes + ERRNO count = VnEncoding.TryFromBase64Chars(secret.Result, buffer.Span); + //Parse the SPKI from base64 + return PublicKey.CreateFromSubjectPublicKeyInfo(buffer.Span[..(int)count], out _); + } + + /// <summary> + /// Gets the value of the <see cref="SecretResult"/> as a <see cref="PrivateKey"/> + /// container + /// </summary> + /// <param name="secret"></param> + /// <returns>The <see cref="PrivateKey"/> from the secret value</returns> + /// <exception cref="FormatException"></exception> + /// <exception cref="ArgumentNullException"></exception> + public static PrivateKey GetPrivateKey(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + return new PrivateKey(secret); + } + + /// <summary> + /// Gets a <see cref="ReadOnlyJsonWebKey"/> from a secret value + /// </summary> + /// <param name="secret"></param> + /// <returns>The <see cref="ReadOnlyJsonWebKey"/> from the result</returns> + /// <exception cref="JsonException"></exception> + /// <exception cref="ArgumentException"></exception> + /// <exception cref="ArgumentNullException"></exception> + public static ReadOnlyJsonWebKey GetJsonWebKey(this SecretResult secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + //Alloc buffer, utf8 so 1 byte per char + using IMemoryHandle<byte> buffer = Memory.SafeAlloc<byte>(secret.Result.Length); + //Get utf8 bytes + int count = Encoding.UTF8.GetBytes(secret.Result, buffer.Span); + return new ReadOnlyJsonWebKey(buffer.Span[..count]); + } + + /// <summary> + /// Gets a task that resolves a <see cref="ReadOnlyJsonWebKey"/> + /// from a <see cref="SecretResult"/> task + /// </summary> + /// <param name="secret"></param> + /// <returns>The <see cref="ReadOnlyJsonWebKey"/> from the secret, or null if the secret was not found</returns> + /// <exception cref="ArgumentNullException"></exception> + public static async Task<ReadOnlyJsonWebKey?> ToJsonWebKey(this Task<SecretResult?> secret) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + using SecretResult? sec = await secret.ConfigureAwait(false); + return sec?.GetJsonWebKey(); + } + + /// <summary> + /// Gets a task that resolves a <see cref="ReadOnlyJsonWebKey"/> + /// from a <see cref="SecretResult"/> task + /// </summary> + /// <param name="secret"></param> + /// <param name="required"> + /// A value that inidcates that a value is required from the result, + /// or a <see cref="KeyNotFoundException"/> is raised + /// </param> + /// <returns>The <see cref="ReadOnlyJsonWebKey"/> from the secret, or null if the secret was not found</returns> + /// <exception cref="ArgumentNullException"></exception> + public static async Task<ReadOnlyJsonWebKey> ToJsonWebKey(this Task<SecretResult?> secret, bool required) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + using SecretResult? sec = await secret.ConfigureAwait(false); + //If required is true and result is null, raise an exception + return required && sec == null ? throw new KeyNotFoundException("A required secret was missing") : (sec?.GetJsonWebKey()!); + } + } +} |