using AIStudio.Settings; using AIStudio.Settings.DataModel; namespace AIStudio.Tools.PluginSystem; public static partial class PluginFactory { private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory)); private static SettingsManager SettingsManagerAccess => Program.SERVICE_PROVIDER.GetRequiredService(); private static string DATA_DIR = string.Empty; private static string PLUGINS_ROOT = string.Empty; private static string INTERNAL_PLUGINS_ROOT = string.Empty; /// /// The directory the config server downloads the configuration plugins of an organization into. /// /// /// This is not the home of configuration plugins in general: a local configuration plugin can /// live in any directory below the plugins root. Only the IT department of an organization /// deploys plugins here, each in a directory named after its configuration ID. /// private static string ENTERPRISE_CONFIGURATION_PLUGINS_ROOT = string.Empty; /// /// The directory administrators use to try out a configuration before their organization deploys it. /// /// /// 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. /// 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!; /// /// How many test configurations were removed while AI Studio was starting. /// /// /// 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. /// public static int RemovedTestConfigurationsAtStartup { get; private set; } public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE; public static bool IsInitialized { get; private set; } /// /// Gets the enterprise encryption instance for decrypting API keys in configuration plugins. /// public static EnterpriseEncryption? EnterpriseEncryption { get; private set; } /// /// Initializes the enterprise encryption service by reading the encryption secret /// from the effective enterprise source. /// /// The Rust service to use for reading the encryption secret. public static async Task InitializeEnterpriseEncryption(Services.RustService rustService) { var encryptionSecret = await rustService.EnterpriseEnvConfigEncryptionSecret(); InitializeEnterpriseEncryption(encryptionSecret); } /// /// Initializes the enterprise encryption service using a prefetched secret value. /// /// The base64-encoded enterprise encryption secret. public static void InitializeEnterpriseEncryption(string? encryptionSecret) { LOG.LogInformation("Initializing enterprise encryption service..."); var enterpriseEncryptionLogger = Program.LOGGER_FACTORY.CreateLogger(); EnterpriseEncryption = new EnterpriseEncryption(enterpriseEncryptionLogger, encryptionSecret); if (EnterpriseEncryption.IsAvailable) LOG.LogInformation("Enterprise encryption service is available."); else LOG.LogWarning("Enterprise encryption service is not available (no secret configured)."); } /// /// Set up the plugin factory. We will read the data directory from the settings manager. /// Afterward, we will create the plugins directory and the internal plugin directory. /// public static bool Setup() { if(IsInitialized) return false; LOG.LogInformation("Initializing plugin factory..."); DATA_DIR = SettingsManager.DataDirectory!; PLUGINS_ROOT = Path.Join(DATA_DIR, "plugins"); 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."); return true; } /// /// Checks whether a plugin directory belongs to the enterprise configuration area. /// /// /// Only the IT department of an organization deploys plugins there: the config server downloads /// them into a directory named after their configuration ID. We decide by path on purpose. The /// Lua field DEPLOYED_USING_CONFIG_SERVER is self-declared, so any plugin could claim to be /// deployed by an organization. /// /// The directory of the plugin. /// True when the directory is nested in the enterprise configuration directory. public static bool IsEnterpriseConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, pluginPath); /// /// Checks whether a plugin directory belongs to the test configuration area. /// /// The directory of the plugin. /// True when the directory is nested in the test configuration directory. public static bool IsEnterpriseTestConfigurationPath(string? pluginPath) => IsPathInside(ENTERPRISE_TEST_CONFIGURATION_PLUGINS_ROOT, pluginPath); /// /// Checks whether a plugin acts on behalf of an organization, either deployed by a configuration /// server or staged for a test. /// /// /// 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. /// /// The directory of the plugin. /// True when the directory belongs to the enterprise or the test configuration area. public static bool IsOrganizationConfigurationPath(string? pluginPath) => IsEnterpriseConfigurationPath(pluginPath) || IsEnterpriseTestConfigurationPath(pluginPath); /// /// 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. /// /// /// 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. /// private static int GetConfigurationAuthority(string? pluginPath) { if (IsEnterpriseTestConfigurationPath(pluginPath)) return 2; return IsEnterpriseConfigurationPath(pluginPath) ? 1 : 0; } /// /// Empties the test configuration directory. /// /// /// 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. /// 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}'."); } } /// /// Checks whether a plugin directory is stored below the plugins directory of AI Studio. /// /// /// Everything that removes or replaces plugin files checks this first, so a plugin directory /// which points somewhere else can never be touched. /// /// The directory of the plugin. /// True when the directory is nested in the plugins directory. public static bool IsInsidePluginsRoot(string? pluginPath) => IsPathInside(PLUGINS_ROOT, pluginPath); /// /// Checks whether a plugin directory is the plugins directory itself. /// /// /// 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. /// /// The directory of the plugin. /// True when the directory is the plugins directory. public static bool IsPluginsRoot(string? pluginPath) { if (string.IsNullOrWhiteSpace(pluginPath) || string.IsNullOrWhiteSpace(PLUGINS_ROOT)) return false; try { 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}' 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; } } /// /// Checks whether a configuration plugin was deployed by the IT department of an organization. /// /// /// A plugin which is deployed but could not be loaded still counts: it might be broken, e.g. due /// to invalid Lua code or an incomplete download, but it was not removed. Everything it manages /// stays under the control of the organization until the plugin is gone for good. /// /// The ID of the configuration plugin. /// True when the plugin belongs to an organization, false when it is local or unknown. public static bool IsEnterpriseConfigurationPlugin(Guid configPluginId) { if (configPluginId == Guid.Empty || !IsInitialized) return false; if (AVAILABLE_PLUGINS.Any(plugin => plugin.Id == configPluginId && plugin.Type is PluginType.CONFIGURATION && IsEnterpriseConfigurationPath(plugin.LocalPath))) return true; return Directory.Exists(Path.Join(ENTERPRISE_CONFIGURATION_PLUGINS_ROOT, configPluginId.ToString())); } /// /// Checks whether a configuration plugin speaks for an organization: either deployed by its IT /// department, or staged as a test configuration. /// /// /// 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. /// /// The ID of the configuration plugin. /// True when the plugin speaks for an organization, false when it is local or unknown. 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) { LOG.LogError("PluginFactory is not initialized."); return; } try { if (File.Exists(HOT_RELOAD_LOCK_FILE)) { LOG.LogWarning("Hot reload lock file already exists."); return; } await File.WriteAllTextAsync(HOT_RELOAD_LOCK_FILE, DateTime.UtcNow.ToString("o")); } catch (Exception e) { LOG.LogError(e, "An error occurred while trying to lock hot reloading."); } } private static void UnlockHotReload() { if (!IsInitialized) { LOG.LogError("PluginFactory is not initialized."); return; } try { if(File.Exists(HOT_RELOAD_LOCK_FILE)) File.Delete(HOT_RELOAD_LOCK_FILE); else LOG.LogWarning("Hot reload lock file does not exist. Nothing to unlock."); } catch (Exception e) { LOG.LogError(e, "An error occurred while trying to unlock hot reloading."); } } public static void Dispose() { if(!IsInitialized) return; HOT_RELOAD_WATCHER.Dispose(); } public static IReadOnlyList GetMandatoryInfos() { return RUNNING_PLUGINS .OfType() .SelectMany(plugin => plugin.MandatoryInfos) .ToList(); } public static IReadOnlyList GetIntroductions() { return RUNNING_PLUGINS .OfType() .SelectMany(plugin => plugin.Introductions) .OrderBy(introduction => introduction.Index) .ToList(); } }