2025-06-01 21:14:21 +02:00
using AIStudio.Tools.PluginSystem ;
2026-03-31 13:02:59 +02:00
using AIStudio.Settings ;
using System.Security.Cryptography ;
using System.Text ;
2025-06-01 21:14:21 +02:00
namespace AIStudio.Tools.Services ;
2025-06-02 20:08:25 +02:00
public sealed class EnterpriseEnvironmentService ( ILogger < EnterpriseEnvironmentService > logger , RustService rustService ) : BackgroundService
2025-06-01 21:14:21 +02:00
{
2026-02-15 18:11:57 +01:00
public static List < EnterpriseEnvironment > CURRENT_ENVIRONMENTS = [];
2026-02-19 20:43:47 +01:00
public static bool HasValidEnterpriseSnapshot { get ; private set ; }
2026-03-31 13:02:59 +02:00
private static EnterpriseSecretSnapshot CURRENT_SECRET_SNAPSHOT ;
2025-06-02 20:08:25 +02:00
2026-05-31 12:11:09 +02:00
private readonly record struct EnterpriseEnvironmentSnapshot ( Guid ConfigurationId , string ConfigurationServerUrl , string Source , string SourceDetail , string Slot , string? ETag );
2026-03-31 13:02:59 +02:00
private readonly record struct EnterpriseSecretSnapshot ( bool HasSecret , string Fingerprint );
private readonly record struct EnterpriseSecretTarget ( string SecretId , string SecretName , SecretStoreType StoreType ) : ISecretId ;
2026-03-23 13:57:26 +01:00
2025-06-02 20:08:25 +02:00
#if DEBUG
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan . FromMinutes ( 6 );
#else
2025-06-01 21:14:21 +02:00
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan . FromMinutes ( 16 );
2025-06-02 20:08:25 +02:00
#endif
2025-06-01 21:14:21 +02:00
#region Overrides of BackgroundService
protected override async Task ExecuteAsync ( CancellationToken stoppingToken )
{
logger . LogInformation ( "The enterprise environment service was initialized." );
2025-06-02 20:08:25 +02:00
await this . StartUpdating ( isFirstRun : true );
2025-06-01 21:14:21 +02:00
while (! stoppingToken . IsCancellationRequested )
{
await Task . Delay ( CHECK_INTERVAL , stoppingToken );
await this . StartUpdating ();
}
}
#endregion
2025-06-02 20:08:25 +02:00
private async Task StartUpdating ( bool isFirstRun = false )
2025-06-01 21:14:21 +02:00
{
try
{
2025-06-27 20:52:33 +02:00
logger . LogInformation ( "Start updating of the enterprise environment." );
2026-02-19 20:43:47 +01:00
HasValidEnterpriseSnapshot = false ;
2026-03-23 13:57:26 +01:00
var previousSnapshot = BuildNormalizedSnapshot ( CURRENT_ENVIRONMENTS );
2026-03-31 13:02:59 +02:00
var previousSecretSnapshot = CURRENT_SECRET_SNAPSHOT ;
2026-02-15 18:11:57 +01:00
//
2026-02-19 20:43:47 +01:00
// Step 1: Fetch all active configurations.
2026-02-15 18:11:57 +01:00
//
List < EnterpriseEnvironment > fetchedConfigs ;
2026-01-24 20:17:35 +01:00
try
{
2026-02-15 18:11:57 +01:00
fetchedConfigs = await rustService . EnterpriseEnvConfigs ();
2026-01-24 20:17:35 +01:00
}
catch ( Exception e )
{
2026-02-15 18:11:57 +01:00
logger . LogError ( e , "Failed to fetch the enterprise configurations from the Rust service." );
await MessageBus . INSTANCE . SendMessage ( null , Event . RUST_SERVICE_UNAVAILABLE , "EnterpriseEnvConfigs failed" );
2026-01-24 20:17:35 +01:00
return ;
}
2026-03-31 13:02:59 +02:00
string enterpriseEncryptionSecret ;
try
{
enterpriseEncryptionSecret = await rustService . EnterpriseEnvConfigEncryptionSecret ();
}
catch ( Exception e )
{
logger . LogError ( e , "Failed to fetch the enterprise encryption secret from the Rust service." );
await MessageBus . INSTANCE . SendMessage ( null , Event . RUST_SERVICE_UNAVAILABLE , "EnterpriseEnvConfigEncryptionSecret failed" );
return ;
}
var nextSecretSnapshot = await BuildSecretSnapshot ( enterpriseEncryptionSecret );
var wasSecretChanged = previousSecretSnapshot != nextSecretSnapshot ;
2026-02-15 18:11:57 +01:00
//
2026-02-19 20:43:47 +01:00
// Step 2: Determine ETags and build the list of reachable configurations.
// IMPORTANT: when one config server fails, we continue with the others.
2026-02-15 18:11:57 +01:00
//
2026-02-19 20:43:47 +01:00
var reachableEnvironments = new List < EnterpriseEnvironment >();
var failedConfigIds = new HashSet < Guid >();
var currentEnvironmentsById = CURRENT_ENVIRONMENTS
. GroupBy ( env => env . ConfigurationId )
. ToDictionary ( group => group . Key , group => group . Last ());
var activeFetchedEnvironmentsById = fetchedConfigs
. Where ( config => config . IsActive )
. GroupBy ( config => config . ConfigurationId )
. ToDictionary ( group => group . Key , group => group . Last ());
2026-02-15 18:11:57 +01:00
foreach ( var config in fetchedConfigs )
2026-01-24 20:17:35 +01:00
{
2026-02-15 18:11:57 +01:00
if (! config . IsActive )
{
logger . LogWarning ( "Skipping inactive enterprise configuration with ID '{ConfigId}'. There is either no valid server URL or config ID set." , config . ConfigurationId );
continue ;
}
2026-02-19 20:43:47 +01:00
var etagResponse = await PluginFactory . DetermineConfigPluginETagAsync ( config . ConfigurationId , config . ConfigurationServerUrl );
if (! etagResponse . Success )
2026-02-15 18:11:57 +01:00
{
2026-02-19 20:43:47 +01:00
failedConfigIds . Add ( config . ConfigurationId );
logger . LogWarning ( "Failed to read enterprise config metadata for '{ConfigId}' from '{ServerUrl}': {Issue}. Keeping the current plugin state for this configuration." , config . ConfigurationId , config . ConfigurationServerUrl , etagResponse . Issue ?? "Unknown issue" );
continue ;
2026-02-15 18:11:57 +01:00
}
2026-02-19 20:43:47 +01:00
reachableEnvironments . Add ( config with { ETag = etagResponse . ETag });
2026-01-24 20:17:35 +01:00
}
2026-02-15 18:11:57 +01:00
//
2026-02-19 20:43:47 +01:00
// Step 3: Compare with current environments and process changes.
// Download per configuration. A single failure must not block others.
2026-02-15 18:11:57 +01:00
//
2026-02-19 20:43:47 +01:00
var shouldDeferStartupDownloads = isFirstRun && ! PluginFactory . IsInitialized ;
var effectiveEnvironmentsById = new Dictionary < Guid , EnterpriseEnvironment >();
2026-02-15 18:11:57 +01:00
// Process new or changed configs:
2026-02-19 20:43:47 +01:00
foreach ( var nextEnv in reachableEnvironments )
2025-06-01 21:14:21 +02:00
{
2026-02-19 20:43:47 +01:00
var hasCurrentEnvironment = currentEnvironmentsById . TryGetValue ( nextEnv . ConfigurationId , out var currentEnv );
if ( hasCurrentEnvironment && currentEnv == nextEnv ) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag).
2025-06-01 21:14:21 +02:00
{
2026-02-15 18:11:57 +01:00
logger . LogInformation ( "Enterprise configuration '{ConfigId}' has not changed. No update required." , nextEnv . ConfigurationId );
2026-02-19 20:43:47 +01:00
effectiveEnvironmentsById [ nextEnv . ConfigurationId ] = nextEnv ;
2026-02-15 18:11:57 +01:00
continue ;
2025-06-01 21:14:21 +02:00
}
2026-02-15 18:11:57 +01:00
2026-02-19 20:43:47 +01:00
if (! hasCurrentEnvironment )
2026-02-15 18:11:57 +01:00
logger . LogInformation ( "Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'." , nextEnv . ConfigurationId , nextEnv . ConfigurationServerUrl );
else
logger . LogInformation ( "Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed." , nextEnv . ConfigurationId );
2026-02-19 20:43:47 +01:00
if ( shouldDeferStartupDownloads )
{
2026-02-15 18:11:57 +01:00
MessageBus . INSTANCE . DeferMessage ( null , Event . STARTUP_ENTERPRISE_ENVIRONMENT , nextEnv );
2026-02-19 20:43:47 +01:00
effectiveEnvironmentsById [ nextEnv . ConfigurationId ] = nextEnv ;
}
2026-02-15 18:11:57 +01:00
else
2026-02-19 20:43:47 +01:00
{
var wasDownloadSuccessful = await PluginFactory . TryDownloadingConfigPluginAsync ( nextEnv . ConfigurationId , nextEnv . ConfigurationServerUrl );
if (! wasDownloadSuccessful )
{
failedConfigIds . Add ( nextEnv . ConfigurationId );
if ( hasCurrentEnvironment )
{
logger . LogWarning ( "Failed to update enterprise configuration '{ConfigId}'. Keeping the previously active version." , nextEnv . ConfigurationId );
effectiveEnvironmentsById [ nextEnv . ConfigurationId ] = currentEnv ;
}
else
logger . LogWarning ( "Failed to download the new enterprise configuration '{ConfigId}'. Skipping activation for now." , nextEnv . ConfigurationId );
continue ;
}
effectiveEnvironmentsById [ nextEnv . ConfigurationId ] = nextEnv ;
}
2025-06-01 21:14:21 +02:00
}
2026-02-15 18:11:57 +01:00
2026-02-19 20:43:47 +01:00
// Retain configurations for all failed IDs. On cold start there might be no
// previous in-memory snapshot yet, so we also keep the current fetched entry
// to protect it from cleanup while the server is unreachable.
foreach ( var failedConfigId in failedConfigIds )
{
if ( effectiveEnvironmentsById . ContainsKey ( failedConfigId ))
continue ;
if (! currentEnvironmentsById . TryGetValue ( failedConfigId , out var retainedEnvironment ))
{
if (! activeFetchedEnvironmentsById . TryGetValue ( failedConfigId , out retainedEnvironment ))
continue ;
logger . LogWarning ( "Could not refresh enterprise configuration '{ConfigId}'. Protecting it from cleanup until connectivity is restored." , failedConfigId );
}
else
logger . LogWarning ( "Could not refresh enterprise configuration '{ConfigId}'. Keeping the previously active version." , failedConfigId );
effectiveEnvironmentsById [ failedConfigId ] = retainedEnvironment ;
}
var effectiveEnvironments = effectiveEnvironmentsById . Values . ToList ();
// Cleanup is only allowed after a successful sync cycle:
if ( PluginFactory . IsInitialized && ! shouldDeferStartupDownloads )
PluginFactory . RemoveUnreferencedManagedConfigurationPlugins ( effectiveEnvironmentsById . Keys . ToHashSet ());
if ( effectiveEnvironments . Count == 0 )
logger . LogInformation ( "AI Studio runs without any enterprise configurations." );
2026-03-23 13:57:26 +01:00
var effectiveSnapshot = BuildNormalizedSnapshot ( effectiveEnvironments );
2026-03-31 13:02:59 +02:00
if ( PluginFactory . IsInitialized && wasSecretChanged )
{
logger . LogInformation ( "The enterprise encryption secret changed. Refreshing the enterprise encryption service and reloading plugins." );
PluginFactory . InitializeEnterpriseEncryption ( enterpriseEncryptionSecret );
2026-05-18 16:26:51 +02:00
await this . RemoveEnterpriseManagedSecretsAsync ();
2026-03-31 13:02:59 +02:00
await PluginFactory . LoadAll ();
}
2026-02-19 20:43:47 +01:00
CURRENT_ENVIRONMENTS = effectiveEnvironments ;
2026-03-31 13:02:59 +02:00
CURRENT_SECRET_SNAPSHOT = nextSecretSnapshot ;
2026-02-19 20:43:47 +01:00
HasValidEnterpriseSnapshot = true ;
2026-03-23 13:57:26 +01:00
2026-03-31 13:02:59 +02:00
if (! previousSnapshot . SequenceEqual ( effectiveSnapshot ) || wasSecretChanged )
2026-03-23 13:57:26 +01:00
await MessageBus . INSTANCE . SendMessage < bool >( null , Event . ENTERPRISE_ENVIRONMENTS_CHANGED );
2025-06-01 21:14:21 +02:00
}
catch ( Exception e )
{
logger . LogError ( e , "An error occurred while updating the enterprise environment." );
}
}
2026-03-23 13:57:26 +01:00
private static List < EnterpriseEnvironmentSnapshot > BuildNormalizedSnapshot ( IEnumerable < EnterpriseEnvironment > environments )
{
return environments
. Where ( environment => environment . IsActive )
. Select ( environment => new EnterpriseEnvironmentSnapshot (
environment . ConfigurationId ,
NormalizeServerUrl ( environment . ConfigurationServerUrl ),
2026-05-31 12:11:09 +02:00
environment . Source ,
environment . SourceDetail ,
environment . Slot ,
2026-03-23 13:57:26 +01:00
environment . ETag ?. ToString ()))
. OrderBy ( environment => environment . ConfigurationId )
. ToList ();
}
2026-03-31 13:02:59 +02:00
private static async Task < EnterpriseSecretSnapshot > BuildSecretSnapshot ( string secret )
{
if ( string . IsNullOrWhiteSpace ( secret ))
return new EnterpriseSecretSnapshot ( false , string . Empty );
return new EnterpriseSecretSnapshot ( true , await ComputeSecretFingerprint ( secret ));
}
private static async Task < string > ComputeSecretFingerprint ( string secret )
{
using var secretStream = new MemoryStream ( Encoding . UTF8 . GetBytes ( secret ));
var hash = await SHA256 . HashDataAsync ( secretStream );
return Convert . ToHexString ( hash );
}
2026-03-23 13:57:26 +01:00
private static string NormalizeServerUrl ( string serverUrl )
{
return serverUrl . Trim (). TrimEnd ( '/' );
}
2026-03-31 13:02:59 +02:00
2026-05-18 16:26:51 +02:00
private async Task RemoveEnterpriseManagedSecretsAsync ()
2026-03-31 13:02:59 +02:00
{
var secretTargets = GetEnterpriseManagedSecretTargets ();
if ( secretTargets . Count == 0 )
{
2026-05-18 16:26:51 +02:00
logger . LogInformation ( "No enterprise-managed secrets are currently known in the settings. No keyring cleanup is required." );
2026-03-31 13:02:59 +02:00
return ;
}
2026-05-18 16:26:51 +02:00
logger . LogInformation ( "Removing {SecretCount} enterprise-managed secret(s) from the OS keyring after an enterprise encryption secret change." , secretTargets . Count );
2026-03-31 13:02:59 +02:00
foreach ( var target in secretTargets )
{
try
{
2026-05-18 16:26:51 +02:00
var deleteResult = target . StoreType is SecretStoreType . DATA_SOURCE
? await rustService . DeleteSecret ( target , target . StoreType )
: await rustService . DeleteAPIKey ( target , target . StoreType );
2026-03-31 13:02:59 +02:00
if ( deleteResult . Success )
{
if ( deleteResult . WasEntryFound )
2026-05-18 16:26:51 +02:00
logger . LogInformation ( "Successfully deleted enterprise-managed secret '{SecretName}' from the OS keyring." , target . SecretName );
2026-03-31 13:02:59 +02:00
else
2026-05-18 16:26:51 +02:00
logger . LogInformation ( "Enterprise-managed secret '{SecretName}' was already absent from the OS keyring." , target . SecretName );
2026-03-31 13:02:59 +02:00
}
else
2026-05-18 16:26:51 +02:00
logger . LogWarning ( "Failed to delete enterprise-managed secret '{SecretName}' from the OS keyring: {Issue}" , target . SecretName , deleteResult . Issue );
2026-03-31 13:02:59 +02:00
}
catch ( Exception e )
{
2026-05-18 16:26:51 +02:00
logger . LogWarning ( e , "Failed to delete enterprise-managed secret '{SecretName}' from the OS keyring." , target . SecretName );
2026-03-31 13:02:59 +02:00
}
}
}
private static List < EnterpriseSecretTarget > GetEnterpriseManagedSecretTargets ()
{
var configurationData = Program . SERVICE_PROVIDER . GetRequiredService < SettingsManager >(). ConfigurationData ;
var secretTargets = new HashSet < EnterpriseSecretTarget >();
AddEnterpriseManagedSecretTargets ( configurationData . Providers , SecretStoreType . LLM_PROVIDER , secretTargets );
AddEnterpriseManagedSecretTargets ( configurationData . EmbeddingProviders , SecretStoreType . EMBEDDING_PROVIDER , secretTargets );
AddEnterpriseManagedSecretTargets ( configurationData . TranscriptionProviders , SecretStoreType . TRANSCRIPTION_PROVIDER , secretTargets );
2026-05-18 16:26:51 +02:00
AddEnterpriseManagedSecretTargets ( configurationData . DataSources . OfType < IExternalDataSource >(), SecretStoreType . DATA_SOURCE , secretTargets );
2026-03-31 13:02:59 +02:00
return secretTargets . ToList ();
}
private static void AddEnterpriseManagedSecretTargets < TSecret >(
IEnumerable < TSecret > secrets ,
SecretStoreType storeType ,
ISet < EnterpriseSecretTarget > secretTargets ) where TSecret : ISecretId , IConfigurationObject
{
foreach ( var secret in secrets )
{
if (! secret . IsEnterpriseConfiguration || secret . EnterpriseConfigurationPluginId == Guid . Empty )
continue ;
secretTargets . Add ( new EnterpriseSecretTarget ( secret . SecretId , secret . SecretName , storeType ));
}
}
2025-06-01 21:14:21 +02:00
}