diff --git a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs index ec1246f..ce03edf 100644 --- a/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs +++ b/NEW/src/JdeScoping.Infrastructure/DependencyInjection.cs @@ -55,7 +55,31 @@ public static class InfrastructureDependencyInjection services.AddSingleton(); // Register configuration validators + services.AddInfrastructureValidators(configuration); + + return services; + } + + /// + /// Adds only the configuration validators (not other Infrastructure services). + /// Used by ConfigManager for runtime validation where SecureStore is managed separately. + /// + /// The service collection. + /// The configuration. + /// The service collection for chaining. + public static IServiceCollection AddInfrastructureValidators( + this IServiceCollection services, + IConfiguration configuration) + { + // Bind options that validators depend on + services.Configure( + configuration.GetSection(SecureStoreOptions.SectionName)); + services.Configure( + configuration.GetSection(LdapOptions.SectionName)); + + // Register validators - new validators added here are automatically discovered services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/NEW/src/JdeScoping.Infrastructure/Validation/ConnectionStringValidator.cs b/NEW/src/JdeScoping.Infrastructure/Validation/ConnectionStringValidator.cs new file mode 100644 index 0000000..7966be1 --- /dev/null +++ b/NEW/src/JdeScoping.Infrastructure/Validation/ConnectionStringValidator.cs @@ -0,0 +1,170 @@ +using System.Text.RegularExpressions; +using JdeScoping.Core.Interfaces; +using JdeScoping.Core.Validation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace JdeScoping.Infrastructure.Validation; + +/// +/// Validates connection strings by resolving SecureStore placeholders and verifying format. +/// +public partial class ConnectionStringValidator : IConfigurationValidator +{ + private readonly IConfiguration _configuration; + private readonly ISecureStoreService _secureStore; + private readonly ILogger _logger; + + /// + public int Order => 150; // After SecureStore (100), before LDAP (200) + + /// + public string Name => "ConnectionStrings"; + + [GeneratedRegex(@"\$\{([^}]+)\}")] + private static partial Regex PlaceholderPattern(); + + public ConnectionStringValidator( + IConfiguration configuration, + ISecureStoreService secureStore, + ILogger logger) + { + _configuration = configuration; + _secureStore = secureStore; + _logger = logger; + } + + /// + public ConfigurationValidationResult Validate() + { + var result = new ConfigurationValidationResult(Name); + + var connectionStringsSection = _configuration.GetSection("ConnectionStrings"); + var connectionStrings = connectionStringsSection.GetChildren().ToList(); + + if (connectionStrings.Count == 0) + { + _logger.LogDebug("No connection strings configured, skipping validation"); + return result; + } + + _logger.LogDebug("Validating {Count} connection strings", connectionStrings.Count); + + foreach (var connectionString in connectionStrings) + { + ValidateConnectionString(connectionString.Key, connectionString.Value, result); + } + + return result; + } + + private void ValidateConnectionString(string name, string? connectionString, ConfigurationValidationResult result) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + result.AddError($"Connection string '{name}' is empty"); + return; + } + + _logger.LogDebug("Validating connection string: {Name}", name); + + // Extract and validate all placeholders + var placeholders = ExtractPlaceholders(connectionString); + var resolvedConnectionString = connectionString; + var hasPlaceholderErrors = false; + + foreach (var placeholder in placeholders) + { + var resolvedValue = ResolvePlaceholder(placeholder, name, result); + if (resolvedValue is null) + { + hasPlaceholderErrors = true; + } + else + { + // Replace placeholder with resolved value for format validation + resolvedConnectionString = resolvedConnectionString.Replace($"${{{placeholder}}}", resolvedValue); + } + } + + // Only validate format if all placeholders were resolved + if (!hasPlaceholderErrors) + { + ValidateConnectionStringFormat(name, resolvedConnectionString, result); + } + } + + private static List ExtractPlaceholders(string connectionString) + { + var placeholders = new List(); + var matches = PlaceholderPattern().Matches(connectionString); + + foreach (Match match in matches) + { + placeholders.Add(match.Groups[1].Value); + } + + return placeholders; + } + + private string? ResolvePlaceholder(string key, string connectionStringName, ConfigurationValidationResult result) + { + if (!_secureStore.Contains(key)) + { + result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but key '{key}' not found in SecureStore"); + return null; + } + + try + { + var value = _secureStore.Get(key); + if (string.IsNullOrEmpty(value)) + { + result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but key '{key}' has an empty value in SecureStore"); + return null; + } + + _logger.LogDebug("Resolved placeholder '{Key}' for connection string '{Name}'", key, connectionStringName); + return value; + } + catch (Exception ex) + { + result.AddError($"Connection string '{connectionStringName}' references placeholder '${{{key}}}' but failed to retrieve from SecureStore: {ex.Message}"); + return null; + } + } + + private void ValidateConnectionStringFormat(string name, string connectionString, ConfigurationValidationResult result) + { + // Check for required connection string components + // Support both "Server=" (SQL Server) and "Data Source=" (Oracle/generic) + var hasServer = connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase) || + connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase); + + if (!hasServer) + { + result.AddError($"Connection string '{name}' is missing required 'Server=', 'Data Source=', or 'Host=' component"); + return; + } + + // Detect connection type and log info + if (connectionString.Contains("Server=", StringComparison.OrdinalIgnoreCase) && + connectionString.Contains("Database=", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Connection string '{Name}' appears to be SQL Server format", name); + } + else if (connectionString.Contains("Data Source=", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Connection string '{Name}' appears to be Oracle/generic format", name); + } + else if (connectionString.Contains("Host=", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Connection string '{Name}' appears to be PostgreSQL/generic format", name); + } + else + { + result.AddWarning($"Connection string '{name}' has an unknown format"); + } + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs index 5ba5dcd..063373a 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/App.axaml.cs @@ -71,6 +71,9 @@ public partial class App : Avalonia.Application services.AddSingleton(); services.AddSingleton(); + // Runtime Validation Services + services.AddSingleton(); + // ViewModels services.AddTransient(); } diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj b/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj index aee17b9..cd40a37 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj +++ b/NEW/src/Utils/JdeScoping.ConfigManager/JdeScoping.ConfigManager.csproj @@ -18,9 +18,11 @@ - - - + + + + + @@ -28,5 +30,6 @@ + diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/IRuntimeConfigValidationService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IRuntimeConfigValidationService.cs new file mode 100644 index 0000000..a9bdb91 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/IRuntimeConfigValidationService.cs @@ -0,0 +1,40 @@ +namespace JdeScoping.ConfigManager.Services; + +/// +/// Result of runtime configuration validation. +/// +public class RuntimeValidationResult +{ + /// + /// Gets or sets the name of the validator that produced this result. + /// + public string ValidatorName { get; init; } = ""; + + /// + /// Gets whether validation passed (no errors). + /// + public bool IsValid => Errors.Count == 0; + + /// + /// Gets the list of validation errors. + /// + public List Errors { get; } = []; + + /// + /// Gets the list of validation warnings. + /// + public List Warnings { get; } = []; +} + +/// +/// Service for validating runtime configuration using Infrastructure validators. +/// +public interface IRuntimeConfigValidationService +{ + /// + /// Validates the configuration in the specified folder using Infrastructure validators. + /// + /// Path to the configuration folder. + /// List of validation results from each validator. + List ValidateRuntimeConfig(string configFolderPath); +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/RuntimeConfigValidationService.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/RuntimeConfigValidationService.cs new file mode 100644 index 0000000..afc02c2 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/RuntimeConfigValidationService.cs @@ -0,0 +1,114 @@ +using JdeScoping.ConfigManager.Services.SecureStore; +using JdeScoping.Core.Interfaces; +using JdeScoping.Core.Validation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JdeScoping.ConfigManager.Services; + +/// +/// Service that validates configuration using Infrastructure validators. +/// Uses DI-based automatic validator discovery (same pattern as Host). +/// +public class RuntimeConfigValidationService : IRuntimeConfigValidationService +{ + private readonly ISecureStoreManager _secureStoreManager; + private readonly ILogger _logger; + + /// + /// Creates a new instance of the runtime config validation service. + /// + /// The SecureStore manager for accessing secrets. + /// Optional logger for diagnostics. + public RuntimeConfigValidationService( + ISecureStoreManager secureStoreManager, + ILogger? logger = null) + { + _secureStoreManager = secureStoreManager; + _logger = logger ?? NullLogger.Instance; + } + + /// + public List ValidateRuntimeConfig(string configFolderPath) + { + var results = new List(); + + // Build IConfiguration from appsettings.json + var appSettingsPath = Path.Combine(configFolderPath, "appsettings.json"); + if (!File.Exists(appSettingsPath)) + { + var configResult = new RuntimeValidationResult { ValidatorName = "Configuration" }; + configResult.Errors.Add($"appsettings.json not found at: {appSettingsPath}"); + results.Add(configResult); + return results; + } + + var configuration = new ConfigurationBuilder() + .SetBasePath(configFolderPath) + .AddJsonFile("appsettings.json", optional: false) + .Build(); + + // Check if SecureStore is open + if (!_secureStoreManager.IsStoreOpen) + { + var storeWarning = new RuntimeValidationResult { ValidatorName = "SecureStore" }; + storeWarning.Warnings.Add("No SecureStore is currently open. SecureStore-dependent validations will be skipped."); + results.Add(storeWarning); + } + + // Build a scoped service provider for validators (automatic discovery) + using var validatorServices = BuildValidatorServiceProvider(configuration); + + // Discover validators automatically via DI + var validators = validatorServices + .GetServices() + .OrderBy(v => v.Order) + .ToList(); + + _logger.LogDebug("Discovered {Count} configuration validators", validators.Count); + + foreach (var validator in validators) + { + try + { + _logger.LogDebug("Running validator: {Name} (Order={Order})", validator.Name, validator.Order); + var validationResult = validator.Validate(); + var runtimeResult = new RuntimeValidationResult { ValidatorName = validationResult.ValidatorName }; + runtimeResult.Errors.AddRange(validationResult.Errors); + runtimeResult.Warnings.AddRange(validationResult.Warnings); + results.Add(runtimeResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Validator {Name} threw exception", validator.Name); + var errorResult = new RuntimeValidationResult { ValidatorName = validator.Name }; + errorResult.Errors.Add($"Validator threw exception: {ex.Message}"); + results.Add(errorResult); + } + } + + return results; + } + + /// + /// Builds a scoped service provider with validators and their dependencies. + /// New validators added via AddInfrastructureValidators() are automatically discovered. + /// + private ServiceProvider BuildValidatorServiceProvider(IConfiguration configuration) + { + var services = new ServiceCollection(); + + // Register core services that validators depend on + services.AddSingleton(configuration); + services.AddSingleton(new SecureStoreServiceAdapter(_secureStoreManager)); + services.AddLogging(); + + // Register validators using the same extension method as Host + // This ensures new validators are automatically discovered + services.AddInfrastructureValidators(configuration); + + return services.BuildServiceProvider(); + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStoreServiceAdapter.cs b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStoreServiceAdapter.cs new file mode 100644 index 0000000..2c90732 --- /dev/null +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Services/SecureStoreServiceAdapter.cs @@ -0,0 +1,67 @@ +using JdeScoping.ConfigManager.Services.SecureStore; +using JdeScoping.Core.Interfaces; + +namespace JdeScoping.ConfigManager.Services; + +/// +/// Adapts ISecureStoreManager to ISecureStoreService for use by Infrastructure validators. +/// +public class SecureStoreServiceAdapter : ISecureStoreService +{ + private readonly ISecureStoreManager _manager; + + /// + /// Creates a new adapter wrapping the specified manager. + /// + /// The SecureStore manager to adapt. + public SecureStoreServiceAdapter(ISecureStoreManager manager) + { + _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + } + + /// + public string? Get(string key) + { + if (!_manager.IsStoreOpen) return null; + try { return _manager.GetSecret(key); } + catch { return null; } + } + + /// + public string GetRequired(string key) + { + if (!_manager.IsStoreOpen) + throw new InvalidOperationException("No SecureStore is currently open"); + return _manager.GetSecret(key); + } + + /// + public void Set(string key, string value) => _manager.SetSecret(key, value); + + /// + public bool Contains(string key) + { + if (!_manager.IsStoreOpen) return false; + return _manager.GetKeys().Contains(key); + } + + /// + public bool Remove(string key) + { + if (!_manager.IsStoreOpen) return false; + try { _manager.RemoveSecret(key); return true; } + catch { return false; } + } + + /// + public void Save() => _manager.Save(); + + /// + public IEnumerable Keys => _manager.IsStoreOpen ? _manager.GetKeys() : []; + + /// + public void Dispose() + { + // Manager lifecycle handled elsewhere - we don't own it + } +} diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs index 16d9087..d923825 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs +++ b/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs @@ -24,6 +24,7 @@ public class MainWindowViewModel : ViewModelBase private readonly IDialogService? _dialogService; private readonly ISecureStoreManager _secureStoreManager; private readonly IClipboardService _clipboardService; + private readonly IRuntimeConfigValidationService _runtimeValidationService; private readonly ILogger? _logger; private string _configFolderPath = "No folder selected"; @@ -193,6 +194,11 @@ public class MainWindowViewModel : ViewModelBase /// public ICommand DeletePipelineCommand { get; } + /// + /// Gets the command for validating runtime configuration using Infrastructure validators. + /// + public ICommand ValidateRuntimeConfigCommand { get; } + /// /// Initializes a new instance of the class. /// @@ -204,6 +210,7 @@ public class MainWindowViewModel : ViewModelBase /// Service for showing platform dialogs. /// Service for managing encrypted secret stores. /// Service for clipboard operations. + /// Service for runtime configuration validation. /// Optional logger for recording view model activities. public MainWindowViewModel( IFileSystem fileSystem, @@ -214,6 +221,7 @@ public class MainWindowViewModel : ViewModelBase IDialogService? dialogService, ISecureStoreManager secureStoreManager, IClipboardService clipboardService, + IRuntimeConfigValidationService runtimeValidationService, ILogger? logger) { _fileSystem = fileSystem; @@ -224,6 +232,7 @@ public class MainWindowViewModel : ViewModelBase _dialogService = dialogService; _secureStoreManager = secureStoreManager; _clipboardService = clipboardService; + _runtimeValidationService = runtimeValidationService; _logger = logger; OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync); @@ -249,6 +258,11 @@ public class MainWindowViewModel : ViewModelBase AddPipelineCommand = new AsyncRelayCommand(AddPipelineAsync, CanAddPipeline); DeletePipelineCommand = new AsyncRelayCommand(DeletePipelineAsync, CanDeletePipeline); + // Validation commands + ValidateRuntimeConfigCommand = new AsyncRelayCommand( + ValidateRuntimeConfigAsync, + () => ConfigFolderPath != "No folder selected"); + _ = InitializeAsync(); } @@ -264,6 +278,7 @@ public class MainWindowViewModel : ViewModelBase null, new SecureStoreManager(), new NullClipboardService(), + new RuntimeConfigValidationService(new SecureStoreManager()), null) { } @@ -746,6 +761,31 @@ public class MainWindowViewModel : ViewModelBase } } + /// + /// Validates runtime configuration using Infrastructure validators (SecureStore, Connection Strings, LDAP). + /// + private async Task ValidateRuntimeConfigAsync() + { + if (_dialogService == null || ConfigFolderPath == "No folder selected") + return; + + var results = _runtimeValidationService.ValidateRuntimeConfig(ConfigFolderPath); + + // Convert to ValidationResult for dialog + var appSettingsResult = new ValidationResult(); + var pipelinesResult = new ValidationResult(); // Will remain empty for runtime validation + + foreach (var result in results) + { + foreach (var error in result.Errors) + appSettingsResult.AddError($"[{result.ValidatorName}] {error}"); + foreach (var warning in result.Warnings) + appSettingsResult.AddWarning($"[{result.ValidatorName}] {warning}"); + } + + await _dialogService.ShowValidationResultsAsync(appSettingsResult, pipelinesResult); + } + /// /// Tests database connections defined in the configuration. /// diff --git a/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml b/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml index 7396d22..7a908e1 100644 --- a/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml +++ b/NEW/src/Utils/JdeScoping.ConfigManager/Views/MainWindow.axaml @@ -93,6 +93,7 @@ + @@ -154,6 +155,8 @@