diff --git a/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs b/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs index ee4dae1..3249cd3 100644 --- a/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs +++ b/ZA.CoreService.ESBCertificateManager/Configuration/AppSettingsLoader.cs @@ -7,7 +7,20 @@ public static class AppSettingsLoader { public static AppSettings Load() { - string basePath = AppContext.BaseDirectory; + // Zuerst Output-Verzeichnis, dann aktuelles Arbeitsverzeichnis (z.B. VS Debug). + string basePath = Directory.Exists(AppContext.BaseDirectory) + ? AppContext.BaseDirectory + : Directory.GetCurrentDirectory(); + + string settingsPath = Path.Combine(basePath, "appsettings.json"); + if (!File.Exists(settingsPath)) + { + string cwdPath = Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"); + if (File.Exists(cwdPath)) + { + basePath = Directory.GetCurrentDirectory(); + } + } IConfigurationRoot configuration = new ConfigurationBuilder() .SetBasePath(basePath) diff --git a/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json b/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json index 0676594..2fb5864 100644 --- a/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json +++ b/ZA.CoreService.ESBCertificateManager/Data/targets.sample.json @@ -1,17 +1,17 @@ [ { "Id": 1, - "Name": "ESB Local A", - "Environment": "DEV", + "Name": "DE-Test ESB Container", + "Environment": "TEST", "IsActive": true, - "TargetDirectory": "DeploySandbox/target-a", + "TargetDirectory": "DeploySandbox/target-esb", "CertificateFileName": "esb-cert.cer", - "ContainerName": "sonic-container-a", - "RestartType": "None", + "ContainerName": "ESB", + "RestartType": "SonicContainer", "RestartCommand": "", "RestartArguments": "", - "RestartTimeoutSeconds": 30, - "SonicConnectionName": "", + "RestartTimeoutSeconds": 90, + "SonicConnectionName": "DE-Test", "XapiSourcePath": "", "TlsHost": "", "TlsPort": null, @@ -21,15 +21,15 @@ }, { "Id": 2, - "Name": "ESB Local B", + "Name": "Lokaler CMD-Test (Echo)", "Environment": "DEV", "IsActive": true, - "TargetDirectory": "DeploySandbox/target-b", + "TargetDirectory": "DeploySandbox/target-cmd", "CertificateFileName": "esb-cert.cer", - "ContainerName": "sonic-container-b", + "ContainerName": "", "RestartType": "Command", "RestartCommand": "cmd.exe", - "RestartArguments": "/c echo Restart simulated for sonic-container-b", + "RestartArguments": "/c echo Neustart-Simulation OK", "RestartTimeoutSeconds": 15, "SonicConnectionName": "", "XapiSourcePath": "", @@ -38,45 +38,5 @@ "TlsServerName": "", "ExpectedFingerprint": null, "SortOrder": 20 - }, - { - "Id": 3, - "Name": "DE-Test Container A (Sonic)", - "Environment": "TEST", - "IsActive": false, - "TargetDirectory": "DeploySandbox/target-c", - "CertificateFileName": "esb-cert.cer", - "ContainerName": "sonic-container-a", - "RestartType": "SonicContainer", - "RestartCommand": "", - "RestartArguments": "", - "RestartTimeoutSeconds": 60, - "SonicConnectionName": "DE-Test", - "XapiSourcePath": "", - "TlsHost": "dekun-painwbdet", - "TlsPort": 443, - "TlsServerName": "esb-test.firma.local", - "ExpectedFingerprint": null, - "SortOrder": 30 - }, - { - "Id": 4, - "Name": "DE-Test Container B (Sonic + XApi)", - "Environment": "TEST", - "IsActive": false, - "TargetDirectory": "DeploySandbox/target-d", - "CertificateFileName": "esb-cert.cer", - "ContainerName": "sonic-container-b", - "RestartType": "SonicContainerWithXapi", - "RestartCommand": "", - "RestartArguments": "", - "RestartTimeoutSeconds": 60, - "SonicConnectionName": "DE-Test", - "XapiSourcePath": "Assets/xapi-resources.xml", - "TlsHost": "dekun-painwbdet", - "TlsPort": 443, - "TlsServerName": "esb-test.firma.local", - "ExpectedFingerprint": null, - "SortOrder": 40 } ] diff --git a/ZA.CoreService.ESBCertificateManager/Form1.cs b/ZA.CoreService.ESBCertificateManager/Form1.cs index bce8aae..54195ad 100644 --- a/ZA.CoreService.ESBCertificateManager/Form1.cs +++ b/ZA.CoreService.ESBCertificateManager/Form1.cs @@ -52,6 +52,7 @@ namespace ZA.CoreService.ESBCertificateManager private Button btnClose = null!; private Button btnValidate = null!; private Button btnDeploy = null!; + private Button btnRestartOnly = null!; private Button btnCancelRun = null!; private bool isMaximized = false; @@ -1231,6 +1232,11 @@ namespace ZA.CoreService.ESBCertificateManager btnValidate.Anchor = AnchorStyles.Top | AnchorStyles.Right; btnValidate.Click += async (_, _) => await RunValidationAsync(); + btnRestartOnly = CreateSecondaryButton("ESB neu starten"); + btnRestartOnly.Size = new Size(150, 42); + btnRestartOnly.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnRestartOnly.Click += async (_, _) => await RunRestartOnlyAsync(); + btnDeploy = CreatePrimaryButton("Deployment starten"); btnDeploy.Size = new Size(170, 42); btnDeploy.Anchor = AnchorStyles.Top | AnchorStyles.Right; @@ -1238,12 +1244,14 @@ namespace ZA.CoreService.ESBCertificateManager panel.Controls.Add(btnCancelRun); panel.Controls.Add(btnValidate); + panel.Controls.Add(btnRestartOnly); panel.Controls.Add(btnDeploy); panel.Resize += (_, _) => { btnDeploy.Left = panel.Width - btnDeploy.Width; - btnValidate.Left = btnDeploy.Left - btnValidate.Width - 12; + btnRestartOnly.Left = btnDeploy.Left - btnRestartOnly.Width - 12; + btnValidate.Left = btnRestartOnly.Left - btnValidate.Width - 12; btnCancelRun.Left = btnValidate.Left - btnCancelRun.Width - 12; }; @@ -1377,11 +1385,20 @@ namespace ZA.CoreService.ESBCertificateManager btnCancelRun.Enabled = _isOperationRunning; btnValidate.Enabled = idle && hasCertificate && hasTargets; btnDeploy.Enabled = idle && hasCertificate && hasSelection; + if (btnRestartOnly is not null) + { + btnRestartOnly.Enabled = idle && hasSelection; + } if (dgvTargets is not null) { dgvTargets.Enabled = idle; } + + if (btnLoadFromSonic is not null) + { + btnLoadFromSonic.Enabled = idle && _sonicDiscovery.HasConnections; + } } private void SetStatus(string message, bool isError) @@ -1464,6 +1481,86 @@ namespace ZA.CoreService.ESBCertificateManager return Task.CompletedTask; } + private async Task RunRestartOnlyAsync() + { + if (_isOperationRunning) + { + return; + } + + List selected = GetSelectedTargets(); + PreflightValidationResult preflight = _orchestrator.ValidateRestartOnly(selected); + + if (!preflight.IsValid) + { + ShowPreflightIssues( + preflight, + "Neustart blockiert – Vorabprüfung fehlgeschlagen.", + "ESB neu starten"); + return; + } + + DialogResult confirm = MessageBox.Show( + this, + $"ESB/Container für {selected.Count} Ziel(e) wirklich neu starten?\n\n" + + "Es wird stopcontainer/startcontainer über die Sonic-Verbindung ausgeführt\n" + + "(WinRM Remote-CMD oder LocalCmd laut appsettings).", + "ESB neu starten", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + + if (confirm != DialogResult.Yes) + { + return; + } + + _runCts?.Dispose(); + _runCts = new CancellationTokenSource(); + SetOperationRunning(true); + UpdateToNextStep(3); + SetStatus($"ESB-Neustart läuft für {selected.Count} Ziel(e)…", isError: false); + + Progress progress = new(update => + { + SetTargetRowStatus(update.TargetId, update.StatusText); + SetStatus(update.StatusText, isError: update.SuccessHint == false); + }); + + try + { + DeploymentRunResult runResult = await _orchestrator.RestartOnlyAsync( + selected, + progress, + _runCts.Token); + + foreach (TargetStepResult targetResult in runResult.TargetResults) + { + SetTargetRowStatus(targetResult.TargetId, targetResult.StatusText); + } + + SetStatus( + runResult.OverallSuccess + ? $"Neustart erfolgreich ({runResult.TargetResults.Count} Ziel(e))." + : "Neustart mit Fehlern beendet. Details in Status-Spalte / Log.", + isError: !runResult.OverallSuccess); + } + catch (OperationCanceledException) + { + SetStatus("Neustart abgebrochen.", isError: true); + } + catch (Exception ex) + { + SetStatus($"Neustart fehlgeschlagen: {ex.Message}", isError: true); + MessageBox.Show(this, ex.Message, "ESB neu starten", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SetOperationRunning(false); + _runCts?.Dispose(); + _runCts = null; + } + } + private async Task RunDeploymentAsync() { if (_isOperationRunning || _loadedCertificateInfo is null) @@ -1680,9 +1777,6 @@ namespace ZA.CoreService.ESBCertificateManager lblSonicStatus = new Label { - Text = _sonicDiscovery.HasConnections - ? "Keine Verbindung konfiguriert" - : "Keine Sonic-Verbindung konfiguriert", ForeColor = MutedTextColor, Font = new Font("Segoe UI", 8.5f), AutoSize = true, @@ -1696,7 +1790,10 @@ namespace ZA.CoreService.ESBCertificateManager } else { - lblSonicStatus.Text = $"{_sonicDiscovery.Connections.Count} Verbindung(en) konfiguriert – noch nicht geladen"; + SonicConnection first = _sonicDiscovery.Connections[0]; + lblSonicStatus.Text = + $"{_sonicDiscovery.Connections.Count} Verb. | {first.ManagementMode} | {first.DomainName} | SonicHome={first.SonicHome}"; + lblSonicStatus.ForeColor = GreenColor; } card.Controls.Add(sonicLabel); diff --git a/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs b/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs index e24a3bb..227da4b 100644 --- a/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs +++ b/ZA.CoreService.ESBCertificateManager/Models/SonicConnection.cs @@ -15,93 +15,148 @@ public sealed class SonicConnection /// /// Sonic-Broker-/Management-URL im Sonic-Format, z.B. "tcp://dekun-painwbdet:13070". - /// Der Hostname wird daraus extrahiert (für HTTP-Modus: Basis-URL, für WinRM-Modus: Zielrechner). + /// Der Hostname wird daraus extrahiert (WinRM-Ziel / HTTP-Basis). /// public required string ConnectionUrl { get; init; } - /// Benutzername für die Management-Konsole. + /// Benutzername für WinRM / Management-Konsole. public required string Username { get; init; } - /// Passwort für die Management-Konsole. + /// Passwort für WinRM / Management-Konsole. public required string Password { get; init; } /// - /// Management-Modus: HttpApi (REST) oder WinRm (PowerShell Remoting). - /// Standard: WinRm – da Sonic 10.x kein HTTP REST API bereitstellt. + /// Management-Modus: + /// - WinRm: PowerShell Remoting auf dem Sonic-Server (Standard) + /// - LocalCmd: CMD/PowerShell lokal (zum Testen direkt auf dem Sonic-PC) + /// - HttpApi: REST, falls vorhanden /// public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm; + /// + /// Installationsroot von Sonic MQ / Management Console, z.B. "C:\Sonic\MQ10.0". + /// Wird für Default-Scripts (stopcontainer/startcontainer) benötigt. + /// + public string SonicHome { get; init; } = @"C:\Sonic\MQ10.0"; + // --------------------------------------------------------------- // HTTP REST API (ManagementMode = HttpApi) // --------------------------------------------------------------- - /// HTTP-Port der Sonic Management Console REST-API (Standard: 8080). public int ManagementHttpPort { get; init; } = 8080; - - /// Präfix für alle REST-API-Pfade (Standard: "/api/v1"). public string ApiBasePath { get; init; } = "/api/v1"; - - /// Konfigurierter Pfad zum Auflisten aller Container. Leer = automatische Erkennung. public string ContainerListPath { get; init; } = string.Empty; - - /// Konfigurierter Pfad zum Neustarten. Platzhalter: {domain}, {container}. public string ContainerRestartPath { get; init; } = string.Empty; - - /// Konfigurierter Pfad zum Stoppen. Platzhalter: {domain}, {container}. public string ContainerStopPath { get; init; } = string.Empty; - - /// Konfigurierter Pfad zum Starten. Platzhalter: {domain}, {container}. public string ContainerStartPath { get; init; } = string.Empty; // --------------------------------------------------------------- - // WinRM / PowerShell Remoting (ManagementMode = WinRm) + // WinRM / LocalCmd // --------------------------------------------------------------- - /// - /// WinRM-Port auf dem Zielrechner (Standard: 5985 = HTTP, 5986 = HTTPS). - /// public int WinRmPort { get; init; } = 5985; /// - /// PowerShell-Scriptblock zum Neustarten eines Containers. - /// Platzhalter: {container} = Container-Name (nicht enkodiert), {domain} = Domain-Name. - /// Beispiel für Windows-Service: "Restart-Service -Name 'CT-ZADBService' -Force" - /// Beispiel für Sonic-Skript: "& 'C:\\Sonic\\bin\\stopContainer.bat' '{container}'; Start-Sleep 5; & 'C:\\Sonic\\bin\\startContainer.bat' '{container}'" + /// PowerShell-Script zum Neustarten. Leer = Default über Sonic bin\stop/startcontainer.bat. + /// Platzhalter: {container}, {domain}, {sonicHome} /// public string WinRmRestartScript { get; init; } = string.Empty; /// - /// PowerShell-Scriptblock zum Auflisten aller Container der Domain. - /// Ausgabe: eine Zeile pro Container-Name. - /// Beispiel: "Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty Name" + /// PowerShell-Script zum Auflisten der Container. Leer = Default (Services + SonicHome). /// public string WinRmContainerListScript { get; init; } = string.Empty; - /// - /// PowerShell-Scriptblock für XApi-Import. - /// Platzhalter: {container}, {xapiPath}. - /// public string WinRmXapiImportScript { get; init; } = string.Empty; - // --------------------------------------------------------------- - // Gemeinsame Einstellungen - // --------------------------------------------------------------- - - /// Timeout in Sekunden für einzelne API-/Script-Aufrufe (Standard: 60). public int TimeoutSeconds { get; init; } = 60; - - /// Wartezeit in Sekunden nach einem Container-Neustart (Standard: 15). public int PostRestartDelaySeconds { get; init; } = 15; + + /// Effektives Restart-Script inkl. Default, wenn leer. + public string ResolveRestartScript() + => string.IsNullOrWhiteSpace(WinRmRestartScript) + ? DefaultRestartScript + : WinRmRestartScript; + + /// Effektives List-Script inkl. Default, wenn leer. + public string ResolveContainerListScript() + => string.IsNullOrWhiteSpace(WinRmContainerListScript) + ? DefaultContainerListScript + : WinRmContainerListScript; + + /// + /// Default: Sonic Management Console Tools per CMD auf dem Ziel-PC. + /// stopcontainer.bat / startcontainer.bat unter SonicHome\bin. + /// + public const string DefaultRestartScript = + """ + $ErrorActionPreference = 'Stop' + $sonicHome = '{sonicHome}' + $domain = '{domain}' + $container = '{container}' + $bin = Join-Path $sonicHome 'bin' + $stop = Join-Path $bin 'stopcontainer.bat' + $start = Join-Path $bin 'startcontainer.bat' + $fullName = if ($container -like '*.*') { $container } else { "$domain.$container" } + + if (-not (Test-Path -LiteralPath $stop)) { + throw "Sonic stopcontainer.bat nicht gefunden: $stop (SonicHome prüfen)" + } + if (-not (Test-Path -LiteralPath $start)) { + throw "Sonic startcontainer.bat nicht gefunden: $start (SonicHome prüfen)" + } + + Write-Output "STOP $fullName via $stop" + $stopProc = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', "`"$stop`" `"$fullName`"") -Wait -PassThru -NoNewWindow + if ($stopProc.ExitCode -ne 0) { + Write-Warning "stopcontainer ExitCode=$($stopProc.ExitCode) – starte trotzdem neu" + } + + Start-Sleep -Seconds 5 + + Write-Output "START $fullName via $start" + $startProc = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', "`"$start`" `"$fullName`"") -Wait -PassThru -NoNewWindow + if ($startProc.ExitCode -ne 0) { + throw "startcontainer fehlgeschlagen, ExitCode=$($startProc.ExitCode)" + } + + Write-Output "Neustart ok: $fullName" + """; + + public const string DefaultContainerListScript = + """ + $ErrorActionPreference = 'Continue' + $sonicHome = '{sonicHome}' + $names = @() + + # Windows-Dienste mit Sonic/ESB im Namen + $names += @(Get-Service -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match 'Sonic|ESB|MQ' -or $_.Name -match 'Sonic|ESB|MQ' } | + Select-Object -ExpandProperty Name) + + # Container-Cache-Ordner unter SonicHome (Domain.Container.cache) + if (Test-Path -LiteralPath $sonicHome) { + $names += @(Get-ChildItem -LiteralPath $sonicHome -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '*.cache' } | + ForEach-Object { $_.Name -replace '\.cache$', '' }) + } + + $names | Where-Object { $_ } | Sort-Object -Unique + """; } public enum SonicManagementMode { /// HTTP REST API (wenn vom Sonic-Server bereitgestellt). - HttpApi, + HttpApi = 0, /// - /// PowerShell Remoting (WinRM) – Standard für Sonic 10.x auf Windows. - /// Führt konfigurierte Scriptblöcke via Invoke-Command auf dem Sonic-Server aus. + /// PowerShell Remoting (WinRM) – führt Scripts auf dem Sonic-Server aus. /// - WinRm + WinRm = 1, + + /// + /// CMD/PowerShell lokal auf diesem PC – zum Testen direkt auf dem Sonic-Rechner. + /// + LocalCmd = 2 } diff --git a/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs b/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs index 215194c..fb20aeb 100644 --- a/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs +++ b/ZA.CoreService.ESBCertificateManager/Services/DeploymentOrchestrator.cs @@ -26,6 +26,89 @@ public sealed class DeploymentOrchestrator IReadOnlyList selectedTargets) => _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets); + public PreflightValidationResult ValidateRestartOnly(IReadOnlyList selectedTargets) + => _preflightValidator.ValidateRestartOnly(selectedTargets); + + /// + /// Nur ESB-/Container-Neustart – ohne Zertifikatskopieren und ohne TLS-Probe. + /// + public async Task RestartOnlyAsync( + IReadOnlyList selectedTargets, + IProgress? progress, + CancellationToken cancellationToken = default) + { + DateTimeOffset startedAt = DateTimeOffset.Now; + List results = []; + + using RunLogger logger = new(_settings.LogDirectory); + logger.Write($"Neustart-only gestartet für {selectedTargets.Count} Ziel(e)."); + + foreach (DeploymentTarget target in selectedTargets) + { + cancellationToken.ThrowIfCancellationRequested(); + results.Add(await RestartTargetOnlyAsync(target, logger, progress, cancellationToken)); + } + + DateTimeOffset finishedAt = DateTimeOffset.Now; + DeploymentRunResult runResult = new() + { + TargetResults = results, + StartedAt = startedAt, + FinishedAt = finishedAt, + CertificateFilePath = string.Empty, + CertificateFingerprint = string.Empty + }; + + logger.Write( + $"Neustart-only beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}"); + + try + { + await _sqlRunLogger.PersistRunResultAsync(runResult, cancellationToken); + } + catch (Exception ex) + { + logger.Write($"SQL-Protokollierung fehlgeschlagen (nicht kritisch): {ex.Message}"); + } + + return runResult; + } + + private async Task RestartTargetOnlyAsync( + DeploymentTarget target, + RunLogger logger, + IProgress? progress, + CancellationToken cancellationToken) + { + List steps = []; + DateTimeOffset targetStart = DateTimeOffset.Now; + progress?.Report(new TargetProgressUpdate(target.Id, "Neustart…", false)); + + (bool restartOk, string restartStatus, string? restartDetail) = + await _restartExecutor.ExecuteAsync(target, cancellationToken); + + RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail); + + TargetStepResult targetResult = new() + { + TargetId = target.Id, + TargetName = target.Name, + Success = restartOk, + StatusText = restartStatus, + Detail = restartDetail, + Steps = steps, + StartedAt = targetStart, + FinishedAt = DateTimeOffset.Now, + CopySucceeded = true, + RestartSucceeded = restartOk, + TlsSucceeded = true, + ObservedFingerprint = null + }; + + progress?.Report(new TargetProgressUpdate(target.Id, restartStatus, restartOk)); + return targetResult; + } + public async Task RunAsync( string certificatePath, CertificateInfo certificateInfo, diff --git a/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs b/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs index 524cc20..3bd6933 100644 --- a/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs +++ b/ZA.CoreService.ESBCertificateManager/Services/PreflightValidator.cs @@ -32,22 +32,55 @@ public sealed class PreflightValidator foreach (DeploymentTarget target in selectedTargets) { - ValidateTarget(result, target); + ValidateTarget(result, target, requireDeployPaths: true); } return result; } - private static void ValidateTarget(PreflightValidationResult result, DeploymentTarget target) + /// + /// Vorabprüfung nur für ESB-Neustart (ohne Zertifikat / Kopierpfade). + /// + public PreflightValidationResult ValidateRestartOnly(IReadOnlyList selectedTargets) { - if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + PreflightValidationResult result = new(); + + if (selectedTargets.Count == 0) { - AddIssue(result, $"Ziel '{target.Name}': TargetDirectory fehlt.", target.Id); + AddIssue(result, "Bitte mindestens ein Ziel anhaken."); + return result; } - if (string.IsNullOrWhiteSpace(target.CertificateFileName)) + foreach (DeploymentTarget target in selectedTargets) { - AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id); + if (target.RestartType is RestartType.None) + { + AddIssue(result, $"Ziel '{target.Name}': RestartType=None – kein Neustart konfiguriert.", target.Id); + continue; + } + + ValidateTarget(result, target, requireDeployPaths: false); + } + + return result; + } + + private static void ValidateTarget( + PreflightValidationResult result, + DeploymentTarget target, + bool requireDeployPaths) + { + if (requireDeployPaths) + { + if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + { + AddIssue(result, $"Ziel '{target.Name}': TargetDirectory fehlt.", target.Id); + } + + if (string.IsNullOrWhiteSpace(target.CertificateFileName)) + { + AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id); + } } if (target.RestartType == RestartType.Command @@ -75,7 +108,7 @@ public sealed class PreflightValidator AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id); } - if (string.IsNullOrWhiteSpace(target.TargetDirectory)) + if (!requireDeployPaths || string.IsNullOrWhiteSpace(target.TargetDirectory)) { return; } diff --git a/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs b/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs index ff1a242..717b34b 100644 --- a/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs +++ b/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs @@ -9,19 +9,20 @@ namespace ZA.CoreService.ESBCertificateManager.Services; /// /// Verwaltet Sonic ESB Container über die Management Console. /// -/// Modus = WinRm (Standard, Sonic 10.x): -/// PowerShell Remoting (Invoke-Command) auf dem Sonic-Server. -/// Erfordert WinRM auf dem Zielrechner: Enable-PSRemoting -Force +/// Modus = WinRm (Standard): +/// PowerShell Remoting (Invoke-Command) → CMD stop/startcontainer auf dem Sonic-Server. +/// +/// Modus = LocalCmd: +/// Dieselbe CMD/PowerShell-Logik lokal (Test direkt auf dem Sonic-PC). /// /// Modus = HttpApi: /// HTTP REST API mit automatischer Pfad-Erkennung. -/// Probiert: /mf/rest/v1, /api/v1, /sonic/management, /containers /// public sealed class SonicManagementClient : IDisposable { private readonly SonicConnection _connection; private readonly HttpClient? _http; - private readonly WinRmExecutor? _winRm; + private readonly WinRmExecutor? _scriptRunner; // Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus) private string? _resolvedContainerBasePath; @@ -34,13 +35,16 @@ public sealed class SonicManagementClient : IDisposable "/containers" ]; + private bool UsesScripts => + _connection.ManagementMode is SonicManagementMode.WinRm or SonicManagementMode.LocalCmd; + public SonicManagementClient(SonicConnection connection) { _connection = connection; - if (connection.ManagementMode == SonicManagementMode.WinRm) + if (UsesScripts) { - _winRm = new WinRmExecutor(connection); + _scriptRunner = new WinRmExecutor(connection); } else { @@ -77,10 +81,13 @@ public sealed class SonicManagementClient : IDisposable public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync( CancellationToken cancellationToken = default) { - if (_connection.ManagementMode == SonicManagementMode.WinRm) + if (UsesScripts) { - (bool ok, string? error) = await _winRm!.TestConnectionAsync(cancellationToken); - return (ok, error, ok ? $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}" : null); + (bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken); + string modeLabel = _connection.ManagementMode == SonicManagementMode.LocalCmd + ? $"LocalCmd (SonicHome={_connection.SonicHome})" + : $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}"; + return (ok, error, ok ? modeLabel : null); } try @@ -116,9 +123,9 @@ public sealed class SonicManagementClient : IDisposable public async Task<(bool Success, IReadOnlyList ContainerNames, string? Error)> GetContainersAsync( CancellationToken cancellationToken = default) { - if (_connection.ManagementMode == SonicManagementMode.WinRm) + if (UsesScripts) { - return await GetContainersViaWinRmAsync(cancellationToken); + return await GetContainersViaScriptAsync(cancellationToken); } return await GetContainersViaHttpAsync(cancellationToken); @@ -131,9 +138,9 @@ public sealed class SonicManagementClient : IDisposable string containerName, CancellationToken cancellationToken = default) { - if (_connection.ManagementMode == SonicManagementMode.WinRm) + if (UsesScripts) { - return await RestartViaWinRmAsync(containerName, cancellationToken); + return await RestartViaScriptAsync(containerName, cancellationToken); } return await RestartViaHttpAsync(containerName, cancellationToken); @@ -147,83 +154,72 @@ public sealed class SonicManagementClient : IDisposable string xapiSourcePath, CancellationToken cancellationToken = default) { - if (_connection.ManagementMode == SonicManagementMode.WinRm) + if (UsesScripts) { - return await ImportXapiViaWinRmAsync(containerName, xapiSourcePath, cancellationToken); + return await ImportXapiViaScriptAsync(containerName, xapiSourcePath, cancellationToken); } return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken); } // --------------------------------------------------------------- - // WinRM-Implementierungen + // Script-Implementierungen (WinRM / LocalCmd) // --------------------------------------------------------------- - private async Task<(bool, IReadOnlyList, string?)> GetContainersViaWinRmAsync( + private async Task<(bool, IReadOnlyList, string?)> GetContainersViaScriptAsync( CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(_connection.WinRmContainerListScript)) - { - return (false, [], - "WinRmContainerListScript ist nicht konfiguriert.\n" + - "Beispiel: \"Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty DisplayName\""); - } - string script = WinRmExecutor.ApplyScriptTemplate( - _connection.WinRmContainerListScript, + _connection.ResolveContainerListScript(), containerName: string.Empty, - domainName: _connection.DomainName); + domainName: _connection.DomainName, + sonicHome: _connection.SonicHome); (bool ok, string? output, string? error) = - await _winRm!.RunScriptAsync(script, cancellationToken); + await _scriptRunner!.RunScriptAsync(script, cancellationToken); if (!ok) { - return (false, [], $"WinRM Container-Liste fehlgeschlagen: {error}"); + return (false, [], $"Container-Liste fehlgeschlagen ({_connection.ManagementMode}): {error}"); } List names = (output ?? string.Empty) .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(l => l.Length > 0) + .Where(l => l.Length > 0 && !l.StartsWith("WARNING", StringComparison.OrdinalIgnoreCase)) .ToList(); return (true, names, null); } - private async Task<(bool, string, string?)> RestartViaWinRmAsync( + private async Task<(bool, string, string?)> RestartViaScriptAsync( string containerName, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(_connection.WinRmRestartScript)) - { - return (false, "Neustart fehlgeschlagen", - "WinRmRestartScript ist nicht konfiguriert.\n" + - "Beispiel für Windows-Service: \"Restart-Service -Name 'CT-ZADBService' -Force\"\n" + - "Platzhalter {container} wird durch den Container-Namen ersetzt."); - } - string script = WinRmExecutor.ApplyScriptTemplate( - _connection.WinRmRestartScript, + _connection.ResolveRestartScript(), containerName, - _connection.DomainName); + _connection.DomainName, + _connection.SonicHome); (bool ok, string? output, string? error) = - await _winRm!.RunScriptAsync(script, cancellationToken); + await _scriptRunner!.RunScriptAsync(script, cancellationToken); + + string mode = _connection.ManagementMode.ToString(); if (!ok) { - return (false, "Neustart fehlgeschlagen (WinRM)", - $"Fehler: {error}\nAusgabe: {output}"); + return (false, $"Neustart fehlgeschlagen ({mode})", + $"Fehler: {error}\nAusgabe: {output}\nSonicHome={_connection.SonicHome}"); } await Task.Delay( TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)), cancellationToken); - return (true, "Container neugestartet (WinRM)", + return (true, $"ESB-Container neugestartet ({mode})", $"Ausgabe: {output ?? "(keine)"}"); } - private async Task<(bool, string, string?)> ImportXapiViaWinRmAsync( + private async Task<(bool, string, string?)> ImportXapiViaScriptAsync( string containerName, string xapiSourcePath, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript)) @@ -236,21 +232,22 @@ public sealed class SonicManagementClient : IDisposable _connection.WinRmXapiImportScript, containerName, _connection.DomainName, + _connection.SonicHome, xapiSourcePath); (bool importOk, string? importOut, string? importErr) = - await _winRm!.RunScriptAsync(script, cancellationToken); + await _scriptRunner!.RunScriptAsync(script, cancellationToken); if (!importOk) { - return (false, "XApi-Import fehlgeschlagen (WinRM)", importErr ?? importOut); + return (false, "XApi-Import fehlgeschlagen", importErr ?? importOut); } (bool restartOk, string restartStatus, string? restartDetail) = - await RestartViaWinRmAsync(containerName, cancellationToken); + await RestartViaScriptAsync(containerName, cancellationToken); return restartOk - ? (true, "XApi importiert + Container neugestartet (WinRM)", + ? (true, "XApi importiert + Container neugestartet", $"Import: {importOut} | Restart: {restartDetail}") : (false, restartStatus, restartDetail); } @@ -473,8 +470,14 @@ public sealed class SonicManagementClient : IDisposable ? root.EnumerateArray() : root.ValueKind == JsonValueKind.Object ? new[] { "containers", "data", "items", "result" } - .Where(root.TryGetProperty) - .SelectMany(k => { root.TryGetProperty(k, out JsonElement a); return a.EnumerateArray(); }) + .Where(k => root.TryGetProperty(k, out _)) + .SelectMany(k => + { + root.TryGetProperty(k, out JsonElement a); + return a.ValueKind == JsonValueKind.Array + ? a.EnumerateArray() + : Enumerable.Empty(); + }) : []; foreach (JsonElement el in elements) diff --git a/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs b/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs index f1baefc..dd74c63 100644 --- a/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs +++ b/ZA.CoreService.ESBCertificateManager/Services/WinRmExecutor.cs @@ -5,14 +5,7 @@ using ZA.CoreService.ESBCertificateManager.Models; namespace ZA.CoreService.ESBCertificateManager.Services; /// -/// Führt PowerShell-Befehle via WinRM (Invoke-Command) auf dem Sonic-Server aus. -/// -/// Voraussetzungen auf dem Zielrechner: -/// - WinRM muss aktiviert sein: Enable-PSRemoting -Force -/// - Ausführungsrichtlinie: Set-ExecutionPolicy RemoteSigned -/// -/// Voraussetzungen auf dem App-Rechner (einmalig, als Admin): -/// - Set-Item WSMan:\localhost\Client\TrustedHosts -Value "dekun-painwbdet" +/// Führt PowerShell-Befehle lokal oder via WinRM (Invoke-Command) auf dem Sonic-Server aus. /// public sealed class WinRmExecutor { @@ -23,15 +16,20 @@ public sealed class WinRmExecutor _connection = connection; } - /// - /// Prüft die WinRM-Konnektivität und ob der Sonic-Server per TCP erreichbar ist. - /// public async Task<(bool Success, string? Error)> TestConnectionAsync( CancellationToken cancellationToken = default) { + if (_connection.ManagementMode == SonicManagementMode.LocalCmd) + { + (bool ok, string? output, string? error) = await RunScriptAsync( + "$env:COMPUTERNAME", cancellationToken); + return ok + ? (true, null) + : (false, $"LocalCmd fehlgeschlagen: {error ?? output}"); + } + string host = ExtractHost(_connection.ConnectionUrl); - // TCP-Ping auf WinRM-Port try { using System.Net.Sockets.TcpClient tcp = new(); @@ -44,30 +42,25 @@ public sealed class WinRmExecutor { return (false, $"WinRM-Port {_connection.WinRmPort} auf '{host}' nicht erreichbar: {ex.Message}\n" + - $"Auf dem Zielrechner ausführen: Enable-PSRemoting -Force"); + "Auf dem Zielrechner: Enable-PSRemoting -Force\n" + + "Oder ManagementMode=LocalCmd setzen und die App auf dem Sonic-PC starten."); } - // Kurztest: Hostname zurückgeben - (bool ok, string? output, string? error) = await RunScriptAsync( + (bool sessionOk, _, string? sessionError) = await RunScriptAsync( "$env:COMPUTERNAME", cancellationToken); - return ok + return sessionOk ? (true, null) - : (false, $"WinRM-Verbindung fehlgeschlagen: {error}"); + : (false, $"WinRM-Verbindung fehlgeschlagen: {sessionError}"); } - /// - /// Führt einen Scriptblock auf dem Remote-Rechner aus und gibt Stdout zurück. - /// public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync( string scriptBlock, CancellationToken cancellationToken = default) { - string host = ExtractHost(_connection.ConnectionUrl); - - // Passwort als SecureString – bleibt im PowerShell-Prozess, wird nicht als Argument übergeben - // Stattdessen: Scriptblock über stdin senden - string fullScript = BuildScript(host, scriptBlock); + string fullScript = _connection.ManagementMode == SonicManagementMode.LocalCmd + ? BuildLocalScript(scriptBlock) + : BuildRemoteScript(ExtractHost(_connection.ConnectionUrl), scriptBlock); ProcessStartInfo psi = new() { @@ -88,8 +81,7 @@ public sealed class WinRmExecutor return (false, null, "PowerShell-Prozess konnte nicht gestartet werden."); } - // Skript über stdin – Credentials gehen NICHT als sichtbares Argument durch - await process.StandardInput.WriteAsync(fullScript); + await process.StandardInput.WriteAsync(fullScript.AsMemory(), cancellationToken); process.StandardInput.Close(); using CancellationTokenSource timeoutCts = @@ -106,7 +98,7 @@ public sealed class WinRmExecutor catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } - return (false, null, $"WinRM-Ausführung Timeout nach {_connection.TimeoutSeconds}s."); + return (false, null, $"Ausführung Timeout nach {_connection.TimeoutSeconds}s."); } string stdout = (await stdoutTask).Trim(); @@ -121,9 +113,11 @@ public sealed class WinRmExecutor stderr.Length > 0 ? Truncate(stderr) : $"PowerShell ExitCode={process.ExitCode}"); } - private string BuildScript(string host, string scriptBlock) + private static string BuildLocalScript(string scriptBlock) + => "$ErrorActionPreference = 'Stop'\n" + scriptBlock; + + private string BuildRemoteScript(string host, string scriptBlock) { - // Passwort über Variable, nicht als Argument – verhindert Sichtbarkeit in Prozessliste string escapedPwd = _connection.Password.Replace("'", "''"); string escapedUser = _connection.Username.Replace("'", "''"); string escapedHost = host.Replace("'", "''"); @@ -137,21 +131,17 @@ public sealed class WinRmExecutor "} -ErrorAction Stop"; } - /// - /// Ersetzt Platzhalter in einem konfigurierten WinRM-Script. - /// {container} → Container-Name (einfache Hochkommas werden verdoppelt) - /// {domain} → Domain-Name - /// {xapiPath} → Pfad zur XApi-Quelldatei - /// - public static string ApplyScriptTemplate(string template, string containerName, - string domainName = "", string xapiPath = "") + public static string ApplyScriptTemplate( + string template, + string containerName, + string domainName = "", + string sonicHome = "", + string xapiPath = "") => template - .Replace("{container}", containerName.Replace("'", "''"), - StringComparison.OrdinalIgnoreCase) - .Replace("{domain}", domainName.Replace("'", "''"), - StringComparison.OrdinalIgnoreCase) - .Replace("{xapiPath}", xapiPath.Replace("'", "''"), - StringComparison.OrdinalIgnoreCase); + .Replace("{container}", containerName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase) + .Replace("{domain}", domainName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase) + .Replace("{sonicHome}", sonicHome.Replace("'", "''"), StringComparison.OrdinalIgnoreCase) + .Replace("{xapiPath}", xapiPath.Replace("'", "''"), StringComparison.OrdinalIgnoreCase); private static string ExtractHost(string connectionUrl) { diff --git a/ZA.CoreService.ESBCertificateManager/appsettings.json b/ZA.CoreService.ESBCertificateManager/appsettings.json index dbddb3f..71e2704 100644 --- a/ZA.CoreService.ESBCertificateManager/appsettings.json +++ b/ZA.CoreService.ESBCertificateManager/appsettings.json @@ -10,17 +10,22 @@ "Name": "DE-Test", "DomainName": "proalpha-test", "ConnectionUrl": "tcp://dekun-painwbdet:13070", - "ManagementHttpPort": 8080, - "ApiBasePath": "/api/v1", "Username": "Administrator", "Password": "Administrator", - "TimeoutSeconds": 30, + "ManagementMode": "WinRm", + "SonicHome": "C:\\Sonic\\MQ10.0", + "WinRmPort": 5985, + "TimeoutSeconds": 90, "PostRestartDelaySeconds": 15, - + "ManagementHttpPort": 8080, + "ApiBasePath": "/api/v1", "ContainerListPath": "", "ContainerRestartPath": "", "ContainerStopPath": "", - "ContainerStartPath": "" + "ContainerStartPath": "", + "WinRmRestartScript": "", + "WinRmContainerListScript": "", + "WinRmXapiImportScript": "" } ] }