diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs index 65dcba8e..2e44fe9b 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginFactory.Remove.cs @@ -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"; + /// + /// Removes the configuration plugins an organization deployed once but does not reference anymore. + /// + /// + /// 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. + ///

+ /// 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. + ///
+ /// The IDs of the enterprise configurations which are currently referenced. public static void RemoveUnreferencedManagedConfigurationPlugins(ISet activeConfigurationIds) { - if (!IsInitialized) + if (!IsInitialized || !Directory.Exists(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT)) return; - var pluginIdsToRemove = new HashSet(); - - // 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) + /// + /// Checks whether a directory below the enterprise configuration directory belongs to a running + /// download instead of to an installed configuration. + /// + private static bool IsTransientDownloadDirectory(string directoryName) => + directoryName.Contains(".staging-", StringComparison.OrdinalIgnoreCase) || + directoryName.Contains(".backup-", StringComparison.OrdinalIgnoreCase); + + /// + /// Unloads every plugin stored in the given directory and deletes the directory afterwards. + /// + 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(); } \ No newline at end of file