2025-06-01 19:14:21 +00:00
|
|
|
namespace AIStudio.Tools.PluginSystem;
|
|
|
|
|
|
|
|
|
|
public static partial class PluginFactory
|
|
|
|
|
{
|
2026-02-19 19:43:47 +00:00
|
|
|
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
|
|
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
/// <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>
|
2026-02-19 19:43:47 +00:00
|
|
|
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
2025-06-01 19:14:21 +00:00
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
if (!IsInitialized || !Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
2025-06-01 19:14:21 +00:00
|
|
|
return;
|
|
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
foreach (var configurationDirectory in Directory.EnumerateDirectories(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT))
|
2026-02-19 19:43:47 +00:00
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
var directoryName = Path.GetFileName(configurationDirectory);
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
//
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
RemoveConfigurationDirectory(configurationDirectory, REASON_NO_LONGER_REFERENCED);
|
2026-02-19 19:43:47 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
/// <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)
|
2026-02-19 19:43:47 +00:00
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
LOG.LogWarning("Removing the enterprise configuration directory '{Directory}'. Reason: {Reason}.", configurationDirectory, reason);
|
2025-06-01 19:14:21 +00:00
|
|
|
|
|
|
|
|
//
|
2026-08-09 17:01:58 +00:00
|
|
|
// 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:
|
2025-06-01 19:14:21 +00:00
|
|
|
//
|
2026-08-09 17:01:58 +00:00
|
|
|
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin => IsPathInside(configurationDirectory, plugin.LocalPath)).ToList())
|
2025-06-01 19:14:21 +00:00
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
AVAILABLE_PLUGINS.Remove(plugin);
|
2026-02-19 19:43:47 +00:00
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
if (RUNNING_PLUGINS.FirstOrDefault(runningPlugin => runningPlugin.Id == plugin.Id) is { } runningPluginToRemove)
|
|
|
|
|
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
2026-02-19 19:43:47 +00:00
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
LOG.LogInformation("Unloaded the plugin '{PluginName}' ({PluginId}). Reason: {Reason}.", plugin.Name, plugin.Id, reason);
|
2025-06-01 19:14:21 +00:00
|
|
|
}
|
2026-02-19 19:43:47 +00:00
|
|
|
|
2026-08-09 17:01:58 +00:00
|
|
|
if (!Directory.Exists(configurationDirectory))
|
2026-02-19 19:43:47 +00:00
|
|
|
return;
|
2025-06-01 19:14:21 +00:00
|
|
|
|
2026-02-19 19:43:47 +00:00
|
|
|
try
|
|
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
Directory.Delete(configurationDirectory, true);
|
|
|
|
|
LOG.LogInformation($"Plugin directory '{configurationDirectory}' deleted successfully.");
|
2026-02-19 19:43:47 +00:00
|
|
|
}
|
2026-08-09 17:01:58 +00:00
|
|
|
catch (Exception e)
|
2026-02-19 19:43:47 +00:00
|
|
|
{
|
2026-08-09 17:01:58 +00:00
|
|
|
LOG.LogError(e, $"Failed to delete plugin directory '{configurationDirectory}'.");
|
2026-02-19 19:43:47 +00:00
|
|
|
}
|
2025-06-01 19:14:21 +00:00
|
|
|
}
|
|
|
|
|
}
|