mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-05-20 00:32:15 +00:00
Enhanced enterprise config support
This commit is contained in:
parent
6e33c361dc
commit
3f0b2edb80
@ -215,8 +215,30 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
||||
.Where(env => env != default)
|
||||
.ToList();
|
||||
var wereDeferredDownloadsSuccessful = true;
|
||||
foreach (var env in enterpriseEnvironments)
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
wereDeferredDownloadsSuccessful = false;
|
||||
this.Logger.LogWarning("Failed to download deferred enterprise configuration '{ConfigId}' during startup. Keeping managed plugins unchanged.", env.ConfigurationId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (EnterpriseEnvironmentService.HasValidEnterpriseSnapshot && wereDeferredDownloadsSuccessful)
|
||||
{
|
||||
var activeConfigIds = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS
|
||||
.Select(env => env.ConfigurationId)
|
||||
.ToHashSet();
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(activeConfigIds);
|
||||
}
|
||||
else if (!wereDeferredDownloadsSuccessful)
|
||||
{
|
||||
// Force a retry on the next enterprise sync cycle.
|
||||
EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS = [];
|
||||
}
|
||||
|
||||
// Initialize the enterprise encryption service for decrypting API keys:
|
||||
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);
|
||||
|
||||
@ -24,6 +24,9 @@ VERSION = "1.0.0"
|
||||
-- The type of the plugin:
|
||||
TYPE = "CONFIGURATION"
|
||||
|
||||
-- True when this plugin is deployed by an enterprise configuration server:
|
||||
DEPLOYED_USING_CONFIG_SERVER = false
|
||||
|
||||
-- The authors of the plugin:
|
||||
AUTHORS = {"<Company Name>"}
|
||||
|
||||
|
||||
@ -3,4 +3,5 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
public interface IAvailablePlugin : IPluginMetadata
|
||||
{
|
||||
public string LocalPath { get; }
|
||||
public bool IsManagedByConfigServer { get; }
|
||||
}
|
||||
@ -17,6 +17,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
|
||||
/// </summary>
|
||||
public IEnumerable<PluginConfigurationObject> ConfigObjects => this.configObjects;
|
||||
|
||||
/// <summary>
|
||||
/// True/false when explicitly configured in the plugin, otherwise null.
|
||||
/// </summary>
|
||||
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// </summary>
|
||||
private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId;
|
||||
|
||||
private static bool? ReadDeployedUsingConfigServer(LuaState state)
|
||||
{
|
||||
if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead<bool>(out var deployedUsingConfigServer))
|
||||
return deployedUsingConfigServer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
|
||||
@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static async Task<EntityTagHeaderValue?> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
public static async Task<(bool Success, EntityTagHeaderValue? ETag)> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl))
|
||||
return null;
|
||||
return (false, null);
|
||||
|
||||
try
|
||||
{
|
||||
@ -18,18 +18,24 @@ public static partial class PluginFactory
|
||||
using var http = new HttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
return response.Headers.ETag;
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
LOG.LogError($"Failed to determine the ETag for configuration plugin '{configPlugId}'. HTTP Status: {response.StatusCode}");
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
return (true, response.Headers.ETag);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin.");
|
||||
return null;
|
||||
return (false, null);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
{
|
||||
LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin.");
|
||||
return false;
|
||||
@ -40,36 +46,72 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
|
||||
var tempDownloadFile = Path.GetTempFileName();
|
||||
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||
string? backupDirectory = null;
|
||||
var wasSuccessful = false;
|
||||
try
|
||||
{
|
||||
await LockHotReloadAsync();
|
||||
using var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if(Directory.Exists(configDirectory))
|
||||
Directory.Delete(configDirectory, true);
|
||||
|
||||
Directory.CreateDirectory(configDirectory);
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
}
|
||||
else
|
||||
LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory);
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
|
||||
Directory.Move(configDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagedDirectory, configDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
|
||||
Directory.Delete(backupDirectory, true);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
wasSuccessful = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, configDirectory);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
LOG.LogError(restoreException, "Failed to restore the previous configuration plugin after a failed update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(stagedDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(stagedDirectory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "Failed to delete the staged configuration plugin directory.");
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(tempDownloadFile))
|
||||
{
|
||||
try
|
||||
@ -85,6 +127,6 @@ public static partial class PluginFactory
|
||||
UnlockHotReload();
|
||||
}
|
||||
|
||||
return true;
|
||||
return wasSuccessful;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void SetUpHotReloading()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@ -10,7 +10,7 @@ public static partial class PluginFactory
|
||||
{
|
||||
public static async Task EnsureInternalPlugins()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@ -30,7 +30,7 @@ public static partial class PluginFactory
|
||||
/// </remarks>
|
||||
public static async Task LoadAll(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
@ -104,16 +104,33 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
|
||||
|
||||
var isConfigurationPluginInConfigDirectory =
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var isManagedByConfigServer = false;
|
||||
if (plugin is PluginConfiguration configPlugin)
|
||||
{
|
||||
if (configPlugin.DeployedUsingConfigServer.HasValue)
|
||||
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
|
||||
|
||||
else if (isConfigurationPluginInConfigDirectory)
|
||||
{
|
||||
isManagedByConfigServer = true;
|
||||
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
|
||||
// (the directory name under which the plugin was downloaded):
|
||||
if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase))
|
||||
if (isConfigurationPluginInConfigDirectory)
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginPath);
|
||||
if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id)
|
||||
LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID.");
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath));
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@ -1,54 +1,127 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static void RemovePluginAsync(Guid pluginId)
|
||||
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning($"Try to remove plugin with ID: {pluginId}");
|
||||
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(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
continue;
|
||||
|
||||
if (activeConfigurationIds.Contains(pluginId))
|
||||
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 '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pluginId in pluginIdsToRemove)
|
||||
RemovePluginAsync(pluginId);
|
||||
}
|
||||
|
||||
private static void RemovePluginAsync(Guid pluginId)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning("Try to remove plugin with ID: '{PluginId}'.", pluginId);
|
||||
|
||||
//
|
||||
// Remove the plugin from the available plugins list:
|
||||
//
|
||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (availablePluginToRemove == null)
|
||||
{
|
||||
LOG.LogWarning($"No plugin found with ID: {pluginId}");
|
||||
return;
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
if (availablePluginToRemove != null)
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
else
|
||||
LOG.LogWarning("No available plugin found with ID: '{PluginId}'.", pluginId);
|
||||
|
||||
//
|
||||
// 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}");
|
||||
LOG.LogWarning("No running plugin found with ID: '{PluginId}'.", pluginId);
|
||||
else
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
//
|
||||
// Delete the plugin directory:
|
||||
//
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString());
|
||||
if (Directory.Exists(pluginDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
DeleteConfigurationPluginDirectory(pluginId);
|
||||
|
||||
LOG.LogInformation($"Plugin with ID: {pluginId} removed successfully.");
|
||||
LOG.LogInformation("Plugin with ID='{PluginId}' removed successfully.", pluginId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
{
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex DeployedByConfigServerRegex();
|
||||
}
|
||||
@ -34,7 +34,7 @@ public static partial class PluginFactory
|
||||
|
||||
if (startedBasePlugin is PluginLanguage languagePlugin)
|
||||
{
|
||||
BASE_LANGUAGE_PLUGIN = languagePlugin;
|
||||
BaseLanguage = languagePlugin;
|
||||
RUNNING_PLUGINS.Add(languagePlugin);
|
||||
LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'");
|
||||
}
|
||||
@ -44,7 +44,7 @@ public static partial class PluginFactory
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'.");
|
||||
BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
BaseLanguage = NoPluginLanguage.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,8 +106,8 @@ public static partial class PluginFactory
|
||||
//
|
||||
// When this is a language plugin, we need to set the base language plugin.
|
||||
//
|
||||
if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
|
||||
if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BaseLanguage);
|
||||
|
||||
if(plugin is PluginConfiguration configPlugin)
|
||||
await configPlugin.InitializeAsync(false);
|
||||
|
||||
@ -6,17 +6,17 @@ public static partial class PluginFactory
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
private static string DATA_DIR = string.Empty;
|
||||
private static string PLUGINS_ROOT = string.Empty;
|
||||
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
||||
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN;
|
||||
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static bool IsInitialized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
|
||||
@ -47,7 +47,7 @@ public static partial class PluginFactory
|
||||
/// </summary>
|
||||
public static bool Setup()
|
||||
{
|
||||
if(IS_INITIALIZED)
|
||||
if(IsInitialized)
|
||||
return false;
|
||||
|
||||
LOG.LogInformation("Initializing plugin factory...");
|
||||
@ -61,14 +61,14 @@ public static partial class PluginFactory
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
|
||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||
IS_INITIALIZED = true;
|
||||
IsInitialized = true;
|
||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@ -92,7 +92,7 @@ public static partial class PluginFactory
|
||||
|
||||
private static void UnlockHotReload()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@ -113,7 +113,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void Dispose()
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
return;
|
||||
|
||||
HOT_RELOAD_WATCHER.Dispose();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false) : IAvailablePlugin
|
||||
{
|
||||
#region Implementation of IPluginMetadata
|
||||
|
||||
@ -51,6 +51,8 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail
|
||||
#region Implementation of IAvailablePlugin
|
||||
|
||||
public string LocalPath { get; } = localPath;
|
||||
|
||||
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ namespace AIStudio.Tools.Services;
|
||||
public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentService> logger, RustService rustService) : BackgroundService
|
||||
{
|
||||
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
|
||||
|
||||
public static bool HasValidEnterpriseSnapshot { get; private set; }
|
||||
|
||||
#if DEBUG
|
||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
|
||||
@ -33,34 +35,10 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Start updating of the enterprise environment.");
|
||||
HasValidEnterpriseSnapshot = false;
|
||||
|
||||
//
|
||||
// Step 1: Handle deletions first.
|
||||
//
|
||||
List<Guid> deleteConfigIds;
|
||||
try
|
||||
{
|
||||
deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var deleteId in deleteConfigIds)
|
||||
{
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId);
|
||||
if (isPluginInUse)
|
||||
{
|
||||
logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId);
|
||||
PluginFactory.RemovePluginAsync(deleteId);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Step 2: Fetch all active configurations.
|
||||
// Step 1: Fetch all active configurations.
|
||||
//
|
||||
List<EnterpriseEnvironment> fetchedConfigs;
|
||||
try
|
||||
@ -75,7 +53,9 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
}
|
||||
|
||||
//
|
||||
// Step 3: Determine ETags and build the next environment list.
|
||||
// Step 2: Determine ETags and build the next environment list.
|
||||
// IMPORTANT: if we cannot read the ETag for any active configuration,
|
||||
// do not mutate the plugin state and keep everything as-is.
|
||||
//
|
||||
var nextEnvironments = new List<EnterpriseEnvironment>();
|
||||
foreach (var config in fetchedConfigs)
|
||||
@ -86,48 +66,24 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
continue;
|
||||
}
|
||||
|
||||
var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||
nextEnvironments.Add(config with { ETag = etag });
|
||||
}
|
||||
|
||||
if (nextEnvironments.Count == 0)
|
||||
{
|
||||
if (CURRENT_ENVIRONMENTS.Count > 0)
|
||||
var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||
if (!etagResponse.Success)
|
||||
{
|
||||
logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs.");
|
||||
|
||||
// Remove plugins for configs that were previously active:
|
||||
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
|
||||
{
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
|
||||
if (isPluginInUse)
|
||||
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
|
||||
}
|
||||
logger.LogWarning("Failed to read enterprise config metadata for '{ConfigId}'. Keeping current plugins unchanged.", config.ConfigurationId);
|
||||
return;
|
||||
}
|
||||
else
|
||||
logger.LogInformation("AI Studio runs without any enterprise configurations.");
|
||||
|
||||
CURRENT_ENVIRONMENTS = [];
|
||||
return;
|
||||
nextEnvironments.Add(config with { ETag = etagResponse.ETag });
|
||||
}
|
||||
|
||||
//
|
||||
// Step 4: Compare with current environments and process changes.
|
||||
// Step 3: Compare with current environments and process changes.
|
||||
// Download first. We only clean up obsolete plugins after all required
|
||||
// downloads have been completed successfully.
|
||||
//
|
||||
var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet();
|
||||
var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet();
|
||||
|
||||
// Remove plugins for configs that are no longer present:
|
||||
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
|
||||
{
|
||||
if (!nextIds.Contains(oldEnv.ConfigurationId))
|
||||
{
|
||||
logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId);
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
|
||||
if (isPluginInUse)
|
||||
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
|
||||
}
|
||||
}
|
||||
var shouldDeferStartupDownloads = isFirstRun && !PluginFactory.IsInitialized;
|
||||
|
||||
// Process new or changed configs:
|
||||
foreach (var nextEnv in nextEnvironments)
|
||||
@ -145,13 +101,28 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
else
|
||||
logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId);
|
||||
|
||||
if (isFirstRun)
|
||||
if (shouldDeferStartupDownloads)
|
||||
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv);
|
||||
else
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
logger.LogWarning("Failed to update enterprise configuration '{ConfigId}'. Keeping current plugins unchanged.", nextEnv.ConfigurationId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup is only allowed after a successful sync cycle:
|
||||
if (PluginFactory.IsInitialized && !shouldDeferStartupDownloads)
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(nextIds);
|
||||
|
||||
if (nextEnvironments.Count == 0)
|
||||
logger.LogInformation("AI Studio runs without any enterprise configurations.");
|
||||
|
||||
CURRENT_ENVIRONMENTS = nextEnvironments;
|
||||
HasValidEnterpriseSnapshot = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@ -36,13 +36,12 @@ public sealed partial class RustService
|
||||
var result = await this.http.GetAsync("/system/enterprise/configs");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||
return [];
|
||||
throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||
}
|
||||
|
||||
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
|
||||
if (configs is null)
|
||||
return [];
|
||||
throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust.");
|
||||
|
||||
var environments = new List<EnterpriseEnvironment>();
|
||||
foreach (var config in configs)
|
||||
@ -55,35 +54,4 @@ public sealed partial class RustService
|
||||
|
||||
return environments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads all enterprise configuration IDs that should be deleted.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns a list of GUIDs representing configuration IDs to remove.
|
||||
/// </returns>
|
||||
public async Task<List<Guid>> EnterpriseEnvDeleteConfigIds()
|
||||
{
|
||||
var result = await this.http.GetAsync("/system/enterprise/delete-configs");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'");
|
||||
return [];
|
||||
}
|
||||
|
||||
var ids = await result.Content.ReadFromJsonAsync<List<string>>(this.jsonRustSerializerOptions);
|
||||
if (ids is null)
|
||||
return [];
|
||||
|
||||
var guids = new List<Guid>();
|
||||
foreach (var idStr in ids)
|
||||
{
|
||||
if (Guid.TryParse(idStr, out var id))
|
||||
guids.Add(id);
|
||||
else
|
||||
this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'.");
|
||||
}
|
||||
|
||||
return guids;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,11 +4,14 @@
|
||||
- Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled.
|
||||
- Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled.
|
||||
- Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details.
|
||||
- Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details.
|
||||
- Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged.
|
||||
- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users.
|
||||
- Improved the workspaces experience by using a different color for the delete button to avoid confusion.
|
||||
- Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened.
|
||||
- Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition.
|
||||
- Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably.
|
||||
- Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example vacation), which could lead to configuration conflicts.
|
||||
- Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves.
|
||||
- Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver.
|
||||
- Upgraded dependencies.
|
||||
@ -25,8 +25,6 @@ AI Studio supports loading multiple enterprise configurations simultaneously. Th
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier).
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_ids` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS`: One or more configuration IDs that should be removed, separated by `;`. The format is: `id1;id2;id3`. This is helpful if an employee moves to a different department or leaves the organization.
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret.
|
||||
|
||||
**Example:** To configure two enterprise configurations (one for the organization and one for a department):
|
||||
@ -43,8 +41,6 @@ The following single-configuration keys and variables are still supported for ba
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person.
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID`: This is a configuration ID that should be removed. This is helpful if an employee moves to a different department or leaves the organization.
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file.
|
||||
|
||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration.
|
||||
@ -107,6 +103,16 @@ For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861
|
||||
ID = "9072b77d-ca81-40da-be6a-861da525ef7b"
|
||||
```
|
||||
|
||||
## Important: Mark enterprise-managed plugins explicitly
|
||||
|
||||
Configuration plugins deployed by your configuration server should define:
|
||||
|
||||
```lua
|
||||
DEPLOYED_USING_CONFIG_SERVER = true
|
||||
```
|
||||
|
||||
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
|
||||
|
||||
## Example AI Studio configuration
|
||||
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files:
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::env;
|
||||
use std::sync::OnceLock;
|
||||
use log::{debug, info, warn};
|
||||
use rocket::{delete, get};
|
||||
use rocket::get;
|
||||
use rocket::serde::json::Json;
|
||||
use serde::Serialize;
|
||||
use sys_locale::get_locale;
|
||||
@ -178,30 +178,6 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[delete("/system/enterprise/config/id")]
|
||||
pub fn delete_enterprise_env_config_id(_token: APIToken) -> String {
|
||||
//
|
||||
// When we are on a Windows machine, we try to read the enterprise config from
|
||||
// the Windows registry. In case we can't find the registry key, or we are on a
|
||||
// macOS or Linux machine, we try to read the enterprise config from the
|
||||
// environment variables.
|
||||
//
|
||||
// The registry key is:
|
||||
// HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT
|
||||
//
|
||||
// In this registry key, we expect the following values:
|
||||
// - delete_config_id
|
||||
//
|
||||
// The environment variable is:
|
||||
// MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID
|
||||
//
|
||||
debug!("Trying to read the enterprise environment for some config ID, which should be deleted.");
|
||||
get_enterprise_configuration(
|
||||
"delete_config_id",
|
||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/system/enterprise/config/server")]
|
||||
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
|
||||
//
|
||||
@ -314,46 +290,6 @@ pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>>
|
||||
Json(configs)
|
||||
}
|
||||
|
||||
/// Returns all enterprise configuration IDs that should be deleted. Supports the new
|
||||
/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable.
|
||||
#[get("/system/enterprise/delete-configs")]
|
||||
pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json<Vec<String>> {
|
||||
info!("Trying to read the enterprise environment for configuration IDs to delete.");
|
||||
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
// Read the new combined format:
|
||||
let combined = get_enterprise_configuration(
|
||||
"delete_config_ids",
|
||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS",
|
||||
);
|
||||
|
||||
if !combined.is_empty() {
|
||||
for id in combined.split(';') {
|
||||
let id = id.trim().to_lowercase();
|
||||
if !id.is_empty() && seen.insert(id.clone()) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also read the legacy single-delete variable:
|
||||
let delete_id = get_enterprise_configuration(
|
||||
"delete_config_id",
|
||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
|
||||
);
|
||||
|
||||
if !delete_id.is_empty() {
|
||||
let id = delete_id.trim().to_lowercase();
|
||||
if seen.insert(id.clone()) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
Json(ids)
|
||||
}
|
||||
|
||||
fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "windows")] {
|
||||
|
||||
@ -83,11 +83,9 @@ pub fn start_runtime_api() {
|
||||
crate::environment::get_config_directory,
|
||||
crate::environment::read_user_language,
|
||||
crate::environment::read_enterprise_env_config_id,
|
||||
crate::environment::delete_enterprise_env_config_id,
|
||||
crate::environment::read_enterprise_env_config_server_url,
|
||||
crate::environment::read_enterprise_env_config_encryption_secret,
|
||||
crate::environment::read_enterprise_configs,
|
||||
crate::environment::read_enterprise_delete_config_ids,
|
||||
crate::file_data::extract_data,
|
||||
crate::log::get_log_paths,
|
||||
crate::log::log_event,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user