mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-04 15:24:28 +00:00
Improved local plugin handling (share, import, delete) (#900)
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
This commit is contained in:
1 parent
0eb747b386
commit
6e143aafaa
47 files changed
+3108
-1480
No files matched your search
@@ -49,6 +49,17 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// refine it, e.g. per department.
|
||||
/// </remarks>
|
||||
public int Priority { get; } = ReadPriority(state);
|
||||
|
||||
/// <summary>
|
||||
/// How many settings this configuration plugin declares.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This counts the entries of the Lua SETTINGS table, without the <c>.AllowUserOverride</c>
|
||||
/// companions. We need it for the import preview: a dry run does not lock anything, so the
|
||||
/// number of settings the plugin would take over cannot be read from the managed configuration
|
||||
/// at that point.
|
||||
/// </remarks>
|
||||
public int DeclaredSettingsCount { get; private set; }
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@@ -148,6 +159,26 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the settings a configuration plugin declares, ignoring the <c>.AllowUserOverride</c>
|
||||
/// companion keys: those refine a setting instead of adding one.
|
||||
/// </summary>
|
||||
private static int CountDeclaredSettings(LuaTable settingsTable)
|
||||
{
|
||||
const string USER_OVERRIDE_SUFFIX = ".AllowUserOverride";
|
||||
|
||||
var count = 0;
|
||||
var previousKey = LuaValue.Nil;
|
||||
while (settingsTable.TryGetNext(previousKey, out var pair))
|
||||
{
|
||||
previousKey = pair.Key;
|
||||
if (pair.Key.TryRead<string>(out var settingName) && !settingName.EndsWith(USER_OVERRIDE_SUFFIX, StringComparison.Ordinal))
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
@@ -173,6 +204,8 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
message = TB("The SETTINGS table does not exist or is not a valid table.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.DeclaredSettingsCount = CountDeclaredSettings(settingsTable);
|
||||
|
||||
// Config: check for updates, and if so, how often?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateInterval, this.Id, settingsTable, dryRun);
|
||||
@@ -201,6 +234,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
// Config: allow the user to import plugin archives?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to import configuration plugin archives?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportConfigurationPlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
// Config: allow the user to share or export plugins?
|
||||
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToSharePlugins, this.Id, settingsTable, dryRun);
|
||||
|
||||
@@ -355,6 +391,28 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
if (dryRun)
|
||||
return;
|
||||
|
||||
//
|
||||
// Only a configuration which speaks for an organization may approve assistant plugins: one
|
||||
// deployed by a configuration server, or one staged in the test directory. An approval marks
|
||||
// a plugin as safe without any security audit, and the user interface states that the
|
||||
// organization approved it. No local configuration plugin may make that claim: it would
|
||||
// disable the security audit for arbitrary assistant plugins while telling the user that
|
||||
// their organization vouched for them.
|
||||
//
|
||||
// We decide by the plugin path. The self-declared DEPLOYED_USING_CONFIG_SERVER field would
|
||||
// not do, because any plugin can set it to true.
|
||||
//
|
||||
if (!PluginFactory.IsOrganizationConfigurationPath(this.PluginPath))
|
||||
{
|
||||
if (successful)
|
||||
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' at '{PluginPath}' declares enterprise approvals for assistant plugins, but your organization's IT did not deploy it. Ignoring these approvals: only configuration plugins from a configuration server or from the test directory may approve assistant plugins.", this.Id, this.PluginPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (PluginFactory.IsEnterpriseTestConfigurationPath(this.PluginPath))
|
||||
LOG.LogWarning("The test configuration plugin '{ConfigPluginId}' at '{PluginPath}' approves assistant plugins. These approvals are valid for this session only: AI Studio empties the test directory on every start.", this.Id, this.PluginPath);
|
||||
|
||||
switch (successful)
|
||||
{
|
||||
case true:
|
||||
|
||||
@@ -34,6 +34,41 @@ public sealed record PluginConfigurationObject
|
||||
/// </summary>
|
||||
public required PluginConfigurationObjectType Type { get; init; } = PluginConfigurationObjectType.NONE;
|
||||
|
||||
/// <summary>
|
||||
/// The name of the configuration object, e.g. the name of a provider.
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Where this configuration object sends data to: the host of a self-hosted provider or data
|
||||
/// source, or the name of the cloud provider. Empty for objects without a destination, such as
|
||||
/// chat templates or profiles.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We keep this next to the object metadata so the import preview can tell users where a
|
||||
/// configuration would send their prompts before its providers are stored.
|
||||
/// </remarks>
|
||||
public string Endpoint { get; private init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Determines the destination of a configuration object for the import preview.
|
||||
/// </summary>
|
||||
private static string DescribeEndpoint(IConfigurationObject configObject) => configObject switch
|
||||
{
|
||||
Settings.Provider { IsSelfHosted: true } provider => provider.Hostname,
|
||||
Settings.Provider provider => Provider.LLMProvidersExtensions.ToName(provider.UsedLLMProvider),
|
||||
|
||||
EmbeddingProvider { IsSelfHosted: true } embeddingProvider => embeddingProvider.Hostname,
|
||||
EmbeddingProvider embeddingProvider => Provider.LLMProvidersExtensions.ToName(embeddingProvider.UsedLLMProvider),
|
||||
|
||||
TranscriptionProvider { IsSelfHosted: true } transcriptionProvider => transcriptionProvider.Hostname,
|
||||
TranscriptionProvider transcriptionProvider => Provider.LLMProvidersExtensions.ToName(transcriptionProvider.UsedLLMProvider),
|
||||
|
||||
DataSourceERI_V1 dataSource => dataSource.Hostname,
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses Lua table entries into configuration objects of the specified type, populating the
|
||||
/// provided list with results.
|
||||
@@ -125,6 +160,8 @@ public sealed record PluginConfigurationObject
|
||||
ConfigPluginId = configPluginId,
|
||||
Id = Guid.Parse(configObject.Id),
|
||||
Type = configObjectType,
|
||||
Name = configObject.Name,
|
||||
Endpoint = DescribeEndpoint(configObject),
|
||||
});
|
||||
|
||||
if (dryRun)
|
||||
@@ -214,6 +251,8 @@ public sealed record PluginConfigurationObject
|
||||
ConfigPluginId = configPluginId,
|
||||
Id = Guid.Parse(configObject.Id),
|
||||
Type = PluginConfigurationObjectType.DATA_SOURCE,
|
||||
Name = configObject.Name,
|
||||
Endpoint = DescribeEndpoint(configObject),
|
||||
});
|
||||
|
||||
if (dryRun)
|
||||
@@ -273,10 +312,10 @@ public sealed record PluginConfigurationObject
|
||||
if (!existingObject.IsEnterpriseConfiguration || existingObject.EnterpriseConfigurationPluginId == configPluginId)
|
||||
return true;
|
||||
|
||||
if (!PluginFactory.IsEnterpriseConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
|
||||
if (!PluginFactory.IsOrganizationConfigurationPlugin(existingObject.EnterpriseConfigurationPluginId))
|
||||
return true;
|
||||
|
||||
if (PluginFactory.IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
if (PluginFactory.IsOrganizationConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
LOG.LogWarning("The configuration plugin '{ConfigPluginId}' tried to replace the object '{ConfigObjectName}' (id={ConfigObjectId}), which belongs to the configuration plugin '{OwningConfigPluginId}' of your organization. Ignoring the attempt: configurations deployed by your organization's IT take precedence.", configPluginId, existingObject.Name, existingObject.Id, existingObject.EnterpriseConfigurationPluginId);
|
||||
|
||||
@@ -119,13 +119,17 @@ public static partial class PluginFactory
|
||||
//
|
||||
if (AVAILABLE_PLUGINS.FirstOrDefault(candidate => candidate.Id == plugin.Id) is { } duplicatePlugin)
|
||||
{
|
||||
if (!IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseConfigurationPath(duplicatePlugin.LocalPath))
|
||||
if (GetConfigurationAuthority(pluginPath) <= GetConfigurationAuthority(duplicatePlugin.LocalPath))
|
||||
{
|
||||
LOG.LogWarning($"Ignoring the plugin '{pluginMainFile}': its ID ('{plugin.Id}') is already used by the plugin at '{duplicatePlugin.LocalPath}'. Plugin IDs must be unique. Please remove one of these plugins.");
|
||||
continue;
|
||||
}
|
||||
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
|
||||
if (IsEnterpriseTestConfigurationPath(pluginPath))
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the test configuration plugin at '{pluginPath}'. A test configuration takes precedence until AI Studio is restarted.");
|
||||
else
|
||||
LOG.LogWarning($"Ignoring the plugin at '{duplicatePlugin.LocalPath}': it uses the ID ('{plugin.Id}') of the enterprise configuration plugin at '{pluginPath}'. Plugins deployed by your organization's IT take precedence.");
|
||||
|
||||
AVAILABLE_PLUGINS.Remove(duplicatePlugin);
|
||||
}
|
||||
|
||||
@@ -199,6 +203,15 @@ public static partial class PluginFactory
|
||||
// one broken configuration plugin would wipe the entire organization configuration:
|
||||
//
|
||||
var deployedEnterpriseConfigPluginIds = GetDeployedEnterpriseConfigPluginIds();
|
||||
|
||||
//
|
||||
// Test configurations manage settings and objects like a deployed configuration, so those must
|
||||
// not be treated as left over while the test runs. They are only ever loaded, never merely
|
||||
// present: the test directory is emptied on every start.
|
||||
//
|
||||
foreach (var testConfigurationPlugin in AVAILABLE_PLUGINS.Where(plugin => plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath)))
|
||||
deployedEnterpriseConfigPluginIds.Add(testConfigurationPlugin.Id);
|
||||
|
||||
var unloadedEnterpriseConfigPluginIds = deployedEnterpriseConfigPluginIds.Where(x => AVAILABLE_PLUGINS.All(plugin => plugin.Id != x)).ToList();
|
||||
foreach (var unloadedEnterpriseConfigPluginId in unloadedEnterpriseConfigPluginIds)
|
||||
LOG.LogWarning($"The configuration plugin '{unloadedEnterpriseConfigPluginId}' is deployed, but was not loaded. Everything it manages stays unchanged, because the plugin was not removed. Please check the errors above and fix the plugin.");
|
||||
@@ -345,7 +358,10 @@ public static partial class PluginFactory
|
||||
if(type is PluginType.NONE)
|
||||
return new NoPlugin($"TYPE is not a valid plugin type. Valid types are: {CommonTools.GetAllEnumValues<PluginType>()}");
|
||||
|
||||
var isInternal = !string.IsNullOrWhiteSpace(pluginPath) && pluginPath.StartsWith(INTERNAL_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
// Whether a plugin is internal is decided by its path, never by the plugin itself. We use the
|
||||
// same nesting check as everywhere else, so that a directory like `.internal-old` next to the
|
||||
// internal plugins does not count as internal:
|
||||
var isInternal = IsPathInside(INTERNAL_PLUGINS_ROOT, pluginPath);
|
||||
switch (type)
|
||||
{
|
||||
case PluginType.LANGUAGE:
|
||||
|
||||
@@ -1,129 +1,90 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
|
||||
|
||||
/// <summary>
|
||||
/// Removes the configuration plugins an organization deployed once but does not reference anymore.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is how an organization withdraws a configuration: it removes the configuration ID from the
|
||||
/// devices, e.g. through a group policy. The next time AI Studio syncs, the local copy has to go.
|
||||
/// A device which was offline while the policy changed applies the withdrawal when it starts again.
|
||||
/// <br/><br/>
|
||||
/// What an organization deployed is decided by the plugin path alone. We must not ask the plugin
|
||||
/// itself: `DEPLOYED_USING_CONFIG_SERVER` is part of the plugin, so a configuration declaring
|
||||
/// `false` could never be withdrawn again once it was deployed, while it would keep every right of
|
||||
/// an organization configuration, including the approval of assistant plugins.
|
||||
/// </remarks>
|
||||
/// <param name="activeConfigurationIds">The IDs of the enterprise configurations which are currently referenced.</param>
|
||||
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
if (!IsInitialized || !Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
return;
|
||||
|
||||
var pluginIdsToRemove = new HashSet<Guid>();
|
||||
|
||||
// Case 1: Plugins are already loaded and metadata is available.
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin =>
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
plugin.IsManagedByConfigServer &&
|
||||
!activeConfigurationIds.Contains(plugin.Id)))
|
||||
pluginIdsToRemove.Add(plugin.Id);
|
||||
|
||||
// Case 2: Startup cleanup before the initial plugin load.
|
||||
// In this case, we inspect the .config directories directly.
|
||||
if (Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
foreach (var configurationDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
continue;
|
||||
var directoryName = Path.GetFileName(configurationDirectory);
|
||||
|
||||
if (activeConfigurationIds.Contains(pluginId))
|
||||
continue;
|
||||
// A download in flight stages and backs up next to the configuration directories. Those
|
||||
// directories belong to a running update, not to a withdrawn configuration:
|
||||
if (IsTransientDownloadDirectory(directoryName))
|
||||
continue;
|
||||
|
||||
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
|
||||
var isManagedByConfigServer = deployFlag ?? true;
|
||||
if (!deployFlag.HasValue)
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{ENTERPRISE_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
//
|
||||
// A configuration server downloads each configuration into a directory named after its
|
||||
// ID. Any other directory name cannot be referenced by an enterprise environment, so it
|
||||
// has no place here either:
|
||||
//
|
||||
if (Guid.TryParse(directoryName, out var configurationId) && activeConfigurationIds.Contains(configurationId))
|
||||
continue;
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pluginId in pluginIdsToRemove)
|
||||
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
|
||||
private static void RemovePluginAsync(Guid pluginId, string reason)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the available plugins list:
|
||||
//
|
||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (availablePluginToRemove != null)
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
else
|
||||
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the running plugins list:
|
||||
//
|
||||
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (runningPluginToRemove == null)
|
||||
LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
else
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
//
|
||||
// Delete the plugin directory:
|
||||
//
|
||||
DeleteConfigurationPluginDirectory(pluginId);
|
||||
|
||||
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
|
||||
}
|
||||
|
||||
private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginFile = Path.Join(pluginDirectory, "plugin.lua");
|
||||
if (!File.Exists(pluginFile))
|
||||
return null;
|
||||
|
||||
var pluginCode = File.ReadAllText(pluginFile);
|
||||
var match = DeployedByConfigServerRegex().Match(pluginCode);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return bool.TryParse(match.Groups[1].Value, out var deployFlag)
|
||||
? deployFlag
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'.");
|
||||
return null;
|
||||
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
/// <summary>
|
||||
/// Checks whether a directory below the enterprise configuration directory belongs to a running
|
||||
/// download instead of to an installed configuration.
|
||||
/// </summary>
|
||||
private static bool IsTransientDownloadDirectory(string directoryName) =>
|
||||
directoryName.Contains(".staging-", StringComparison.OrdinalIgnoreCase) ||
|
||||
directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Unloads every plugin stored in the given directory and deletes the directory afterwards.
|
||||
/// </summary>
|
||||
private static void RemoveConfigurationDirectory(string configurationDirectory, string reason)
|
||||
{
|
||||
var pluginDirectory = Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
LOG.LogWarning("Removing the enterprise configuration directory '{Directory}'. Reason: {Reason}.", configurationDirectory, reason);
|
||||
|
||||
//
|
||||
// We collect the plugins by path, not by the ID the directory is named after: a plugin may
|
||||
// declare an ID which differs from its directory name, and a single directory may even hold
|
||||
// several plugins:
|
||||
//
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
return;
|
||||
AVAILABLE_PLUGINS.Remove(plugin);
|
||||
|
||||
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(configurationDirectory))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
Directory.Delete(configurationDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{configurationDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
LOG.LogError(e, $"Failed to delete plugin directory '{configurationDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex DeployedByConfigServerRegex();
|
||||
}
|
||||
@@ -110,9 +110,10 @@ public static partial class PluginFactory
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The configuration plugins an organization deployed go first: they are the baseline for
|
||||
/// everything else. Local configuration plugins follow, so they can add to that baseline instead
|
||||
/// of replacing parts of it. All remaining plugin types write no settings at all, so their rank
|
||||
/// is irrelevant for the outcome.<br/><br/>
|
||||
/// everything else. A test configuration follows, so that an administrator sees their draft take
|
||||
/// effect over the deployed baseline. Local configuration plugins come last, so they can add to
|
||||
/// that baseline instead of replacing parts of it. All remaining plugin types write no settings at
|
||||
/// all, so their rank is irrelevant for the outcome.<br/><br/>
|
||||
/// The rank comes before the declared priority on purpose: a local configuration plugin must not
|
||||
/// be able to jump ahead of an organization by declaring a high priority.
|
||||
/// </remarks>
|
||||
@@ -121,9 +122,10 @@ public static partial class PluginFactory
|
||||
private static int GetStartupRank(IAvailablePlugin plugin) => plugin.Type switch
|
||||
{
|
||||
PluginType.CONFIGURATION when IsEnterpriseConfigurationPath(plugin.LocalPath) => 0,
|
||||
PluginType.CONFIGURATION => 1,
|
||||
PluginType.CONFIGURATION when IsEnterpriseTestConfigurationPath(plugin.LocalPath) => 1,
|
||||
PluginType.CONFIGURATION => 2,
|
||||
|
||||
_ => 2,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
private static void LogAssistantPluginStartupState()
|
||||
|
||||
@@ -21,9 +21,31 @@ public static partial class PluginFactory
|
||||
/// deploys plugins here, each in a directory named after its configuration ID.
|
||||
/// </remarks>
|
||||
private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The directory administrators use to try out a configuration before their organization deploys it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything stored here acts on behalf of the organization, so that a test behaves like the
|
||||
/// later rollout, including the approval of assistant plugins. In exchange, the directory is
|
||||
/// emptied on every start: a test configuration lives for one session only. It also never gets
|
||||
/// the protection of a deployed configuration, so users can remove or replace it through the user
|
||||
/// interface.
|
||||
/// </remarks>
|
||||
private static string ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
|
||||
/// <summary>
|
||||
/// How many test configurations were removed while AI Studio was starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The user interface reports this: an administrator who placed a test configuration and restarted
|
||||
/// AI Studio would otherwise face an empty directory without any explanation.
|
||||
/// </remarks>
|
||||
public static int RemovedTestConfigurationsAtStartup { get; private set; }
|
||||
|
||||
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static bool IsInitialized { get; private set; }
|
||||
@@ -75,10 +97,12 @@ public static partial class PluginFactory
|
||||
HOT_RELOAD_LOCK_FILE = Path.Join(PLUGINS_ROOT, ".lock");
|
||||
INTERNAL_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".internal");
|
||||
ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config");
|
||||
|
||||
ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT = Path.Join(PLUGINS_ROOT, ".config-tests");
|
||||
|
||||
if (!Directory.Exists(PLUGINS_ROOT))
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
|
||||
|
||||
ClearTestConfigurationPlugins();
|
||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||
IsInitialized = true;
|
||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||
@@ -96,20 +120,130 @@ public static partial class PluginFactory
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the enterprise configuration directory.</returns>
|
||||
private static bool IsEnterpriseConfigurationPath(string? pluginPath)
|
||||
public static bool IsEnterpriseConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory belongs to the test configuration area.
|
||||
/// </summary>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the test configuration directory.</returns>
|
||||
public static bool IsEnterpriseTestConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration
|
||||
/// server or staged for a test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use this wherever a configuration speaks for the organization, e.g. when it approves assistant
|
||||
/// plugins or claims a setting against a local configuration plugin. Do not use it where a
|
||||
/// deployed configuration is protected against the user, e.g. against deletion: an administrator
|
||||
/// must be able to get rid of their own test configuration.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory belongs to the enterprise or the test configuration area.</returns>
|
||||
public static bool IsOrganizationConfigurationPath(string? pluginPath) => IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseTestConfigurationPath(pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Ranks how much say a configuration plugin has, based on where it is stored. The higher rank
|
||||
/// wins when two configuration plugins claim the same plugin ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration outranks a deployed one on purpose: an administrator tries out the next
|
||||
/// version of a configuration under the ID it will have later. Local configuration plugins rank
|
||||
/// lowest, so nobody can push aside what an organization deployed.
|
||||
/// </remarks>
|
||||
private static int GetConfigurationAuthority(string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
||||
if (IsEnterpriseTestConfigurationPath(pluginPath))
|
||||
return 2;
|
||||
|
||||
return IsEnterpriseConfigurationPath(pluginPath) ? 1 : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties the test configuration directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration carries the rights of an organization configuration without anybody having
|
||||
/// deployed it. It must therefore never outlive the session it was placed in, and administrators
|
||||
/// get a predictable lifetime instead of a configuration which is swept away at some point.
|
||||
/// </remarks>
|
||||
private static void ClearTestConfigurationPlugins()
|
||||
{
|
||||
RemovedTestConfigurationsAtStartup = 0;
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var removedTestConfigurations = Directory.EnumerateDirectories(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT).Count();
|
||||
Directory.Delete(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, true);
|
||||
RemovedTestConfigurationsAtStartup = removedTestConfigurations;
|
||||
|
||||
if (removedTestConfigurations > 0)
|
||||
LOG.LogWarning($"Removed {removedTestConfigurations} test configuration(s) from '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'. Test configurations are valid for one session only.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, $"Failed to empty the test configuration directory '{ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory is stored below the plugins directory of AI Studio.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that removes or replaces plugin files checks this first, so a plugin directory
|
||||
/// which points somewhere else can never be touched.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is nested in the plugins directory.</returns>
|
||||
public static bool IsInsidePluginsRoot(string? pluginPath) => IsPathInside(PLUGINS_ROOT, pluginPath);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a plugin directory is the plugins directory itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A `plugin.lua` placed directly in the plugins directory makes that directory the plugin
|
||||
/// directory. Removing or replacing such a plugin means touching its directory, which would take
|
||||
/// every other plugin with it.
|
||||
/// </remarks>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <returns>True when the directory is the plugins directory.</returns>
|
||||
public static bool IsPluginsRoot(string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(PLUGINS_ROOT))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var configurationRoot = Path.GetFullPath(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return pluginDirectory.StartsWith(configurationRoot, StringComparison.OrdinalIgnoreCase);
|
||||
var root = Path.GetFullPath(PLUGINS_ROOT).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(root, pluginDirectory, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' belongs to the enterprise configuration directory. Treating it as a local plugin.");
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' is the plugins directory. Treating it as the plugins directory.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPathInside(string rootDirectory, string? pluginPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(rootDirectory))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var root = Path.GetFullPath(rootDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
var pluginDirectory = Path.GetFullPath(pluginPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
||||
return pluginDirectory.StartsWith(root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogWarning(e, $"Was not able to check whether the plugin directory '{pluginPath}' is nested in '{rootDirectory}'. Treating it as unrelated.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -135,6 +269,27 @@ public static partial class PluginFactory
|
||||
return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a configuration plugin speaks for an organization: either deployed by its IT
|
||||
/// department, or staged as a test configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A test configuration is only ever loaded, never merely present: it is emptied on every start,
|
||||
/// so there is no unloadable leftover to account for.
|
||||
/// </remarks>
|
||||
/// <param name="configPluginId">The ID of the configuration plugin.</param>
|
||||
/// <returns>True when the plugin speaks for an organization, false when it is local or unknown.</returns>
|
||||
public static bool IsOrganizationConfigurationPlugin(Guid configPluginId)
|
||||
{
|
||||
if (configPluginId == Guid.Empty || !IsInitialized)
|
||||
return false;
|
||||
|
||||
if (IsEnterpriseConfigurationPlugin(configPluginId))
|
||||
return true;
|
||||
|
||||
return AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseTestConfigurationPath(plugin.LocalPath));
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IsInitialized)
|
||||
|
||||
Reference in new issue
Block a user