Fix app startup and enable Sonic ESB restart via remote CMD.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 08:17:35 +02:00
co-authored by Cursor
parent ed8e2822d1
commit c2268f1908
9 changed files with 446 additions and 207 deletions
@@ -7,7 +7,20 @@ public static class AppSettingsLoader
{ {
public static AppSettings Load() 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() IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(basePath) .SetBasePath(basePath)
@@ -1,17 +1,17 @@
[ [
{ {
"Id": 1, "Id": 1,
"Name": "ESB Local A", "Name": "DE-Test ESB Container",
"Environment": "DEV", "Environment": "TEST",
"IsActive": true, "IsActive": true,
"TargetDirectory": "DeploySandbox/target-a", "TargetDirectory": "DeploySandbox/target-esb",
"CertificateFileName": "esb-cert.cer", "CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-a", "ContainerName": "ESB",
"RestartType": "None", "RestartType": "SonicContainer",
"RestartCommand": "", "RestartCommand": "",
"RestartArguments": "", "RestartArguments": "",
"RestartTimeoutSeconds": 30, "RestartTimeoutSeconds": 90,
"SonicConnectionName": "", "SonicConnectionName": "DE-Test",
"XapiSourcePath": "", "XapiSourcePath": "",
"TlsHost": "", "TlsHost": "",
"TlsPort": null, "TlsPort": null,
@@ -21,15 +21,15 @@
}, },
{ {
"Id": 2, "Id": 2,
"Name": "ESB Local B", "Name": "Lokaler CMD-Test (Echo)",
"Environment": "DEV", "Environment": "DEV",
"IsActive": true, "IsActive": true,
"TargetDirectory": "DeploySandbox/target-b", "TargetDirectory": "DeploySandbox/target-cmd",
"CertificateFileName": "esb-cert.cer", "CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-b", "ContainerName": "",
"RestartType": "Command", "RestartType": "Command",
"RestartCommand": "cmd.exe", "RestartCommand": "cmd.exe",
"RestartArguments": "/c echo Restart simulated for sonic-container-b", "RestartArguments": "/c echo Neustart-Simulation OK",
"RestartTimeoutSeconds": 15, "RestartTimeoutSeconds": 15,
"SonicConnectionName": "", "SonicConnectionName": "",
"XapiSourcePath": "", "XapiSourcePath": "",
@@ -38,45 +38,5 @@
"TlsServerName": "", "TlsServerName": "",
"ExpectedFingerprint": null, "ExpectedFingerprint": null,
"SortOrder": 20 "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
} }
] ]
+102 -5
View File
@@ -52,6 +52,7 @@ namespace ZA.CoreService.ESBCertificateManager
private Button btnClose = null!; private Button btnClose = null!;
private Button btnValidate = null!; private Button btnValidate = null!;
private Button btnDeploy = null!; private Button btnDeploy = null!;
private Button btnRestartOnly = null!;
private Button btnCancelRun = null!; private Button btnCancelRun = null!;
private bool isMaximized = false; private bool isMaximized = false;
@@ -1231,6 +1232,11 @@ namespace ZA.CoreService.ESBCertificateManager
btnValidate.Anchor = AnchorStyles.Top | AnchorStyles.Right; btnValidate.Anchor = AnchorStyles.Top | AnchorStyles.Right;
btnValidate.Click += async (_, _) => await RunValidationAsync(); 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 = CreatePrimaryButton("Deployment starten");
btnDeploy.Size = new Size(170, 42); btnDeploy.Size = new Size(170, 42);
btnDeploy.Anchor = AnchorStyles.Top | AnchorStyles.Right; btnDeploy.Anchor = AnchorStyles.Top | AnchorStyles.Right;
@@ -1238,12 +1244,14 @@ namespace ZA.CoreService.ESBCertificateManager
panel.Controls.Add(btnCancelRun); panel.Controls.Add(btnCancelRun);
panel.Controls.Add(btnValidate); panel.Controls.Add(btnValidate);
panel.Controls.Add(btnRestartOnly);
panel.Controls.Add(btnDeploy); panel.Controls.Add(btnDeploy);
panel.Resize += (_, _) => panel.Resize += (_, _) =>
{ {
btnDeploy.Left = panel.Width - btnDeploy.Width; 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; btnCancelRun.Left = btnValidate.Left - btnCancelRun.Width - 12;
}; };
@@ -1377,11 +1385,20 @@ namespace ZA.CoreService.ESBCertificateManager
btnCancelRun.Enabled = _isOperationRunning; btnCancelRun.Enabled = _isOperationRunning;
btnValidate.Enabled = idle && hasCertificate && hasTargets; btnValidate.Enabled = idle && hasCertificate && hasTargets;
btnDeploy.Enabled = idle && hasCertificate && hasSelection; btnDeploy.Enabled = idle && hasCertificate && hasSelection;
if (btnRestartOnly is not null)
{
btnRestartOnly.Enabled = idle && hasSelection;
}
if (dgvTargets is not null) if (dgvTargets is not null)
{ {
dgvTargets.Enabled = idle; dgvTargets.Enabled = idle;
} }
if (btnLoadFromSonic is not null)
{
btnLoadFromSonic.Enabled = idle && _sonicDiscovery.HasConnections;
}
} }
private void SetStatus(string message, bool isError) private void SetStatus(string message, bool isError)
@@ -1464,6 +1481,86 @@ namespace ZA.CoreService.ESBCertificateManager
return Task.CompletedTask; return Task.CompletedTask;
} }
private async Task RunRestartOnlyAsync()
{
if (_isOperationRunning)
{
return;
}
List<DeploymentTarget> 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<TargetProgressUpdate> 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() private async Task RunDeploymentAsync()
{ {
if (_isOperationRunning || _loadedCertificateInfo is null) if (_isOperationRunning || _loadedCertificateInfo is null)
@@ -1680,9 +1777,6 @@ namespace ZA.CoreService.ESBCertificateManager
lblSonicStatus = new Label lblSonicStatus = new Label
{ {
Text = _sonicDiscovery.HasConnections
? "Keine Verbindung konfiguriert"
: "Keine Sonic-Verbindung konfiguriert",
ForeColor = MutedTextColor, ForeColor = MutedTextColor,
Font = new Font("Segoe UI", 8.5f), Font = new Font("Segoe UI", 8.5f),
AutoSize = true, AutoSize = true,
@@ -1696,7 +1790,10 @@ namespace ZA.CoreService.ESBCertificateManager
} }
else 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); card.Controls.Add(sonicLabel);
@@ -15,93 +15,148 @@ public sealed class SonicConnection
/// <summary> /// <summary>
/// Sonic-Broker-/Management-URL im Sonic-Format, z.B. "tcp://dekun-painwbdet:13070". /// 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).
/// </summary> /// </summary>
public required string ConnectionUrl { get; init; } public required string ConnectionUrl { get; init; }
/// <summary>Benutzername für die Management-Konsole.</summary> /// <summary>Benutzername für WinRM / Management-Konsole.</summary>
public required string Username { get; init; } public required string Username { get; init; }
/// <summary>Passwort für die Management-Konsole.</summary> /// <summary>Passwort für WinRM / Management-Konsole.</summary>
public required string Password { get; init; } public required string Password { get; init; }
/// <summary> /// <summary>
/// Management-Modus: HttpApi (REST) oder WinRm (PowerShell Remoting). /// Management-Modus:
/// Standard: WinRm da Sonic 10.x kein HTTP REST API bereitstellt. /// - WinRm: PowerShell Remoting auf dem Sonic-Server (Standard)
/// - LocalCmd: CMD/PowerShell lokal (zum Testen direkt auf dem Sonic-PC)
/// - HttpApi: REST, falls vorhanden
/// </summary> /// </summary>
public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm; public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm;
/// <summary>
/// Installationsroot von Sonic MQ / Management Console, z.B. "C:\Sonic\MQ10.0".
/// Wird für Default-Scripts (stopcontainer/startcontainer) benötigt.
/// </summary>
public string SonicHome { get; init; } = @"C:\Sonic\MQ10.0";
// --------------------------------------------------------------- // ---------------------------------------------------------------
// HTTP REST API (ManagementMode = HttpApi) // HTTP REST API (ManagementMode = HttpApi)
// --------------------------------------------------------------- // ---------------------------------------------------------------
/// <summary>HTTP-Port der Sonic Management Console REST-API (Standard: 8080).</summary>
public int ManagementHttpPort { get; init; } = 8080; public int ManagementHttpPort { get; init; } = 8080;
/// <summary>Präfix für alle REST-API-Pfade (Standard: "/api/v1").</summary>
public string ApiBasePath { get; init; } = "/api/v1"; public string ApiBasePath { get; init; } = "/api/v1";
/// <summary>Konfigurierter Pfad zum Auflisten aller Container. Leer = automatische Erkennung.</summary>
public string ContainerListPath { get; init; } = string.Empty; public string ContainerListPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Neustarten. Platzhalter: {domain}, {container}.</summary>
public string ContainerRestartPath { get; init; } = string.Empty; public string ContainerRestartPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Stoppen. Platzhalter: {domain}, {container}.</summary>
public string ContainerStopPath { get; init; } = string.Empty; public string ContainerStopPath { get; init; } = string.Empty;
/// <summary>Konfigurierter Pfad zum Starten. Platzhalter: {domain}, {container}.</summary>
public string ContainerStartPath { get; init; } = string.Empty; public string ContainerStartPath { get; init; } = string.Empty;
// --------------------------------------------------------------- // ---------------------------------------------------------------
// WinRM / PowerShell Remoting (ManagementMode = WinRm) // WinRM / LocalCmd
// --------------------------------------------------------------- // ---------------------------------------------------------------
/// <summary>
/// WinRM-Port auf dem Zielrechner (Standard: 5985 = HTTP, 5986 = HTTPS).
/// </summary>
public int WinRmPort { get; init; } = 5985; public int WinRmPort { get; init; } = 5985;
/// <summary> /// <summary>
/// PowerShell-Scriptblock zum Neustarten eines Containers. /// PowerShell-Script zum Neustarten. Leer = Default über Sonic bin\stop/startcontainer.bat.
/// Platzhalter: {container} = Container-Name (nicht enkodiert), {domain} = Domain-Name. /// Platzhalter: {container}, {domain}, {sonicHome}
/// 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}'"
/// </summary> /// </summary>
public string WinRmRestartScript { get; init; } = string.Empty; public string WinRmRestartScript { get; init; } = string.Empty;
/// <summary> /// <summary>
/// PowerShell-Scriptblock zum Auflisten aller Container der Domain. /// PowerShell-Script zum Auflisten der Container. Leer = Default (Services + SonicHome).
/// Ausgabe: eine Zeile pro Container-Name.
/// Beispiel: "Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty Name"
/// </summary> /// </summary>
public string WinRmContainerListScript { get; init; } = string.Empty; public string WinRmContainerListScript { get; init; } = string.Empty;
/// <summary>
/// PowerShell-Scriptblock für XApi-Import.
/// Platzhalter: {container}, {xapiPath}.
/// </summary>
public string WinRmXapiImportScript { get; init; } = string.Empty; public string WinRmXapiImportScript { get; init; } = string.Empty;
// ---------------------------------------------------------------
// Gemeinsame Einstellungen
// ---------------------------------------------------------------
/// <summary>Timeout in Sekunden für einzelne API-/Script-Aufrufe (Standard: 60).</summary>
public int TimeoutSeconds { get; init; } = 60; public int TimeoutSeconds { get; init; } = 60;
/// <summary>Wartezeit in Sekunden nach einem Container-Neustart (Standard: 15).</summary>
public int PostRestartDelaySeconds { get; init; } = 15; public int PostRestartDelaySeconds { get; init; } = 15;
/// <summary>Effektives Restart-Script inkl. Default, wenn leer.</summary>
public string ResolveRestartScript()
=> string.IsNullOrWhiteSpace(WinRmRestartScript)
? DefaultRestartScript
: WinRmRestartScript;
/// <summary>Effektives List-Script inkl. Default, wenn leer.</summary>
public string ResolveContainerListScript()
=> string.IsNullOrWhiteSpace(WinRmContainerListScript)
? DefaultContainerListScript
: WinRmContainerListScript;
/// <summary>
/// Default: Sonic Management Console Tools per CMD auf dem Ziel-PC.
/// stopcontainer.bat / startcontainer.bat unter SonicHome\bin.
/// </summary>
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 public enum SonicManagementMode
{ {
/// <summary>HTTP REST API (wenn vom Sonic-Server bereitgestellt).</summary> /// <summary>HTTP REST API (wenn vom Sonic-Server bereitgestellt).</summary>
HttpApi, HttpApi = 0,
/// <summary> /// <summary>
/// PowerShell Remoting (WinRM) Standard für Sonic 10.x auf Windows. /// PowerShell Remoting (WinRM) führt Scripts auf dem Sonic-Server aus.
/// Führt konfigurierte Scriptblöcke via Invoke-Command auf dem Sonic-Server aus.
/// </summary> /// </summary>
WinRm WinRm = 1,
/// <summary>
/// CMD/PowerShell lokal auf diesem PC zum Testen direkt auf dem Sonic-Rechner.
/// </summary>
LocalCmd = 2
} }
@@ -26,6 +26,89 @@ public sealed class DeploymentOrchestrator
IReadOnlyList<DeploymentTarget> selectedTargets) IReadOnlyList<DeploymentTarget> selectedTargets)
=> _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets); => _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets);
public PreflightValidationResult ValidateRestartOnly(IReadOnlyList<DeploymentTarget> selectedTargets)
=> _preflightValidator.ValidateRestartOnly(selectedTargets);
/// <summary>
/// Nur ESB-/Container-Neustart ohne Zertifikatskopieren und ohne TLS-Probe.
/// </summary>
public async Task<DeploymentRunResult> RestartOnlyAsync(
IReadOnlyList<DeploymentTarget> selectedTargets,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken = default)
{
DateTimeOffset startedAt = DateTimeOffset.Now;
List<TargetStepResult> 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<TargetStepResult> RestartTargetOnlyAsync(
DeploymentTarget target,
RunLogger logger,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken)
{
List<string> 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<DeploymentRunResult> RunAsync( public async Task<DeploymentRunResult> RunAsync(
string certificatePath, string certificatePath,
CertificateInfo certificateInfo, CertificateInfo certificateInfo,
@@ -32,13 +32,45 @@ public sealed class PreflightValidator
foreach (DeploymentTarget target in selectedTargets) foreach (DeploymentTarget target in selectedTargets)
{ {
ValidateTarget(result, target); ValidateTarget(result, target, requireDeployPaths: true);
} }
return result; return result;
} }
private static void ValidateTarget(PreflightValidationResult result, DeploymentTarget target) /// <summary>
/// Vorabprüfung nur für ESB-Neustart (ohne Zertifikat / Kopierpfade).
/// </summary>
public PreflightValidationResult ValidateRestartOnly(IReadOnlyList<DeploymentTarget> selectedTargets)
{
PreflightValidationResult result = new();
if (selectedTargets.Count == 0)
{
AddIssue(result, "Bitte mindestens ein Ziel anhaken.");
return result;
}
foreach (DeploymentTarget target in selectedTargets)
{
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)) if (string.IsNullOrWhiteSpace(target.TargetDirectory))
{ {
@@ -49,6 +81,7 @@ public sealed class PreflightValidator
{ {
AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id); AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id);
} }
}
if (target.RestartType == RestartType.Command if (target.RestartType == RestartType.Command
&& string.IsNullOrWhiteSpace(target.RestartCommand)) && string.IsNullOrWhiteSpace(target.RestartCommand))
@@ -75,7 +108,7 @@ public sealed class PreflightValidator
AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id); AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id);
} }
if (string.IsNullOrWhiteSpace(target.TargetDirectory)) if (!requireDeployPaths || string.IsNullOrWhiteSpace(target.TargetDirectory))
{ {
return; return;
} }
@@ -9,19 +9,20 @@ namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary> /// <summary>
/// Verwaltet Sonic ESB Container über die Management Console. /// Verwaltet Sonic ESB Container über die Management Console.
/// ///
/// Modus = WinRm (Standard, Sonic 10.x): /// Modus = WinRm (Standard):
/// PowerShell Remoting (Invoke-Command) auf dem Sonic-Server. /// PowerShell Remoting (Invoke-Command) → CMD stop/startcontainer auf dem Sonic-Server.
/// Erfordert WinRM auf dem Zielrechner: Enable-PSRemoting -Force ///
/// Modus = LocalCmd:
/// Dieselbe CMD/PowerShell-Logik lokal (Test direkt auf dem Sonic-PC).
/// ///
/// Modus = HttpApi: /// Modus = HttpApi:
/// HTTP REST API mit automatischer Pfad-Erkennung. /// HTTP REST API mit automatischer Pfad-Erkennung.
/// Probiert: /mf/rest/v1, /api/v1, /sonic/management, /containers
/// </summary> /// </summary>
public sealed class SonicManagementClient : IDisposable public sealed class SonicManagementClient : IDisposable
{ {
private readonly SonicConnection _connection; private readonly SonicConnection _connection;
private readonly HttpClient? _http; private readonly HttpClient? _http;
private readonly WinRmExecutor? _winRm; private readonly WinRmExecutor? _scriptRunner;
// Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus) // Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus)
private string? _resolvedContainerBasePath; private string? _resolvedContainerBasePath;
@@ -34,13 +35,16 @@ public sealed class SonicManagementClient : IDisposable
"/containers" "/containers"
]; ];
private bool UsesScripts =>
_connection.ManagementMode is SonicManagementMode.WinRm or SonicManagementMode.LocalCmd;
public SonicManagementClient(SonicConnection connection) public SonicManagementClient(SonicConnection connection)
{ {
_connection = connection; _connection = connection;
if (connection.ManagementMode == SonicManagementMode.WinRm) if (UsesScripts)
{ {
_winRm = new WinRmExecutor(connection); _scriptRunner = new WinRmExecutor(connection);
} }
else else
{ {
@@ -77,10 +81,13 @@ public sealed class SonicManagementClient : IDisposable
public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync( public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (_connection.ManagementMode == SonicManagementMode.WinRm) if (UsesScripts)
{ {
(bool ok, string? error) = await _winRm!.TestConnectionAsync(cancellationToken); (bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken);
return (ok, error, ok ? $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}" : null); string modeLabel = _connection.ManagementMode == SonicManagementMode.LocalCmd
? $"LocalCmd (SonicHome={_connection.SonicHome})"
: $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}";
return (ok, error, ok ? modeLabel : null);
} }
try try
@@ -116,9 +123,9 @@ public sealed class SonicManagementClient : IDisposable
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync( public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (_connection.ManagementMode == SonicManagementMode.WinRm) if (UsesScripts)
{ {
return await GetContainersViaWinRmAsync(cancellationToken); return await GetContainersViaScriptAsync(cancellationToken);
} }
return await GetContainersViaHttpAsync(cancellationToken); return await GetContainersViaHttpAsync(cancellationToken);
@@ -131,9 +138,9 @@ public sealed class SonicManagementClient : IDisposable
string containerName, string containerName,
CancellationToken cancellationToken = default) 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); return await RestartViaHttpAsync(containerName, cancellationToken);
@@ -147,83 +154,72 @@ public sealed class SonicManagementClient : IDisposable
string xapiSourcePath, string xapiSourcePath,
CancellationToken cancellationToken = default) 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); return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
// WinRM-Implementierungen // Script-Implementierungen (WinRM / LocalCmd)
// --------------------------------------------------------------- // ---------------------------------------------------------------
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaWinRmAsync( private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaScriptAsync(
CancellationToken cancellationToken) 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( string script = WinRmExecutor.ApplyScriptTemplate(
_connection.WinRmContainerListScript, _connection.ResolveContainerListScript(),
containerName: string.Empty, containerName: string.Empty,
domainName: _connection.DomainName); domainName: _connection.DomainName,
sonicHome: _connection.SonicHome);
(bool ok, string? output, string? error) = (bool ok, string? output, string? error) =
await _winRm!.RunScriptAsync(script, cancellationToken); await _scriptRunner!.RunScriptAsync(script, cancellationToken);
if (!ok) if (!ok)
{ {
return (false, [], $"WinRM Container-Liste fehlgeschlagen: {error}"); return (false, [], $"Container-Liste fehlgeschlagen ({_connection.ManagementMode}): {error}");
} }
List<string> names = (output ?? string.Empty) List<string> names = (output ?? string.Empty)
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(l => l.Length > 0) .Where(l => l.Length > 0 && !l.StartsWith("WARNING", StringComparison.OrdinalIgnoreCase))
.ToList(); .ToList();
return (true, names, null); return (true, names, null);
} }
private async Task<(bool, string, string?)> RestartViaWinRmAsync( private async Task<(bool, string, string?)> RestartViaScriptAsync(
string containerName, CancellationToken cancellationToken) 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( string script = WinRmExecutor.ApplyScriptTemplate(
_connection.WinRmRestartScript, _connection.ResolveRestartScript(),
containerName, containerName,
_connection.DomainName); _connection.DomainName,
_connection.SonicHome);
(bool ok, string? output, string? error) = (bool ok, string? output, string? error) =
await _winRm!.RunScriptAsync(script, cancellationToken); await _scriptRunner!.RunScriptAsync(script, cancellationToken);
string mode = _connection.ManagementMode.ToString();
if (!ok) if (!ok)
{ {
return (false, "Neustart fehlgeschlagen (WinRM)", return (false, $"Neustart fehlgeschlagen ({mode})",
$"Fehler: {error}\nAusgabe: {output}"); $"Fehler: {error}\nAusgabe: {output}\nSonicHome={_connection.SonicHome}");
} }
await Task.Delay( await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)), TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken); cancellationToken);
return (true, "Container neugestartet (WinRM)", return (true, $"ESB-Container neugestartet ({mode})",
$"Ausgabe: {output ?? "(keine)"}"); $"Ausgabe: {output ?? "(keine)"}");
} }
private async Task<(bool, string, string?)> ImportXapiViaWinRmAsync( private async Task<(bool, string, string?)> ImportXapiViaScriptAsync(
string containerName, string xapiSourcePath, CancellationToken cancellationToken) string containerName, string xapiSourcePath, CancellationToken cancellationToken)
{ {
if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript)) if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript))
@@ -236,21 +232,22 @@ public sealed class SonicManagementClient : IDisposable
_connection.WinRmXapiImportScript, _connection.WinRmXapiImportScript,
containerName, containerName,
_connection.DomainName, _connection.DomainName,
_connection.SonicHome,
xapiSourcePath); xapiSourcePath);
(bool importOk, string? importOut, string? importErr) = (bool importOk, string? importOut, string? importErr) =
await _winRm!.RunScriptAsync(script, cancellationToken); await _scriptRunner!.RunScriptAsync(script, cancellationToken);
if (!importOk) if (!importOk)
{ {
return (false, "XApi-Import fehlgeschlagen (WinRM)", importErr ?? importOut); return (false, "XApi-Import fehlgeschlagen", importErr ?? importOut);
} }
(bool restartOk, string restartStatus, string? restartDetail) = (bool restartOk, string restartStatus, string? restartDetail) =
await RestartViaWinRmAsync(containerName, cancellationToken); await RestartViaScriptAsync(containerName, cancellationToken);
return restartOk return restartOk
? (true, "XApi importiert + Container neugestartet (WinRM)", ? (true, "XApi importiert + Container neugestartet",
$"Import: {importOut} | Restart: {restartDetail}") $"Import: {importOut} | Restart: {restartDetail}")
: (false, restartStatus, restartDetail); : (false, restartStatus, restartDetail);
} }
@@ -473,8 +470,14 @@ public sealed class SonicManagementClient : IDisposable
? root.EnumerateArray() ? root.EnumerateArray()
: root.ValueKind == JsonValueKind.Object : root.ValueKind == JsonValueKind.Object
? new[] { "containers", "data", "items", "result" } ? new[] { "containers", "data", "items", "result" }
.Where(root.TryGetProperty) .Where(k => root.TryGetProperty(k, out _))
.SelectMany(k => { root.TryGetProperty(k, out JsonElement a); return a.EnumerateArray(); }) .SelectMany(k =>
{
root.TryGetProperty(k, out JsonElement a);
return a.ValueKind == JsonValueKind.Array
? a.EnumerateArray()
: Enumerable.Empty<JsonElement>();
})
: []; : [];
foreach (JsonElement el in elements) foreach (JsonElement el in elements)
@@ -5,14 +5,7 @@ using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services; namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary> /// <summary>
/// Führt PowerShell-Befehle via WinRM (Invoke-Command) auf dem Sonic-Server aus. /// Führt PowerShell-Befehle lokal oder 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"
/// </summary> /// </summary>
public sealed class WinRmExecutor public sealed class WinRmExecutor
{ {
@@ -23,15 +16,20 @@ public sealed class WinRmExecutor
_connection = connection; _connection = connection;
} }
/// <summary>
/// Prüft die WinRM-Konnektivität und ob der Sonic-Server per TCP erreichbar ist.
/// </summary>
public async Task<(bool Success, string? Error)> TestConnectionAsync( public async Task<(bool Success, string? Error)> TestConnectionAsync(
CancellationToken cancellationToken = default) 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); string host = ExtractHost(_connection.ConnectionUrl);
// TCP-Ping auf WinRM-Port
try try
{ {
using System.Net.Sockets.TcpClient tcp = new(); using System.Net.Sockets.TcpClient tcp = new();
@@ -44,30 +42,25 @@ public sealed class WinRmExecutor
{ {
return (false, return (false,
$"WinRM-Port {_connection.WinRmPort} auf '{host}' nicht erreichbar: {ex.Message}\n" + $"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 sessionOk, _, string? sessionError) = await RunScriptAsync(
(bool ok, string? output, string? error) = await RunScriptAsync(
"$env:COMPUTERNAME", cancellationToken); "$env:COMPUTERNAME", cancellationToken);
return ok return sessionOk
? (true, null) ? (true, null)
: (false, $"WinRM-Verbindung fehlgeschlagen: {error}"); : (false, $"WinRM-Verbindung fehlgeschlagen: {sessionError}");
} }
/// <summary>
/// Führt einen Scriptblock auf dem Remote-Rechner aus und gibt Stdout zurück.
/// </summary>
public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync( public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync(
string scriptBlock, string scriptBlock,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
string host = ExtractHost(_connection.ConnectionUrl); string fullScript = _connection.ManagementMode == SonicManagementMode.LocalCmd
? BuildLocalScript(scriptBlock)
// Passwort als SecureString bleibt im PowerShell-Prozess, wird nicht als Argument übergeben : BuildRemoteScript(ExtractHost(_connection.ConnectionUrl), scriptBlock);
// Stattdessen: Scriptblock über stdin senden
string fullScript = BuildScript(host, scriptBlock);
ProcessStartInfo psi = new() ProcessStartInfo psi = new()
{ {
@@ -88,8 +81,7 @@ public sealed class WinRmExecutor
return (false, null, "PowerShell-Prozess konnte nicht gestartet werden."); return (false, null, "PowerShell-Prozess konnte nicht gestartet werden.");
} }
// Skript über stdin Credentials gehen NICHT als sichtbares Argument durch await process.StandardInput.WriteAsync(fullScript.AsMemory(), cancellationToken);
await process.StandardInput.WriteAsync(fullScript);
process.StandardInput.Close(); process.StandardInput.Close();
using CancellationTokenSource timeoutCts = using CancellationTokenSource timeoutCts =
@@ -106,7 +98,7 @@ public sealed class WinRmExecutor
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } 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(); string stdout = (await stdoutTask).Trim();
@@ -121,9 +113,11 @@ public sealed class WinRmExecutor
stderr.Length > 0 ? Truncate(stderr) : $"PowerShell ExitCode={process.ExitCode}"); 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 escapedPwd = _connection.Password.Replace("'", "''");
string escapedUser = _connection.Username.Replace("'", "''"); string escapedUser = _connection.Username.Replace("'", "''");
string escapedHost = host.Replace("'", "''"); string escapedHost = host.Replace("'", "''");
@@ -137,21 +131,17 @@ public sealed class WinRmExecutor
"} -ErrorAction Stop"; "} -ErrorAction Stop";
} }
/// <summary> public static string ApplyScriptTemplate(
/// Ersetzt Platzhalter in einem konfigurierten WinRM-Script. string template,
/// {container} → Container-Name (einfache Hochkommas werden verdoppelt) string containerName,
/// {domain} → Domain-Name string domainName = "",
/// {xapiPath} → Pfad zur XApi-Quelldatei string sonicHome = "",
/// </summary> string xapiPath = "")
public static string ApplyScriptTemplate(string template, string containerName,
string domainName = "", string xapiPath = "")
=> template => template
.Replace("{container}", containerName.Replace("'", "''"), .Replace("{container}", containerName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
StringComparison.OrdinalIgnoreCase) .Replace("{domain}", domainName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{domain}", domainName.Replace("'", "''"), .Replace("{sonicHome}", sonicHome.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
StringComparison.OrdinalIgnoreCase) .Replace("{xapiPath}", xapiPath.Replace("'", "''"), StringComparison.OrdinalIgnoreCase);
.Replace("{xapiPath}", xapiPath.Replace("'", "''"),
StringComparison.OrdinalIgnoreCase);
private static string ExtractHost(string connectionUrl) private static string ExtractHost(string connectionUrl)
{ {
@@ -10,17 +10,22 @@
"Name": "DE-Test", "Name": "DE-Test",
"DomainName": "proalpha-test", "DomainName": "proalpha-test",
"ConnectionUrl": "tcp://dekun-painwbdet:13070", "ConnectionUrl": "tcp://dekun-painwbdet:13070",
"ManagementHttpPort": 8080,
"ApiBasePath": "/api/v1",
"Username": "Administrator", "Username": "Administrator",
"Password": "Administrator", "Password": "Administrator",
"TimeoutSeconds": 30, "ManagementMode": "WinRm",
"SonicHome": "C:\\Sonic\\MQ10.0",
"WinRmPort": 5985,
"TimeoutSeconds": 90,
"PostRestartDelaySeconds": 15, "PostRestartDelaySeconds": 15,
"ManagementHttpPort": 8080,
"ApiBasePath": "/api/v1",
"ContainerListPath": "", "ContainerListPath": "",
"ContainerRestartPath": "", "ContainerRestartPath": "",
"ContainerStopPath": "", "ContainerStopPath": "",
"ContainerStartPath": "" "ContainerStartPath": "",
"WinRmRestartScript": "",
"WinRmContainerListScript": "",
"WinRmXapiImportScript": ""
} }
] ]
} }