Simplify app to certificate recognition and MfApi restart only.

Remove XApi, WinRM, LocalCmd, HTTP API, TLS probe, SQL deploy model, and deploy UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 11:06:10 +02:00
co-authored by Cursor
parent 7a9cdb6ecf
commit 12eebfc78a
23 changed files with 735 additions and 5189 deletions
@@ -1,167 +0,0 @@
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
/// <summary>
/// Schreibt Deployment-Lauf-Ergebnisse in die SQL-Datenbank.
/// Tabellen: dbo.DeploymentRuns (GUID-PK) + dbo.DeploymentTargetResults.
/// Ist kein ConnectionString konfiguriert, werden alle Operationen still übersprungen.
/// </summary>
public sealed class SqlRunLogger
{
private readonly string? _connectionString;
public SqlRunLogger(string? connectionString)
{
_connectionString = string.IsNullOrWhiteSpace(connectionString)
? null
: connectionString;
}
public bool IsEnabled => _connectionString is not null;
/// <summary>
/// Legt einen neuen Lauf-Datensatz an.
/// </summary>
public async Task BeginRunAsync(
Guid runId,
DateTimeOffset startedAt,
string certificateFilePath,
string certificateFingerprint,
CancellationToken cancellationToken = default)
{
if (_connectionString is null) return;
const string sql = """
INSERT INTO dbo.DeploymentRuns
(Id, StartedAtUtc, SourceFile, SourceFingerprint, StartedBy, MachineName, OverallStatus)
VALUES
(@Id, @StartedAtUtc, @SourceFile, @SourceFingerprint, @StartedBy, @MachineName, N'Running');
""";
await using SqlConnection conn = new(_connectionString);
await conn.OpenAsync(cancellationToken);
await using SqlCommand cmd = new(sql, conn);
cmd.Parameters.AddWithValue("@Id", runId);
cmd.Parameters.AddWithValue("@StartedAtUtc", startedAt.UtcDateTime);
cmd.Parameters.AddWithValue("@SourceFile", Path.GetFileName(certificateFilePath));
cmd.Parameters.AddWithValue("@SourceFingerprint", certificateFingerprint);
cmd.Parameters.AddWithValue("@StartedBy", Environment.UserName);
cmd.Parameters.AddWithValue("@MachineName", Environment.MachineName);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
/// <summary>
/// Aktualisiert den Lauf-Datensatz mit Endzeitpunkt und Gesamtstatus.
/// </summary>
public async Task CompleteRunAsync(
Guid runId,
DateTimeOffset finishedAt,
bool overallSuccess,
int successCount,
int totalCount,
CancellationToken cancellationToken = default)
{
if (_connectionString is null) return;
string status = overallSuccess
? "Success"
: (successCount > 0 ? "PartialFailure" : "Failure");
const string sql = """
UPDATE dbo.DeploymentRuns
SET FinishedAtUtc = @FinishedAtUtc,
OverallStatus = @OverallStatus
WHERE Id = @Id;
""";
await using SqlConnection conn = new(_connectionString);
await conn.OpenAsync(cancellationToken);
await using SqlCommand cmd = new(sql, conn);
cmd.Parameters.AddWithValue("@Id", runId);
cmd.Parameters.AddWithValue("@FinishedAtUtc", finishedAt.UtcDateTime);
cmd.Parameters.AddWithValue("@OverallStatus", status);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
/// <summary>
/// Schreibt das Ergebnis eines einzelnen Deployment-Ziels.
/// </summary>
public async Task WriteTargetResultAsync(
Guid runId,
TargetStepResult step,
CancellationToken cancellationToken = default)
{
if (_connectionString is null) return;
string status = step.Success ? "Success" : "Failure";
const string sql = """
INSERT INTO dbo.DeploymentTargetResults
(DeploymentRunId, TargetId, TargetName,
StartedAtUtc, FinishedAtUtc,
CopySucceeded, RestartSucceeded, TlsSucceeded,
ObservedFingerprint, Status, ErrorMessage)
VALUES
(@RunId, @TargetId, @TargetName,
@StartedAtUtc, @FinishedAtUtc,
@CopySucceeded, @RestartSucceeded, @TlsSucceeded,
@ObservedFingerprint, @Status, @ErrorMessage);
""";
await using SqlConnection conn = new(_connectionString);
await conn.OpenAsync(cancellationToken);
await using SqlCommand cmd = new(sql, conn);
cmd.Parameters.AddWithValue("@RunId", runId);
cmd.Parameters.AddWithValue("@TargetId", step.TargetId);
cmd.Parameters.AddWithValue("@TargetName", step.TargetName);
cmd.Parameters.AddWithValue("@StartedAtUtc", step.StartedAt.UtcDateTime);
cmd.Parameters.AddWithValue("@FinishedAtUtc", step.FinishedAt.UtcDateTime);
cmd.Parameters.AddWithValue("@CopySucceeded", step.CopySucceeded);
cmd.Parameters.AddWithValue("@RestartSucceeded", step.RestartSucceeded);
cmd.Parameters.AddWithValue("@TlsSucceeded", step.TlsSucceeded);
cmd.Parameters.AddWithValue("@ObservedFingerprint", (object?)step.ObservedFingerprint ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Status", status);
cmd.Parameters.AddWithValue("@ErrorMessage", step.Success ? DBNull.Value : (object?)(step.Detail ?? step.StatusText));
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
/// <summary>
/// Persistiert ein vollständiges <see cref="DeploymentRunResult"/> in einem einzigen Aufruf.
/// </summary>
public async Task PersistRunResultAsync(
DeploymentRunResult runResult,
CancellationToken cancellationToken = default)
{
if (_connectionString is null) return;
await BeginRunAsync(
runResult.RunId,
runResult.StartedAt,
runResult.CertificateFilePath,
runResult.CertificateFingerprint,
cancellationToken);
foreach (TargetStepResult step in runResult.TargetResults)
{
await WriteTargetResultAsync(runResult.RunId, step, cancellationToken);
}
int successCount = runResult.TargetResults.Count(r => r.Success);
await CompleteRunAsync(
runResult.RunId,
runResult.FinishedAt,
runResult.OverallSuccess,
successCount,
runResult.TargetResults.Count,
cancellationToken);
}
}
@@ -1,111 +0,0 @@
using Microsoft.Data.SqlClient;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
public sealed class SqlTargetRepository : ITargetRepository
{
private readonly string _connectionString;
public SqlTargetRepository(string connectionString)
{
_connectionString = connectionString;
}
public string SourceDescription => "SQL Server (dbo.DeploymentTargets)";
public async Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_connectionString))
{
throw new InvalidOperationException("Es ist keine SQL-Verbindungszeichenfolge konfiguriert.");
}
const string sql = """
SELECT
Id,
Name,
Environment,
IsActive,
CertificateTargetPath,
CertificateFileName,
ContainerName,
RestartType,
RestartCommand,
RestartArguments,
RestartTimeoutSeconds,
SonicConnectionName,
XapiSourcePath,
TlsHost,
TlsPort,
TlsServerName,
ExpectedFingerprint,
SortOrder
FROM dbo.DeploymentTargets
WHERE IsActive = 1
ORDER BY SortOrder, Name;
""";
List<DeploymentTarget> targets = [];
await using SqlConnection connection = new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlCommand command = new(sql, connection);
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
int id = reader.GetOrdinal("Id");
int name = reader.GetOrdinal("Name");
int environment = reader.GetOrdinal("Environment");
int isActive = reader.GetOrdinal("IsActive");
int certificateTargetPath = reader.GetOrdinal("CertificateTargetPath");
int certificateFileName = reader.GetOrdinal("CertificateFileName");
int containerName = reader.GetOrdinal("ContainerName");
int restartType = reader.GetOrdinal("RestartType");
int restartCommand = reader.GetOrdinal("RestartCommand");
int restartArguments = reader.GetOrdinal("RestartArguments");
int restartTimeoutSeconds = reader.GetOrdinal("RestartTimeoutSeconds");
int sonicConnectionName = reader.GetOrdinal("SonicConnectionName");
int xapiSourcePath = reader.GetOrdinal("XapiSourcePath");
int tlsHost = reader.GetOrdinal("TlsHost");
int tlsPort = reader.GetOrdinal("TlsPort");
int tlsServerName = reader.GetOrdinal("TlsServerName");
int expectedFingerprint = reader.GetOrdinal("ExpectedFingerprint");
int sortOrder = reader.GetOrdinal("SortOrder");
while (await reader.ReadAsync(cancellationToken))
{
RestartType parsedRestartType = Enum.TryParse(
reader.GetString(restartType),
ignoreCase: true,
out RestartType parsed)
? parsed
: RestartType.None;
targets.Add(new DeploymentTarget
{
Id = reader.GetInt32(id),
Name = reader.GetString(name),
Environment = reader.GetString(environment),
IsActive = reader.GetBoolean(isActive),
TargetDirectory = reader.GetString(certificateTargetPath),
CertificateFileName = reader.GetString(certificateFileName),
ContainerName = reader.GetString(containerName),
RestartType = parsedRestartType,
RestartCommand = reader.IsDBNull(restartCommand) ? string.Empty : reader.GetString(restartCommand),
RestartArguments = reader.GetString(restartArguments),
RestartTimeoutSeconds = reader.GetInt32(restartTimeoutSeconds),
SonicConnectionName = reader.GetString(sonicConnectionName),
XapiSourcePath = reader.GetString(xapiSourcePath),
TlsHost = reader.GetString(tlsHost),
TlsPort = reader.IsDBNull(tlsPort) ? null : reader.GetInt32(tlsPort),
TlsServerName = reader.GetString(tlsServerName),
ExpectedFingerprint = reader.IsDBNull(expectedFingerprint) ? null : reader.GetString(expectedFingerprint),
SortOrder = reader.GetInt32(sortOrder)
});
}
return targets;
}
}
@@ -11,34 +11,8 @@ public static class TargetRepositoryFactory
string samplePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, settings.SampleTargetsPath));
if (settings.UseOfflineSampleData)
{
ITargetRepository jsonRepo = new JsonTargetRepository(samplePath);
_ = await jsonRepo.GetActiveTargetsAsync(cancellationToken);
return (jsonRepo, $"Ziele geladen aus {jsonRepo.SourceDescription}.");
}
if (string.IsNullOrWhiteSpace(settings.ConnectionString))
{
return OfflineSample(samplePath, "Keine SQL-Verbindung konfiguriert Offline-Sample wird verwendet.");
}
try
{
SqlTargetRepository sqlRepo = new(settings.ConnectionString);
_ = await sqlRepo.GetActiveTargetsAsync(cancellationToken);
return (sqlRepo, $"Ziele geladen aus {sqlRepo.SourceDescription}.");
}
catch (Exception ex)
{
return OfflineSample(
samplePath,
$"SQL nicht erreichbar ({ex.Message}). Offline-Sample wird verwendet.");
}
}
private static (ITargetRepository Repository, string LoadMessage) OfflineSample(
string samplePath,
string message)
=> (new JsonTargetRepository(samplePath), message);
}
@@ -4,39 +4,8 @@
"Name": "DE-Test / ct-ZADBService",
"Environment": "TEST",
"IsActive": true,
"TargetDirectory": "DeploySandbox/target-esb",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "ct-ZADBService",
"RestartType": "SonicContainer",
"RestartCommand": "",
"RestartArguments": "",
"RestartTimeoutSeconds": 120,
"SonicConnectionName": "DE-Test",
"XapiSourcePath": "",
"TlsHost": "",
"TlsPort": null,
"TlsServerName": "",
"ExpectedFingerprint": null,
"SortOrder": 10
},
{
"Id": 2,
"Name": "Lokaler CMD-Test (Echo)",
"Environment": "DEV",
"IsActive": false,
"TargetDirectory": "DeploySandbox/target-cmd",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "",
"RestartType": "Command",
"RestartCommand": "cmd.exe",
"RestartArguments": "/c echo Neustart-Simulation OK",
"RestartTimeoutSeconds": 15,
"SonicConnectionName": "",
"XapiSourcePath": "",
"TlsHost": "",
"TlsPort": null,
"TlsServerName": "",
"ExpectedFingerprint": null,
"SortOrder": 20
}
]
File diff suppressed because it is too large Load Diff
@@ -2,16 +2,8 @@ namespace ZA.CoreService.ESBCertificateManager.Models;
public sealed class AppSettings
{
public string ConnectionString { get; set; } = string.Empty;
public bool UseOfflineSampleData { get; set; } = true;
public string SampleTargetsPath { get; set; } = "Data/targets.sample.json";
public string LogDirectory { get; set; } = "Logs";
public int TlsTimeoutSeconds { get; set; } = 8;
public int TlsRetryCount { get; set; } = 2;
/// <summary>
/// Verbindungskonfigurationen für Progress Sonic ESB Management Instanzen.
/// Jeder Eintrag entspricht einer Sonic-Domain (z.B. einer Umgebung oder Tochtergesellschaft).
/// </summary>
public List<SonicConnection> SonicConnections { get; set; } = [];
}
@@ -7,8 +7,6 @@ public sealed class DeploymentRunResult
public bool OverallSuccess => TargetResults.All(r => r.Success);
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset FinishedAt { get; init; }
public string CertificateFilePath { get; init; } = string.Empty;
public string CertificateFingerprint { get; init; } = string.Empty;
}
public sealed class TargetStepResult
@@ -18,13 +16,8 @@ public sealed class TargetStepResult
public bool Success { get; init; }
public required string StatusText { get; init; }
public string? Detail { get; init; }
public IReadOnlyList<string> Steps { get; init; } = [];
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset FinishedAt { get; init; }
public bool CopySucceeded { get; init; }
public bool RestartSucceeded { get; init; }
public bool TlsSucceeded { get; init; }
public string? ObservedFingerprint { get; init; }
}
public sealed class ValidationIssue
@@ -38,3 +31,5 @@ public sealed class PreflightValidationResult
public bool IsValid => Issues.Count == 0;
public List<ValidationIssue> Issues { get; } = [];
}
public readonly record struct TargetProgressUpdate(int TargetId, string StatusText, bool? SuccessHint);
@@ -4,64 +4,9 @@ public sealed class DeploymentTarget
{
public int Id { get; init; }
public required string Name { get; init; }
public required string Environment { get; init; }
public string Environment { get; init; } = "TEST";
public bool IsActive { get; init; } = true;
public required string TargetDirectory { get; init; }
public required string CertificateFileName { get; init; }
/// <summary>Name des Sonic-ESB-Containers (z.B. "sonic-container-a").</summary>
public string ContainerName { get; init; } = string.Empty;
public RestartType RestartType { get; init; } = RestartType.None;
// --- Command-basierter Neustart ---
public string RestartCommand { get; init; } = string.Empty;
public string RestartArguments { get; init; } = string.Empty;
public int RestartTimeoutSeconds { get; init; } = 60;
// --- Sonic-ESB-Management-Neustart ---
/// <summary>
/// Referenz auf den Namen einer <see cref="SonicConnection"/> in AppSettings.
/// Pflichtfeld bei RestartType = SonicContainer oder SonicContainerWithXapi.
/// </summary>
public string SonicConnectionName { get; init; } = string.Empty;
/// <summary>
/// Pfad zur XApi-Ressourcendatei (.xml/.zip), die vor dem Neustart importiert wird.
/// Pflichtfeld bei RestartType = SonicContainerWithXapi.
/// </summary>
public string XapiSourcePath { get; init; } = string.Empty;
// --- TLS-Probe nach Deployment ---
public string TlsHost { get; init; } = string.Empty;
public int? TlsPort { get; init; }
/// <summary>
/// Hostname, der im TLS-Handshake als ServerName (SNI) verwendet wird.
/// Wichtig wenn TlsHost eine IP-Adresse ist, das Zertifikat aber einen DNS-Namen trägt.
/// Ist leer, wird TlsHost als ServerName verwendet.
/// </summary>
public string TlsServerName { get; init; } = string.Empty;
/// <summary>
/// Erwarteter SHA-256-Fingerprint des Zertifikats nach dem Deployment (ohne Trennzeichen).
/// Wenn gesetzt, schlägt der TLS-Probe fehl wenn der Fingerprint abweicht.
/// </summary>
public string? ExpectedFingerprint { get; init; }
public int SortOrder { get; init; }
}
public enum RestartType
{
None = 0,
/// <summary>Neustart über einen lokalen Betriebssystem-Prozess (RestartCommand).</summary>
Command = 1,
/// <summary>Container-Neustart wie in der Sonic Management Console (stop/startcontainer bzw. SMC-API).</summary>
SonicContainer = 2,
/// <summary>XApi-Ressourcen importieren und danach Container neu starten (Sonic ESB).</summary>
SonicContainerWithXapi = 3
}
@@ -1,398 +1,22 @@
namespace ZA.CoreService.ESBCertificateManager.Models;
/// <summary>
/// Verbindungskonfiguration für eine Progress Sonic ESB Management Instanz.
/// Name = Verbindungs-Alias (hier oft gleich ContainerName, z.B. "DE-Test").
/// DomainName = Sonic-Domain (z.B. "proalpha-test").
/// Container wird in KnownContainers / DeploymentTarget.ContainerName geführt (z.B. "DE-Test").
/// SMC-/Domain-Manager-Verbindung für MfApi-Neustart.
/// Name = Alias (z.B. DE-Test), ContainerName separat (z.B. ct-ZADBService).
/// </summary>
public sealed class SonicConnection
{
/// <summary>
/// Alias der Verbindung; referenziert von <see cref="DeploymentTarget.SonicConnectionName"/>.
/// </summary>
public required string Name { get; init; }
/// <summary>Sonic-Domain, z.B. "proalpha-test" (nicht der Containername).</summary>
public required string DomainName { get; init; }
/// <summary>
/// Dieselbe Broker-/Management-URL wie in der Sonic Management Console (SMC),
/// z.B. "tcp://dekun-painwbdet:13070". Keine separate Domain-Console nötig.
/// </summary>
public required string ConnectionUrl { get; init; }
/// <summary>Login wie in der Sonic Management Console (nicht für WinRM).</summary>
public required string Username { get; init; }
/// <summary>Passwort wie in der Sonic Management Console (nicht für WinRM).</summary>
public required string Password { get; init; }
/// <summary>
/// Windows-Konto für WinRM (<c>Invoke-Command -Credential</c>).
/// Leer = aktueller Prozess-Benutzer ohne explizite Credentials.
/// Nicht mit <see cref="Username"/>/<see cref="Password"/> (Sonic SMC) verwechseln.
/// </summary>
public string WinRmUsername { get; init; } = string.Empty;
/// <summary>Windows-Passwort für WinRM; nur relevant wenn <see cref="WinRmUsername"/> gesetzt ist.</summary>
public string WinRmPassword { get; init; } = string.Empty;
/// <summary>
/// MfApi = Sonic MF Management Runtime API über ConnectionUrl + SMC-Logins (Standard);
/// WinRm / LocalCmd = optionaler Fallback über stopcontainer/startcontainer;
/// HttpApi = REST (falls vorhanden).
/// </summary>
/// <summary>
/// Standard: MfApi = Management Application API (wie SMC):
/// JMSConnectorClient + MFProxyFactory.createAgentProxy + IAgentProxy.restart.
/// Laut CX-Messenger-Doku-Index: Docs2017/api/mgmt_api (nicht stopcontainer.bat).
/// LocalCmd nur wenn Server-Scripts existieren (bei reiner SMC oft nicht der Fall).
/// </summary>
public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.MfApi;
/// <summary>
/// Effektiver Modus: bei WinRm und lokalem ConnectionUrl-Host wird LocalCmd erzwungen.
/// MfApi bleibt unverändert (nutzt Domain-Manager-Verbindung).
/// </summary>
public SonicManagementMode EffectiveManagementMode
=> ManagementMode == SonicManagementMode.WinRm && IsConnectionHostLocal(ConnectionUrl)
? SonicManagementMode.LocalCmd
: ManagementMode;
/// <summary>True wenn WinRm konfiguriert war, aber wegen lokalem Host auf LocalCmd umgestellt wurde.</summary>
public bool IsLocalCmdAutoForced
=> ManagementMode == SonicManagementMode.WinRm
&& EffectiveManagementMode == SonicManagementMode.LocalCmd;
/// <summary>Anzeigetext für UI (inkl. Auto-Erkennung).</summary>
public string ManagementModeDisplay
=> IsLocalCmdAutoForced
? "LocalCmd (Host lokal erkannt)"
: EffectiveManagementMode switch
{
SonicManagementMode.MfApi => "MfApi (SMC Management Application API)",
SonicManagementMode.LocalCmd => "LocalCmd (nur wenn stopcontainer.bat existiert)",
_ => EffectiveManagementMode.ToString()
};
/// <summary>
/// True wenn der Host aus ConnectionUrl dieser Maschine entspricht
/// (localhost / 127.0.0.1 / ::1 / Computername).
/// </summary>
public static bool IsConnectionHostLocal(string? connectionUrl)
{
if (string.IsNullOrWhiteSpace(connectionUrl))
{
return false;
}
string host;
try
{
host = new Uri(connectionUrl).Host;
}
catch
{
host = connectionUrl.Trim();
}
if (string.IsNullOrWhiteSpace(host))
{
return false;
}
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase)
|| host.Equals("::1", StringComparison.OrdinalIgnoreCase)
|| host.Equals("[::1]", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string machine = Environment.MachineName;
if (host.Equals(machine, StringComparison.OrdinalIgnoreCase)
|| host.StartsWith(machine + ".", StringComparison.OrdinalIgnoreCase))
{
return true;
}
try
{
string dnsName = System.Net.Dns.GetHostName();
if (!string.IsNullOrWhiteSpace(dnsName)
&& (host.Equals(dnsName, StringComparison.OrdinalIgnoreCase)
|| host.StartsWith(dnsName + ".", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
catch
{
// DNS optional
}
return false;
}
/// <summary>
/// Sonic-/MQ-/ESB-Installationsroot, z.B. "C:\Sonic\MQ10.0" (Client-JARs oft unter lib).
/// Bei ESB-Home: ggf. MfClientLibPath auf MQ\lib setzen.
/// </summary>
public string SonicHome { get; init; } = @"C:\Sonic\MQ10.0";
/// <summary>
/// Optional: JRE/JDK-Home (z.B. C:\Sonic\MQ10.0\jre) oder Pfad zu java.exe.
/// Leer = automatische Suche (JAVA_HOME/JRE_HOME, setenv.bat, rekursiv unter SonicHome, Registry, …).
/// </summary>
public string SonicHome { get; init; } = @"C:\DEV\MQ10.0";
public string JavaHome { get; init; } = string.Empty;
/// <summary>
/// Optionaler direkter Pfad zu java.exe (überschreibt JavaHome wenn gesetzt).
/// </summary>
public string JavaPath { get; init; } = string.Empty;
/// <summary>
/// Optionaler Pfad zu Sonic-Client-JARs für MfApi (mgmt_client.jar etc.).
/// Leer = SonicHome\lib, MQ_HOME\lib, ESB_HOME\lib bzw. nested MQ*/lib.
/// </summary>
public string MfClientLibPath { get; init; } = string.Empty;
/// <summary>
/// Container-Namen (kurz), z.B. ["DE-Test"]. Nicht die Domain.
/// </summary>
public List<string> KnownContainers { get; init; } = [];
public int ManagementHttpPort { get; init; } = 8080;
public string ApiBasePath { get; init; } = "/api/v1";
public string ContainerListPath { get; init; } = string.Empty;
public string ContainerRestartPath { get; init; } = string.Empty;
public string ContainerStopPath { get; init; } = string.Empty;
public string ContainerStartPath { get; init; } = string.Empty;
public int WinRmPort { get; init; } = 5985;
public string WinRmRestartScript { get; init; } = string.Empty;
public string WinRmContainerListScript { get; init; } = string.Empty;
public string WinRmXapiImportScript { get; init; } = string.Empty;
public int TimeoutSeconds { get; init; } = 120;
public int PostRestartDelaySeconds { get; init; } = 20;
public string ResolveRestartScript()
=> string.IsNullOrWhiteSpace(WinRmRestartScript)
? DefaultRestartScript
: WinRmRestartScript;
public string ResolveContainerListScript()
=> string.IsNullOrWhiteSpace(WinRmContainerListScript)
? DefaultContainerListScript
: WinRmContainerListScript;
/// <summary>
/// Offizieller MF-Container-Neustart laut Aurea CX Messenger Doku:
/// SonicHome\bin\stopcontainer.bat Domain.Container
/// SonicHome\bin\startcontainer.bat Domain.Container
/// Danach Prozess-Verifikation (alte PIDs weg, neue PIDs da).
/// </summary>
public const string DefaultRestartScript =
"""
$ErrorActionPreference = 'Stop'
$sonicHome = '{sonicHome}'
$domain = '{domain}'
$container = '{container}'
$bin = Join-Path $sonicHome 'bin'
$stopBat = Join-Path $bin 'stopcontainer.bat'
$startBat = Join-Path $bin 'startcontainer.bat'
# Kurzname = Container (z.B. DE-Test), Full = Domain.Container (z.B. proalpha-test.DE-Test)
$shortName = if ($container -like '*.*') { ($container -split '\.', 2)[1] } else { $container }
$fullName = if ($container -like '*.*') { $container } else { "$domain.$container" }
$markers = @($shortName, $fullName, "$domain.$shortName") | Select-Object -Unique
function Get-ContainerPids {
$pids = @()
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$cmd = $_.CommandLine
if (-not $cmd) { return $false }
if ($_.Name -notmatch 'java|javaw|sonic') { return $false }
foreach ($m in $markers) {
if ($cmd -like "*$m*") { return $true }
}
return $false
} |
ForEach-Object { $pids += [int]$_.ProcessId }
return @($pids | Select-Object -Unique)
}
function Wait-PidsGone([int[]]$pids, [int]$seconds) {
$deadline = (Get-Date).AddSeconds($seconds)
while ((Get-Date) -lt $deadline) {
$alive = @($pids | Where-Object { Get-Process -Id $_ -ErrorAction SilentlyContinue })
if ($alive.Count -eq 0) { return $true }
Start-Sleep -Seconds 2
}
return $false
}
function Wait-NewPids([int[]]$oldPids, [int]$seconds) {
$deadline = (Get-Date).AddSeconds($seconds)
while ((Get-Date) -lt $deadline) {
$now = @(Get-ContainerPids)
$fresh = @($now | Where-Object { $oldPids -notcontains $_ })
if ($fresh.Count -gt 0) { return ,$fresh }
Start-Sleep -Seconds 2
}
return ,@()
}
function Invoke-ContainerBat([string]$bat, [string]$name) {
if (-not (Test-Path -LiteralPath $bat)) {
throw "Sonic BAT fehlt: $bat (SonicHome pruefen)"
}
$arg = '/c "' + $bat + '" "' + $name + '"'
$p = Start-Process -FilePath 'cmd.exe' -ArgumentList $arg -Wait -PassThru -NoNewWindow
return [int]$p.ExitCode
}
Write-Output "INFO:Domain=$domain Container=$shortName Full=$fullName"
Write-Output "INFO:RestartVia=stopcontainer/startcontainer (MF Container)"
$before = @(Get-ContainerPids)
Write-Output "INFO:PIDsBefore=$($before -join ',')"
if ($before.Count -eq 0) {
Write-Output "WARN:No running Java/Sonic process for '$shortName' - starting anyway"
}
# 1) Offiziell: stopcontainer.bat Domain.Container (danach Kurzname als Fallback)
$stopOk = $false
foreach ($n in @($fullName, $shortName)) {
Write-Output "INFO:stopcontainer $n"
$code = Invoke-ContainerBat -bat $stopBat -name $n
Write-Output "INFO:stopcontainer ExitCode=$code Name=$n"
if ($code -eq 0) { $stopOk = $true; break }
}
if (-not $stopOk) {
Write-Output "WARN:stopcontainer non-zero; will force-stop remaining PIDs if any"
}
Start-Sleep -Seconds 3
$still = @(Get-ContainerPids)
# 2) Force-Stop falls BAT den Prozess nicht beendet (sonst kein echter Restart in SMC)
if ($still.Count -gt 0) {
Write-Output "INFO:Force-Stop PIDs=$($still -join ',')"
foreach ($procId in $still) {
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
}
}
if ($before.Count -gt 0) {
if (-not (Wait-PidsGone -pids $before -seconds 45)) {
throw "Container process still running after stop (PIDs=$($before -join ','))"
}
Write-Output "INFO:Old PIDs gone"
}
Start-Sleep -Seconds 3
# 3) Offiziell: startcontainer.bat Domain.Container
$started = $false
foreach ($n in @($fullName, $shortName)) {
Write-Output "INFO:startcontainer $n"
$code = Invoke-ContainerBat -bat $startBat -name $n
Write-Output "INFO:startcontainer ExitCode=$code Name=$n"
if ($code -eq 0) { $started = $true; break }
}
if (-not $started) {
throw "startcontainer failed for $fullName / $shortName"
}
$after = @(Wait-NewPids -oldPids $before -seconds 60)
if ($after.Count -eq 0) {
$any = @(Get-ContainerPids)
if ($any.Count -eq 0) {
throw "After start no process for container '$shortName'. Check SMC / SonicHome / ContainerName."
}
Write-Output "INFO:PIDsAfter=$($any -join ',')"
}
else {
Write-Output "INFO:PIDsAfter=$($after -join ',')"
}
Write-Output "OK:ContainerRestartVerified Domain=$domain Container=$shortName"
""";
public const string DefaultContainerListScript =
"""
$ErrorActionPreference = 'Continue'
$sonicHome = '{sonicHome}'
$domain = '{domain}'
$names = New-Object System.Collections.Generic.List[string]
function Add-Name([string]$n) {
if ([string]::IsNullOrWhiteSpace($n)) { return }
$n = $n.Trim()
if (-not $names.Contains($n)) { [void]$names.Add($n) }
}
if (-not (Test-Path -LiteralPath $sonicHome)) {
Write-Output "WARN:SonicHomeNichtGefunden:$sonicHome"
}
else {
Write-Output "INFO:SonicHomeOk:$sonicHome Domain=$domain"
Get-ChildItem -LiteralPath $sonicHome -Directory -ErrorAction SilentlyContinue |
ForEach-Object {
if ($_.Name -like '*.cache') {
Add-Name ($_.Name -replace '\.cache$', '')
}
Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '*.cache' } |
ForEach-Object { Add-Name ($_.Name -replace '\.cache$', '') }
}
Get-ChildItem -LiteralPath $sonicHome -Recurse -Filter 'container.xml' -File -ErrorAction SilentlyContinue |
Select-Object -First 40 |
ForEach-Object {
Add-Name (Split-Path $_.DirectoryName -Leaf)
}
}
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { $_.CommandLine -match 'sonic|mf\.framework|container\.xml' } |
ForEach-Object {
if ($_.CommandLine -match '([A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+)\.cache') {
Add-Name $Matches[1]
}
elseif ($_.CommandLine -match '\\([A-Za-z0-9_\-]+)\\container\.xml') {
Add-Name $Matches[1]
}
}
# Kurzform Domain.Container Container
foreach ($n in @($names.ToArray())) {
if ($n -like '*.*') {
$parts = $n -split '\.', 2
if ($parts.Count -eq 2) { Add-Name $parts[1] }
}
}
if ($names.Count -eq 0) {
Write-Output "WARN:KeineContainerGefunden Domain=$domain SonicHome=$sonicHome"
}
$names | Sort-Object -Unique
""";
}
public enum SonicManagementMode
{
HttpApi = 0,
WinRm = 1,
LocalCmd = 2,
/// <summary>Sonic MF Management API über Domain-Manager (ConnectionUrl + SMC-Credentials).</summary>
MfApi = 3
}
@@ -1,98 +0,0 @@
using System.Security.Cryptography;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class CertificateDeployer
{
public Task<(bool Success, string Status, string? Detail, string? BackupPath)> DeployAsync(
string sourceCertificatePath,
DeploymentTarget target,
CancellationToken cancellationToken = default)
{
return Task.Run(() => Deploy(sourceCertificatePath, target, cancellationToken), cancellationToken);
}
private static (bool Success, string Status, string? Detail, string? BackupPath) Deploy(
string sourceCertificatePath,
DeploymentTarget target,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (!File.Exists(sourceCertificatePath))
{
return (false, "Quelle fehlt", $"Quelldatei nicht gefunden: {sourceCertificatePath}", null);
}
string targetDirectory = PathResolver.ResolvePath(target.TargetDirectory);
Directory.CreateDirectory(targetDirectory);
string destinationPath = Path.Combine(targetDirectory, target.CertificateFileName);
string? backupPath = null;
try
{
if (File.Exists(destinationPath))
{
backupPath = $"{destinationPath}.bak-{DateTime.Now:yyyyMMddHHmmss}";
File.Copy(destinationPath, backupPath, overwrite: false);
}
cancellationToken.ThrowIfCancellationRequested();
File.Copy(sourceCertificatePath, destinationPath, overwrite: true);
string sourceHash = ComputeSha256(sourceCertificatePath);
string destHash = ComputeSha256(destinationPath);
if (!string.Equals(sourceHash, destHash, StringComparison.OrdinalIgnoreCase))
{
RestoreFromBackupOrDelete(destinationPath, backupPath);
return (false, "Hash-Fehler", "SHA-256 von Quelle und Ziel stimmen nicht überein. Rollback ausgeführt.", backupPath);
}
return (true, "Kopiert", $"Ziel: {destinationPath}", backupPath);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
try
{
if (backupPath is not null && File.Exists(backupPath) && File.Exists(destinationPath))
{
File.Copy(backupPath, destinationPath, overwrite: true);
}
}
catch
{
// Rollback best-effort
}
return (false, "Kopierfehler", ex.Message, backupPath);
}
}
private static void RestoreFromBackupOrDelete(string destinationPath, string? backupPath)
{
if (backupPath is not null && File.Exists(backupPath))
{
File.Copy(backupPath, destinationPath, overwrite: true);
return;
}
if (File.Exists(destinationPath))
{
File.Delete(destinationPath);
}
}
public static string ComputeSha256(string filePath)
{
using FileStream stream = File.OpenRead(filePath);
byte[] hash = SHA256.HashData(stream);
return Convert.ToHexString(hash);
}
}
@@ -1,37 +1,25 @@
using ZA.CoreService.ESBCertificateManager.Data;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Nur Container-Neustart (kein Deploy, kein TLS, kein XApi).
/// </summary>
public sealed class DeploymentOrchestrator
{
private readonly CertificateDeployer _deployer = new();
private readonly RestartExecutor _restartExecutor;
private readonly TlsCertificateProbe _tlsProbe;
private readonly PreflightValidator _preflightValidator = new();
private readonly SqlRunLogger _sqlRunLogger;
private readonly AppSettings _settings;
public DeploymentOrchestrator(AppSettings settings)
{
_settings = settings;
_tlsProbe = new TlsCertificateProbe(settings.TlsTimeoutSeconds, settings.TlsRetryCount);
_restartExecutor = new RestartExecutor(settings.SonicConnections);
_sqlRunLogger = new SqlRunLogger(settings.ConnectionString);
}
public PreflightValidationResult ValidatePreflight(
string? certificatePath,
CertificateInfo? certificateInfo,
IReadOnlyList<DeploymentTarget> 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,
@@ -41,223 +29,43 @@ public sealed class DeploymentOrchestrator
List<TargetStepResult> results = [];
using RunLogger logger = new(_settings.LogDirectory);
logger.Write($"Neustart-only gestartet für {selectedTargets.Count} Ziel(e).");
logger.Write($"Neustart 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));
progress?.Report(new TargetProgressUpdate(target.Id, $"Neustart {target.ContainerName}…", null));
(bool restartOk, string restartStatus, string? restartDetail) =
(bool ok, string status, string? detail) =
await _restartExecutor.ExecuteAsync(target, cancellationToken);
RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail);
logger.Write($"[{target.Name}] {status} | {detail}");
progress?.Report(new TargetProgressUpdate(target.Id, status, ok));
TargetStepResult targetResult = new()
results.Add(new TargetStepResult
{
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(
string certificatePath,
CertificateInfo certificateInfo,
IReadOnlyList<DeploymentTarget> selectedTargets,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken = default)
{
DateTimeOffset startedAt = DateTimeOffset.Now;
List<TargetStepResult> results = [];
using RunLogger logger = new(_settings.LogDirectory);
logger.Write($"Deployment gestartet für {selectedTargets.Count} Ziel(e). Zertifikat={Path.GetFileName(certificatePath)}");
foreach (DeploymentTarget target in selectedTargets)
{
cancellationToken.ThrowIfCancellationRequested();
results.Add(await RunTargetAsync(target, certificatePath, certificateInfo, logger, progress, cancellationToken));
}
DateTimeOffset finishedAt = DateTimeOffset.Now;
DeploymentRunResult runResult = new()
{
TargetResults = results,
StartedAt = startedAt,
FinishedAt = finishedAt,
CertificateFilePath = certificatePath,
CertificateFingerprint = certificateInfo.FingerprintSha256
};
logger.Write(
$"Deployment beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}");
// Ergebnis in SQL-Datenbank protokollieren (wenn ConnectionString konfiguriert)
try
{
await _sqlRunLogger.PersistRunResultAsync(runResult, cancellationToken);
}
catch (Exception ex)
{
logger.Write($"SQL-Protokollierung fehlgeschlagen (nicht kritisch): {ex.Message}");
}
return runResult;
}
private async Task<TargetStepResult> RunTargetAsync(
DeploymentTarget target,
string certificatePath,
CertificateInfo certificateInfo,
RunLogger logger,
IProgress<TargetProgressUpdate>? progress,
CancellationToken cancellationToken)
{
List<string> steps = [];
DateTimeOffset targetStart = DateTimeOffset.Now;
progress?.Report(new TargetProgressUpdate(target.Id, "Läuft…", false));
(bool copyOk, string copyStatus, string? copyDetail, _) =
await _deployer.DeployAsync(certificatePath, target, cancellationToken);
RecordStep(steps, logger, target.Name, "Deploy", copyStatus, copyDetail);
if (!copyOk)
{
return Fail(target, copyStatus, copyDetail, steps, targetStart, progress);
}
progress?.Report(new TargetProgressUpdate(target.Id, copyStatus, false));
(bool restartOk, string restartStatus, string? restartDetail) =
await _restartExecutor.ExecuteAsync(target, cancellationToken);
RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail);
if (!restartOk)
{
return Fail(target, restartStatus, restartDetail, steps, targetStart, progress,
copySucceeded: copyOk);
}
progress?.Report(new TargetProgressUpdate(target.Id, restartStatus, false));
(bool tlsOk, string tlsStatus, string? tlsDetail, string? observedFingerprint) = await _tlsProbe.ProbeAsync(
target,
certificateInfo.FingerprintSha256,
cancellationToken);
RecordStep(steps, logger, target.Name, "TLS", tlsStatus, tlsDetail);
bool success = tlsOk;
string finalStatus = success
? (string.IsNullOrWhiteSpace(target.TlsHost) ? "Erfolg" : tlsStatus)
: tlsStatus;
TargetStepResult targetResult = new()
{
TargetId = target.Id,
TargetName = target.Name,
Success = success,
StatusText = finalStatus,
Detail = tlsDetail,
Steps = steps,
StartedAt = targetStart,
FinishedAt = DateTimeOffset.Now,
CopySucceeded = copyOk,
RestartSucceeded = restartOk,
TlsSucceeded = tlsOk,
ObservedFingerprint = observedFingerprint
};
progress?.Report(new TargetProgressUpdate(target.Id, finalStatus, success));
return targetResult;
}
private static void RecordStep(
List<string> steps,
RunLogger logger,
string targetName,
string phase,
string status,
string? detail)
{
steps.Add($"{status}: {detail}");
logger.Write($"[{targetName}] {phase}: {status} | {detail}");
}
private static TargetStepResult Fail(
DeploymentTarget target,
string status,
string? detail,
List<string> steps,
DateTimeOffset startedAt,
IProgress<TargetProgressUpdate>? progress,
bool copySucceeded = false,
bool restartSucceeded = false)
{
progress?.Report(new TargetProgressUpdate(target.Id, status, false));
return new TargetStepResult
{
TargetId = target.Id,
TargetName = target.Name,
Success = false,
Success = ok,
StatusText = status,
Detail = detail,
Steps = steps,
StartedAt = targetStart,
FinishedAt = DateTimeOffset.Now
});
}
DateTimeOffset finishedAt = DateTimeOffset.Now;
DeploymentRunResult runResult = new()
{
TargetResults = results,
StartedAt = startedAt,
FinishedAt = DateTimeOffset.Now,
CopySucceeded = copySucceeded,
RestartSucceeded = restartSucceeded,
TlsSucceeded = false
FinishedAt = finishedAt
};
logger.Write(
$"Neustart beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}");
return runResult;
}
}
public readonly record struct TargetProgressUpdate(int TargetId, string StatusText, bool? SuccessHint);
@@ -4,136 +4,37 @@ namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class PreflightValidator
{
public PreflightValidationResult Validate(
string? certificatePath,
CertificateInfo? certificateInfo,
IReadOnlyList<DeploymentTarget> selectedTargets)
{
PreflightValidationResult result = new();
if (string.IsNullOrWhiteSpace(certificatePath) || !File.Exists(certificatePath))
{
AddIssue(result, "Es ist keine gültige Zertifikatsdatei ausgewählt.");
}
if (certificateInfo is null)
{
AddIssue(result, "Zertifikatsmetadaten sind nicht geladen.");
}
else if (!certificateInfo.IsCurrentlyValid)
{
AddIssue(result, "Das geladene Zertifikat ist abgelaufen oder ungültig.");
}
if (selectedTargets.Count == 0)
{
AddIssue(result, "Bitte mindestens ein Ziel anhaken.");
}
foreach (DeploymentTarget target in selectedTargets)
{
ValidateTarget(result, target, requireDeployPaths: true);
}
return result;
}
/// <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.");
result.Issues.Add(new ValidationIssue { Message = "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))
{
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
&& string.IsNullOrWhiteSpace(target.RestartCommand))
{
AddIssue(result, $"Ziel '{target.Name}': RestartCommand fehlt bei RestartType=Command.", target.Id);
}
if (target.RestartType is RestartType.SonicContainer or RestartType.SonicContainerWithXapi)
{
if (string.IsNullOrWhiteSpace(target.ContainerName))
{
AddIssue(result, $"Ziel '{target.Name}': ContainerName fehlt bei RestartType={target.RestartType}.", target.Id);
result.Issues.Add(new ValidationIssue
{
Message = $"Ziel '{target.Name}': ContainerName fehlt.",
TargetId = target.Id
});
}
if (string.IsNullOrWhiteSpace(target.SonicConnectionName))
{
AddIssue(result, $"Ziel '{target.Name}': SonicConnectionName fehlt bei RestartType={target.RestartType}.", target.Id);
}
}
if (target.RestartType == RestartType.SonicContainerWithXapi
&& string.IsNullOrWhiteSpace(target.XapiSourcePath))
{
AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id);
}
if (!requireDeployPaths || string.IsNullOrWhiteSpace(target.TargetDirectory))
{
return;
}
try
{
string directory = PathResolver.ResolvePath(target.TargetDirectory);
Directory.CreateDirectory(directory);
string probeFile = Path.Combine(directory, $".write-probe-{Guid.NewGuid():N}");
File.WriteAllText(probeFile, "ok");
File.Delete(probeFile);
}
catch (Exception ex)
{
AddIssue(result, $"Ziel '{target.Name}': Verzeichnis nicht beschreibbar ({ex.Message}).", target.Id);
}
}
private static void AddIssue(PreflightValidationResult result, string message, int? targetId = null)
{
result.Issues.Add(new ValidationIssue
{
TargetId = targetId,
Message = message
Message = $"Ziel '{target.Name}': SonicConnectionName fehlt.",
TargetId = target.Id
});
}
}
return result;
}
}
@@ -1,4 +1,3 @@
using System.Diagnostics;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
@@ -12,26 +11,13 @@ public sealed class RestartExecutor
_sonicConnections = sonicConnections;
}
public Task<(bool Success, string Status, string? Detail)> ExecuteAsync(
public async Task<(bool Success, string Status, string? Detail)> ExecuteAsync(
DeploymentTarget target,
CancellationToken cancellationToken = default)
=> target.RestartType switch
{
RestartType.None => Task.FromResult<(bool, string, string?)>((true, "Neustart übersprungen", "RestartType=None")),
RestartType.Command => ExecuteCommandAsync(target, cancellationToken),
RestartType.SonicContainer => ExecuteSonicRestartAsync(target, importXapi: false, cancellationToken),
RestartType.SonicContainerWithXapi => ExecuteSonicRestartAsync(target, importXapi: true, cancellationToken),
_ => Task.FromResult<(bool, string, string?)>((false, "Unbekannter RestartType", $"RestartType={target.RestartType}"))
};
private async Task<(bool Success, string Status, string? Detail)> ExecuteSonicRestartAsync(
DeploymentTarget target,
bool importXapi,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(target.ContainerName))
{
return (false, "Sonic-Neustart fehlgeschlagen", $"Ziel '{target.Name}': ContainerName fehlt.");
return (false, "Neustart fehlgeschlagen", $"Ziel '{target.Name}': ContainerName fehlt.");
}
SonicConnection? connection = _sonicConnections
@@ -40,111 +26,10 @@ public sealed class RestartExecutor
if (connection is null)
{
return (false, "Sonic-Verbindung nicht gefunden",
$"Ziel '{target.Name}': SonicConnection '{target.SonicConnectionName}' ist nicht in AppSettings konfiguriert.");
$"Ziel '{target.Name}': SonicConnection '{target.SonicConnectionName}' fehlt in appsettings.");
}
using SonicManagementClient client = new(connection);
if (importXapi)
{
if (string.IsNullOrWhiteSpace(target.XapiSourcePath))
{
return (false, "XApi-Import fehlgeschlagen",
$"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.");
}
return await client.ImportXapiAndRestartAsync(target.ContainerName, target.XapiSourcePath, cancellationToken);
}
return await client.RestartContainerAsync(target.ContainerName, cancellationToken);
}
private async Task<(bool Success, string Status, string? Detail)> ExecuteCommandAsync(
DeploymentTarget target,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(target.RestartCommand))
{
return (true, "Neustart übersprungen", "RestartType=None");
}
int timeoutSeconds = Math.Clamp(target.RestartTimeoutSeconds, 1, 600);
ProcessStartInfo startInfo = new()
{
FileName = target.RestartCommand,
Arguments = target.RestartArguments ?? string.Empty,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
try
{
using Process process = new() { StartInfo = startInfo };
if (!process.Start())
{
return (false, "Neustart fehlgeschlagen", "Prozess konnte nicht gestartet werden.");
}
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return (false, "Neustart Timeout", $"Timeout nach {timeoutSeconds}s.");
}
string detail = BuildDetail(process.ExitCode, await stdoutTask, await stderrTask);
return process.ExitCode == 0
? (true, "Neustart ok", detail)
: (false, "Neustart fehlgeschlagen", detail);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return (false, "Neustart fehlgeschlagen", ex.Message);
}
}
private static string BuildDetail(int exitCode, string stdout, string stderr)
{
string detail = $"ExitCode={exitCode}";
stdout = Truncate(stdout).Trim();
if (!string.IsNullOrWhiteSpace(stdout))
{
detail += "; out=" + stdout;
}
stderr = Truncate(stderr).Trim();
if (!string.IsNullOrWhiteSpace(stderr))
{
detail += "; err=" + stderr;
}
return detail;
}
private static string Truncate(string value, int max = 400)
{
if (string.IsNullOrEmpty(value) || value.Length <= max)
{
return value;
}
return value[..max] + "…";
}
}
@@ -1,245 +0,0 @@
using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Neustart über Sonic-Server-Scripts:
/// stopcontainer.bat / startcontainer.bat
/// Diese liegen nur in einer vollen MQ/ESB-Server-Installation nicht in einer
/// reinen Sonic Management Console (SMC/Client).
/// </summary>
public sealed class SonicBinRestartExecutor
{
private readonly SonicConnection _connection;
public SonicBinRestartExecutor(SonicConnection connection)
{
_connection = connection;
}
public (bool Ok, string? Error, string? Detail) Probe()
{
string sonicHome = _connection.SonicHome?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(sonicHome))
{
return (false, "SonicHome ist leer in appsettings setzen.", null);
}
if (!Directory.Exists(sonicHome))
{
return (false, $"SonicHome existiert nicht: '{sonicHome}'", null);
}
(string? stop, string? start, string searchNote) = LocateContainerScripts(sonicHome);
if (stop is null || start is null)
{
string binDir = Path.Combine(sonicHome, "bin");
string binListing = DescribeDirectory(binDir);
bool looksLikeSmcOnly = Directory.Exists(Path.Combine(sonicHome, "lib"))
&& !File.Exists(Path.Combine(binDir, "stopcontainer.bat"));
string why = looksLikeSmcOnly
? "Das sieht nach einer Sonic Management Console / Client-Installation aus "
+ "(lib vorhanden, aber keine Server-Scripts). "
+ "stopcontainer.bat gibt es nur auf dem Sonic-SERVER, nicht in der reinen SMC."
: "Server-Scripts wurden unter SonicHome nicht gefunden.";
return (false,
why + $" Gesucht unter '{sonicHome}'.",
$"{searchNote}\nInhalt von bin: {binListing}\n"
+ "Lösung A: SonicHome auf den Server-Installationspfad setzen (dort wo stopcontainer.bat liegt).\n"
+ "Lösung B: App lässt automatisch MfApi/SMC-Verbindung versuchen (ConnectionUrl + Login).");
}
return (true, null, $"stop={stop}; start={start}");
}
public async Task<(bool Success, string Status, string? Detail)> RestartAsync(
string containerName,
CancellationToken cancellationToken = default)
{
(bool probeOk, string? probeError, string? probeDetail) = Probe();
if (!probeOk)
{
return (false, "Neustart fehlgeschlagen (SonicBin)",
$"{probeError}\n{probeDetail}");
}
string sonicHome = _connection.SonicHome.Trim();
(string? stopBat, string? startBat, _) = LocateContainerScripts(sonicHome);
string bin = Path.GetDirectoryName(stopBat!)!;
string shortName = containerName.Contains('.')
? containerName[(containerName.IndexOf('.') + 1)..]
: containerName;
string fullName = containerName.Contains('.')
? containerName
: $"{_connection.DomainName}.{containerName}";
StringBuilder log = new();
log.AppendLine($"SonicHome={sonicHome}");
log.AppendLine($"stop={stopBat}");
log.AppendLine($"start={startBat}");
log.AppendLine($"Container={fullName} (kurz={shortName})");
(bool stopOk, string stopOut, string stopErr, int stopCode) =
await RunBatAsync(stopBat!, fullName, bin, cancellationToken);
log.AppendLine($"STOP ExitCode={stopCode}");
if (stopOut.Length > 0) log.AppendLine("STOP out: " + Truncate(stopOut));
if (stopErr.Length > 0) log.AppendLine("STOP err: " + Truncate(stopErr));
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
(bool startOk, string startOut, string startErr, int startCode) =
await RunBatAsync(startBat!, fullName, bin, cancellationToken);
log.AppendLine($"START ExitCode={startCode}");
if (startOut.Length > 0) log.AppendLine("START out: " + Truncate(startOut));
if (startErr.Length > 0) log.AppendLine("START err: " + Truncate(startErr));
if (startCode != 0
&& !string.Equals(shortName, fullName, StringComparison.OrdinalIgnoreCase))
{
log.AppendLine($"Retry START mit Kurzname '{shortName}'…");
(_, startOut, startErr, startCode) =
await RunBatAsync(startBat!, shortName, bin, cancellationToken);
log.AppendLine($"START(short) ExitCode={startCode}");
if (startOut.Length > 0) log.AppendLine("START out: " + Truncate(startOut));
if (startErr.Length > 0) log.AppendLine("START err: " + Truncate(startErr));
}
await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken);
if (startCode != 0)
{
return (false, "Neustart fehlgeschlagen (SonicBin)",
$"startcontainer ExitCode={startCode} (stop ExitCode={stopCode}).\n{log}");
}
return (true, $"Container '{fullName}' neugestartet (SonicBin)", log.ToString());
}
/// <summary>
/// Sucht stop/startcontainer.bat unter SonicHome\bin und rekursiv (max. Tiefe 4).
/// </summary>
public static (string? StopBat, string? StartBat, string Note) LocateContainerScripts(string sonicHome)
{
List<string> candidates = [];
void AddDir(string? dir)
{
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir) && !candidates.Contains(dir))
{
candidates.Add(dir);
}
}
AddDir(Path.Combine(sonicHome, "bin"));
AddDir(Path.Combine(sonicHome, "MQ_HOME", "bin"));
AddDir(Path.Combine(sonicHome, "MQ", "bin"));
try
{
string? parent = Directory.GetParent(sonicHome)?.FullName;
if (parent is not null)
{
foreach (string child in Directory.EnumerateDirectories(parent))
{
AddDir(Path.Combine(child, "bin"));
}
}
}
catch
{
// ignore
}
// Rekursiv nach Dateinamen suchen
try
{
foreach (string file in Directory.EnumerateFiles(sonicHome, "stopcontainer.bat", SearchOption.AllDirectories)
.Take(20))
{
AddDir(Path.GetDirectoryName(file));
}
}
catch
{
// ignore permission issues
}
foreach (string dir in candidates)
{
string stop = Path.Combine(dir, "stopcontainer.bat");
string start = Path.Combine(dir, "startcontainer.bat");
if (File.Exists(stop) && File.Exists(start))
{
return (stop, start, $"Scripts gefunden in '{dir}'");
}
}
return (null, null, $"Keine Scripts in {candidates.Count} geprüften bin-Ordnern.");
}
private static string DescribeDirectory(string dir)
{
if (!Directory.Exists(dir))
{
return "(Ordner existiert nicht)";
}
try
{
string[] names = Directory.GetFileSystemEntries(dir)
.Select(Path.GetFileName)
.Where(n => n is not null)
.Cast<string>()
.OrderBy(n => n)
.Take(25)
.ToArray();
return names.Length == 0 ? "(leer)" : string.Join(", ", names);
}
catch (Exception ex)
{
return $"(nicht lesbar: {ex.Message})";
}
}
private static async Task<(bool Started, string StdOut, string StdErr, int ExitCode)> RunBatAsync(
string batPath,
string argument,
string workingDirectory,
CancellationToken cancellationToken)
{
ProcessStartInfo psi = new()
{
FileName = "cmd.exe",
Arguments = $"/c \"\"{batPath}\" \"{argument}\"\"",
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using Process process = new() { StartInfo = psi };
if (!process.Start())
{
return (false, string.Empty, "cmd.exe konnte nicht gestartet werden.", -1);
}
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
return (true, (await stdoutTask).Trim(), (await stderrTask).Trim(), process.ExitCode);
}
private static string Truncate(string value, int max = 500)
=> value.Length <= max ? value : value[..max] + "…";
}
@@ -2,10 +2,6 @@ using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Fragt konfigurierte Sonic-Verbindungen nach Containern ab
/// und merged Remote-Treffer mit KnownContainers / Sample-Zielen.
/// </summary>
public sealed class SonicContainerDiscovery
{
private readonly IReadOnlyList<SonicConnection> _connections;
@@ -29,228 +25,84 @@ public sealed class SonicContainerDiscovery
if (connection is null)
{
return SonicDiscoveryResult.Failed(connectionName,
$"Sonic-Verbindung '{connectionName}' ist nicht in AppSettings konfiguriert.");
$"Sonic-Verbindung '{connectionName}' fehlt in appsettings.");
}
using SonicManagementClient client = new(connection);
(bool reachable, string? pingError, _) = await client.CheckConnectionAsync(cancellationToken);
(bool reachable, string? pingError, string? resolvedPath) = await client.CheckConnectionAsync(cancellationToken);
List<string> diagnostics = [];
List<string> remoteContainers = [];
bool listOk = false;
string? listError = null;
List<string> diagnostics = [];
if (!reachable)
{
// Trotzdem KnownContainers nutzen Neustart kann über SonicBin (stop/startcontainer) gehen.
diagnostics.Add($"Management-Ping: {pingError}");
if (connection.KnownContainers.Count == 0
&& !knownTargets.Any(t =>
string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(t.ContainerName)))
{
return SonicDiscoveryResult.Failed(connectionName,
$"Management-Konsole nicht erreichbar: {pingError}");
}
diagnostics.Add($"Ping: {pingError}");
}
else
{
(listOk, IReadOnlyList<string> remoteNames, listError) =
(bool listOk, IReadOnlyList<string> remoteNames, string? listError) =
await client.GetContainersAsync(cancellationToken);
foreach (string line in remoteNames)
if (listOk)
{
if (line.StartsWith("WARN:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("INFO:", StringComparison.OrdinalIgnoreCase))
remoteContainers.AddRange(remoteNames.Where(n =>
!n.StartsWith("WARN:", StringComparison.OrdinalIgnoreCase)
&& !n.StartsWith("INFO:", StringComparison.OrdinalIgnoreCase)));
}
else if (!string.IsNullOrWhiteSpace(listError))
{
diagnostics.Add(line);
continue;
}
remoteContainers.Add(line);
}
if (!listOk)
{
diagnostics.Add($"Listen-Fehler: {listError}");
remoteContainers = [];
diagnostics.Add(listError);
}
}
// Fallback: explizit in appsettings hinterlegte Container
List<string> mergedNames = MergeContainerNames(
remoteContainers,
connection.KnownContainers,
knownTargets
List<string> merged = [];
foreach (string name in remoteContainers
.Concat(connection.KnownContainers)
.Concat(knownTargets
.Where(t => string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase))
.Select(t => t.ContainerName)
.Where(n => !string.IsNullOrWhiteSpace(n)));
.Select(t => t.ContainerName))
.Where(n => !string.IsNullOrWhiteSpace(n)))
{
if (!merged.Any(m => string.Equals(m, name, StringComparison.OrdinalIgnoreCase)))
{
merged.Add(name);
}
}
List<DeploymentTarget> discovered = BuildTargets(connection, mergedNames, knownTargets);
if (merged.Count == 0)
{
return SonicDiscoveryResult.Failed(connectionName,
string.Join(" | ", diagnostics.DefaultIfEmpty("Keine Container gefunden.")));
}
string? hint = BuildHint(connection, remoteContainers.Count, connection.KnownContainers.Count, diagnostics, listOk, listError);
int id = 1;
List<DeploymentTarget> targets = merged.Select(name =>
{
DeploymentTarget? known = knownTargets.FirstOrDefault(t =>
string.Equals(t.ContainerName, name, StringComparison.OrdinalIgnoreCase)
&& string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase));
return new DeploymentTarget
{
Id = known?.Id ?? id++,
Name = known?.Name ?? $"{connection.Name} / {name}",
Environment = known?.Environment ?? "TEST",
IsActive = true,
ContainerName = name,
SonicConnectionName = connection.Name,
SortOrder = known?.SortOrder ?? id * 10
};
}).ToList();
return new SonicDiscoveryResult
{
ConnectionName = connectionName,
DomainName = connection.DomainName,
Success = true,
ErrorMessage = hint,
DiscoveredTargets = discovered,
RawContainerNames = remoteContainers,
ResolvedPath = resolvedPath
ErrorMessage = diagnostics.Count == 0 ? null : string.Join(" | ", diagnostics),
DiscoveredTargets = targets,
RawContainerNames = remoteContainers
};
}
private static List<string> MergeContainerNames(
IEnumerable<string> remote,
IEnumerable<string> knownConfigured,
IEnumerable<string> knownFromTargets)
{
List<string> result = [];
foreach (string name in remote.Concat(knownConfigured).Concat(knownFromTargets))
{
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
if (!result.Any(existing => ContainerNamesMatch(existing, name, domain: null)))
{
result.Add(name.Trim());
}
}
return result;
}
private static string? BuildHint(
SonicConnection connection,
int remoteCount,
int knownConfigCount,
List<string> diagnostics,
bool listOk,
string? listError)
{
List<string> parts = [];
if (!listOk && !string.IsNullOrWhiteSpace(listError))
{
parts.Add(listError!);
}
if (remoteCount == 0)
{
parts.Add(
$"Remote hat 0 Container geliefert (Domain '{connection.DomainName}', SonicHome='{connection.SonicHome}').");
if (knownConfigCount > 0)
{
parts.Add($"Fallback: {knownConfigCount} KnownContainers aus appsettings.");
}
else
{
parts.Add("Tipp: KnownContainers in appsettings setzen oder SonicHome korrigieren.");
}
}
foreach (string d in diagnostics.Take(3))
{
parts.Add(d);
}
return parts.Count == 0 ? null : string.Join(" ", parts);
}
private static List<DeploymentTarget> BuildTargets(
SonicConnection connection,
IReadOnlyList<string> containerNames,
IReadOnlyList<DeploymentTarget> knownTargets)
{
List<DeploymentTarget> result = [];
int syntheticId = -1;
foreach (string containerName in containerNames)
{
DeploymentTarget? existing = knownTargets.FirstOrDefault(t =>
string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase)
&& ContainerNamesMatch(t.ContainerName, containerName, connection.DomainName));
if (existing is not null)
{
if (!result.Any(r => r.Id == existing.Id))
{
result.Add(existing);
}
}
else
{
result.Add(new DeploymentTarget
{
Id = syntheticId--,
Name = $"{connection.Name} / {containerName}",
Environment = connection.DomainName,
IsActive = true,
TargetDirectory = string.Empty,
CertificateFileName = string.Empty,
ContainerName = containerName,
RestartType = RestartType.SonicContainer,
SonicConnectionName = connection.Name,
SortOrder = result.Count * 10
});
}
}
foreach (DeploymentTarget known in knownTargets)
{
if (!string.Equals(known.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
bool alreadyAdded = result.Any(r =>
ContainerNamesMatch(r.ContainerName, known.ContainerName, connection.DomainName)
|| r.Id == known.Id);
if (!alreadyAdded)
{
result.Add(known);
}
}
return result;
}
internal static bool ContainerNamesMatch(string? a, string? b, string? domain)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
{
return false;
}
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
{
return true;
}
string aShort = StripDomainPrefix(a, domain);
string bShort = StripDomainPrefix(b, domain);
return string.Equals(aShort, bShort, StringComparison.OrdinalIgnoreCase);
}
private static string StripDomainPrefix(string name, string? domain)
{
if (!string.IsNullOrWhiteSpace(domain)
&& name.StartsWith(domain + ".", StringComparison.OrdinalIgnoreCase))
{
return name[(domain.Length + 1)..];
}
int dot = name.IndexOf('.');
return dot > 0 ? name[(dot + 1)..] : name;
}
}
public sealed class SonicDiscoveryResult
@@ -259,10 +111,14 @@ public sealed class SonicDiscoveryResult
public string DomainName { get; init; } = string.Empty;
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public string? ResolvedPath { get; init; }
public IReadOnlyList<DeploymentTarget> DiscoveredTargets { get; init; } = [];
public IReadOnlyList<string> RawContainerNames { get; init; } = [];
public static SonicDiscoveryResult Failed(string connectionName, string error)
=> new() { ConnectionName = connectionName, Success = false, ErrorMessage = error };
=> new()
{
ConnectionName = connectionName,
Success = false,
ErrorMessage = error
};
}
@@ -1,339 +1,67 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Verwaltet Sonic ESB Container über die Management Console.
///
/// Modus = MfApi (Standard, laut Aurea CX Messenger Doku-Index → Management Application API):
/// Dieselbe Aktion wie „Restart“ in der Sonic Management Console:
/// JMSConnectorClient → MFProxyFactory.createAgentProxy → IAgentProxy.restart
/// über ConnectionUrl + SMC-User/Pass aus appsettings.
/// stopcontainer.bat wird NICHT verwendet (existiert in vielen SMC-only Installationen nicht).
///
/// Modus = LocalCmd / WinRm: nur optional, wenn Server-Scripts vorhanden sind.
/// Modus = HttpApi: REST falls vorhanden.
/// Dünne Fassade über die Sonic MfApi (wie SMC-Neustart).
/// </summary>
public sealed class SonicManagementClient : IDisposable
{
private readonly SonicConnection _connection;
private readonly HttpClient? _http;
private readonly WinRmExecutor? _scriptRunner;
private readonly SonicMfApiExecutor? _mfApi;
// Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus)
private string? _resolvedContainerBasePath;
private static readonly string[] CandidateContainerPaths =
[
"/mf/rest/v1/domains/{domain}/containers",
"/api/v1/domains/{domain}/containers",
"/sonic/management/domains/{domain}/containers",
"/containers"
];
private SonicManagementMode Mode => _connection.EffectiveManagementMode;
private bool UsesMfApi => Mode == SonicManagementMode.MfApi;
private bool UsesScripts =>
Mode is SonicManagementMode.WinRm or SonicManagementMode.LocalCmd;
private readonly SonicMfApiExecutor _mfApi;
public SonicManagementClient(SonicConnection connection)
{
_connection = connection;
if (UsesMfApi)
{
_mfApi = new SonicMfApiExecutor(connection);
}
else if (UsesScripts)
{
_scriptRunner = new WinRmExecutor(connection);
}
else
{
HttpClientHandler handler = new()
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
_http = new HttpClient(handler)
{
BaseAddress = BuildHttpBaseUri(connection.ConnectionUrl, connection.ManagementHttpPort),
Timeout = TimeSpan.FromSeconds(Math.Clamp(connection.TimeoutSeconds, 5, 300))
};
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{connection.Username}:{connection.Password}"));
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
_http.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
}
// ---------------------------------------------------------------
// Öffentliche API
// ---------------------------------------------------------------
/// <summary>
/// Prüft die Verbindung zur Management Console.
/// MfApi: Domain-Manager über ConnectionUrl + SMC-Credentials.
/// WinRm/LocalCmd: Script-Laufzeit.
/// Http: Probe gegen bekannte API-Pfade.
/// </summary>
public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync(
public async Task<(bool Reachable, string? Error, string? Detail)> CheckConnectionAsync(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
(bool ok, string? error) = await _mfApi!.TestConnectionAsync(cancellationToken);
(bool ok, string? error) = await _mfApi.TestConnectionAsync(cancellationToken);
if (ok)
{
return (true, null,
$"MfApi {_connection.ConnectionUrl} Domain={_connection.DomainName}");
}
// Ohne lokales Java: Domain-Manager-TCP + KnownContainers reichen für Discovery.
bool javaMissing = error?.Contains("Java nicht gefunden", StringComparison.OrdinalIgnoreCase) == true;
if (javaMissing)
{
SonicBinRestartExecutor bin = new(_connection);
(bool binOk, _, string? binDetail) = bin.Probe();
if (binOk)
{
return (true, null,
$"SonicBin Fallback (kein Java für MfApi) {binDetail}; {_connection.ConnectionUrl}");
return (true, null, $"MfApi {_connection.ConnectionUrl} Domain={_connection.DomainName}");
}
// Soft-Fallback: KnownContainers + TCP reichen für Discovery
if (_connection.KnownContainers.Count > 0
|| await IsTcpReachableAsync(_connection.ConnectionUrl, cancellationToken))
&& await IsTcpReachableAsync(_connection.ConnectionUrl, cancellationToken))
{
return (true, null,
$"MfApi ohne Java KnownContainers/TCP; Hinweis: {Truncate(error ?? string.Empty, 180)}");
}
$"MfApi-Ping fehlgeschlagen, KnownContainers/TCP ok. Hinweis: {Truncate(error)}");
}
return (false, error, null);
return (false, error ?? "MfApi-Verbindung fehlgeschlagen", null);
}
if (UsesScripts)
{
if (Mode == SonicManagementMode.LocalCmd)
{
(bool binOk, string? binError, string? binDetail) = new SonicBinRestartExecutor(_connection).Probe();
return binOk
? (true, null, $"LocalCmd/SonicBin {binDetail}")
: (false, binError, null);
}
(bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken);
return ok
? (true, null, $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}")
: (false, error, null);
}
try
{
string? path = await ResolveContainerBasePathAsync(cancellationToken);
if (path is null)
{
return (false,
$"Keine Sonic HTTP-API unter {_http!.BaseAddress} gefunden.\n" +
$"Geprüfte Pfade: {string.Join(", ", GetCandidatePaths())}\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen (ConnectionUrl + SMC-Logins).",
null);
}
return (true, null, path);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return (false,
$"HTTP-Timeout nach {_connection.TimeoutSeconds}s Port {_connection.ManagementHttpPort} nicht erreichbar.\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen.",
null);
}
catch (Exception ex)
{
return (false, ex.Message, null);
}
}
/// <summary>
/// Listet alle Container der Domain auf.
/// </summary>
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
(bool ok, IReadOnlyList<string> names, string? error) =
await _mfApi.ListContainersAsync(cancellationToken);
if (ok && names.Count > 0)
{
return await GetContainersViaMfApiAsync(cancellationToken);
return (true, names, null);
}
if (UsesScripts)
if (_connection.KnownContainers.Count > 0)
{
return await GetContainersViaScriptAsync(cancellationToken);
return (true, _connection.KnownContainers,
ok ? null : $"Liste leer/fehlerhaft KnownContainers. {error}");
}
return await GetContainersViaHttpAsync(cancellationToken);
return (false, [], error ?? "Keine Container gefunden.");
}
/// <summary>
/// Startet den Container neu.
/// </summary>
public async Task<(bool Success, string Status, string? Detail)> RestartContainerAsync(
string containerName,
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
return await RestartViaMfApiAsync(containerName, cancellationToken);
}
if (UsesScripts)
{
if (Mode == SonicManagementMode.LocalCmd)
{
(bool binOk, string binStatus, string? binDetail) =
await new SonicBinRestartExecutor(_connection)
.RestartAsync(containerName, cancellationToken);
if (binOk)
{
return (true, binStatus, binDetail);
}
// Reine SMC/Client-Installation ohne Server-bin → wie die Console selbst
// über ConnectionUrl + SMC-Login (MfApi) neu starten.
bool batsMissing = binDetail?.Contains("stopcontainer", StringComparison.OrdinalIgnoreCase) == true
|| binStatus.Contains("SonicBin", StringComparison.OrdinalIgnoreCase);
if (batsMissing)
{
SonicMfApiExecutor mf = new(_connection);
(bool mfOk, string? mfOut, string? mfErr) =
await mf.RestartAsync(containerName, cancellationToken);
if (mfOk)
{
await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken);
return (true, $"Container '{containerName}' neugestartet (SMC/MfApi)",
$"Kein stopcontainer.bat unter SonicHome SMC-Verbindung genutzt.\n{mfOut}");
}
return (false, "Neustart fehlgeschlagen",
"Ursache: Unter SonicHome fehlen stopcontainer.bat/startcontainer.bat.\n" +
"Das ist typisch, wenn nur die Sonic Management Console (Client) installiert ist \n" +
"die Scripts liegen auf dem Sonic-SERVER.\n\n" +
$"SonicBin: {binDetail}\n\n" +
$"SMC/MfApi-Fallback: {mfErr}\n{mfOut}\n\n" +
"Was tun:\n" +
"1) SonicHome auf den Server-Pfad setzen (Ordner mit bin\\stopcontainer.bat), ODER\n" +
"2) Java + Client-JARs unter SonicHome\\lib bereitstellen (wie SMC),\n" +
" ConnectionUrl/User/Pass = dieselben Werte wie beim SMC-Login.");
}
return (false, binStatus, binDetail);
}
return await RestartViaScriptAsync(containerName, cancellationToken);
}
return await RestartViaHttpAsync(containerName, cancellationToken);
}
/// <summary>
/// Importiert XApi-Ressourcen und startet den Container neu.
/// </summary>
public async Task<(bool Success, string Status, string? Detail)> ImportXapiAndRestartAsync(
string containerName,
string xapiSourcePath,
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
// XApi-Import bleibt script/HTTP; Neustart danach über MfApi.
if (!string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript))
{
WinRmExecutor local = new(CloneAsLocalCmd(_connection));
string script = WinRmExecutor.ApplyScriptTemplate(
_connection.WinRmXapiImportScript,
containerName,
_connection.DomainName,
_connection.SonicHome,
xapiSourcePath,
_connection.ConnectionUrl,
_connection.Username,
_connection.Password);
(bool importOk, string? importOut, string? importErr) =
await local.RunScriptAsync(script, cancellationToken);
if (!importOk)
{
return (false, "XApi-Import fehlgeschlagen", importErr ?? importOut);
}
(bool restartOk, string restartStatus, string? restartDetail) =
await RestartViaMfApiAsync(containerName, cancellationToken);
return restartOk
? (true, "XApi importiert + Container neugestartet (MfApi)",
$"Import: {importOut} | Restart: {restartDetail}")
: (false, restartStatus, restartDetail);
}
return (false, "XApi-Import fehlgeschlagen",
"Im MfApi-Modus ist WinRmXapiImportScript für den Import nötig, " +
"oder ManagementMode vorübergehend auf LocalCmd/WinRm setzen.");
}
if (UsesScripts)
{
return await ImportXapiViaScriptAsync(containerName, xapiSourcePath, cancellationToken);
}
return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken);
}
// ---------------------------------------------------------------
// MfApi (Sonic Domain Manager / IAgentProxy.restart)
// ---------------------------------------------------------------
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaMfApiAsync(
CancellationToken cancellationToken)
{
(bool ok, IReadOnlyList<string> names, string? error) =
await _mfApi!.ListContainersAsync(cancellationToken);
if (!ok)
{
return (false, [], $"Container-Liste fehlgeschlagen (MfApi): {error}");
}
if (names.Count == 0 && _connection.KnownContainers.Count > 0)
{
List<string> withHint =
[
..names,
$"INFO:MfApiListeLeer FallbackKnownContainers={_connection.KnownContainers.Count}"
];
return (true, withHint, null);
}
return (true, names, null);
}
private async Task<(bool, string, string?)> RestartViaMfApiAsync(
string containerName, CancellationToken cancellationToken)
{
(bool ok, string? output, string? error) =
await _mfApi!.RestartAsync(containerName, cancellationToken);
await _mfApi.RestartAsync(containerName, cancellationToken);
if (ok)
{
@@ -341,27 +69,13 @@ public sealed class SonicManagementClient : IDisposable
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken);
return (true, $"Container '{containerName}' neugestartet (MfApi)",
$"Domain={_connection.DomainName}; ConnectionUrl={_connection.ConnectionUrl}\n{output}");
return (true, $"Container '{containerName}' neugestartet",
$"Domain={_connection.DomainName}; URL={_connection.ConnectionUrl}\n{output}");
}
string libHint = string.IsNullOrWhiteSpace(_connection.MfClientLibPath)
? _connection.SonicHome
: _connection.MfClientLibPath;
return (false, "Neustart fehlgeschlagen (MfApi)",
"Laut Doku (Management Application API / wie SMC-Restart):\n" +
"JMSConnectorClient + MFProxyFactory.createAgentProxy + IAgentProxy.restart\n\n" +
return (false, "Neustart fehlgeschlagen",
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n" +
$"Fehler: {error}\nAusgabe: {output}\n\n" +
"Benötigt (SMC-Installation):\n" +
$"- MfClientLibPath/SonicHome={libHint} (mgmt_client.jar, mfcontext.jar, sonic_Client.jar)\n" +
$"- JavaPath={_connection.JavaPath}\n" +
"- Dieselben ConnectionUrl/User/Pass wie beim SMC-Login\n" +
"SMC-ObjectName Beispiel: proalpha-test.ct-ZADBService:ID=AGENT → ContainerName=ct-ZADBService\n" +
"Hinweis: Bei 'unbounded client connector' nutzt das Tool MBean stop/restart (wie SMC),\n" +
"nicht nur IAgentProxy.restart.\n" +
"Hinweis: stopcontainer.bat wird nicht verwendet (SMC-only).");
$"Fehler: {error}\n\n{output}");
}
private static async Task<bool> IsTcpReachableAsync(string connectionUrl, CancellationToken cancellationToken)
@@ -383,390 +97,12 @@ public sealed class SonicManagementClient : IDisposable
}
}
private static SonicConnection CloneAsLocalCmd(SonicConnection source)
=> new()
{
Name = source.Name,
DomainName = source.DomainName,
ConnectionUrl = source.ConnectionUrl,
Username = source.Username,
Password = source.Password,
WinRmUsername = source.WinRmUsername,
WinRmPassword = source.WinRmPassword,
ManagementMode = SonicManagementMode.LocalCmd,
SonicHome = source.SonicHome,
JavaHome = source.JavaHome,
JavaPath = source.JavaPath,
MfClientLibPath = source.MfClientLibPath,
KnownContainers = source.KnownContainers,
ManagementHttpPort = source.ManagementHttpPort,
ApiBasePath = source.ApiBasePath,
ContainerListPath = source.ContainerListPath,
ContainerRestartPath = source.ContainerRestartPath,
ContainerStopPath = source.ContainerStopPath,
ContainerStartPath = source.ContainerStartPath,
WinRmPort = source.WinRmPort,
WinRmRestartScript = source.WinRmRestartScript,
WinRmContainerListScript = source.WinRmContainerListScript,
WinRmXapiImportScript = source.WinRmXapiImportScript,
TimeoutSeconds = source.TimeoutSeconds,
PostRestartDelaySeconds = source.PostRestartDelaySeconds
};
// ---------------------------------------------------------------
// Script-Implementierungen (WinRM / LocalCmd)
// ---------------------------------------------------------------
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaScriptAsync(
CancellationToken cancellationToken)
{
string script = WinRmExecutor.ApplyScriptTemplate(
_connection.ResolveContainerListScript(),
containerName: string.Empty,
domainName: _connection.DomainName,
sonicHome: _connection.SonicHome,
connectionUrl: _connection.ConnectionUrl,
username: _connection.Username,
password: _connection.Password);
(bool ok, string? output, string? error) =
await _scriptRunner!.RunScriptAsync(script, cancellationToken);
if (!ok)
{
return (false, [], $"Container-Liste fehlgeschlagen ({Mode}): {error}");
}
List<string> names = (output ?? string.Empty)
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(l => l.Length > 0)
.Where(l => !l.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase))
.ToList();
// INFO:/WARN:-Zeilen aus dem Script bewusst durchreichen (Diagnose in Discovery)
return (true, names, null);
}
private async Task<(bool, string, string?)> RestartViaScriptAsync(
string containerName, CancellationToken cancellationToken)
{
string script = WinRmExecutor.ApplyScriptTemplate(
_connection.ResolveRestartScript(),
containerName,
_connection.DomainName,
_connection.SonicHome,
connectionUrl: _connection.ConnectionUrl,
username: _connection.Username,
password: _connection.Password);
(bool ok, string? output, string? error) =
await _scriptRunner!.RunScriptAsync(script, cancellationToken);
string mode = Mode.ToString();
bool verified = (output ?? string.Empty)
.Contains("OK:ContainerRestartVerified", StringComparison.OrdinalIgnoreCase);
if (!ok || !verified)
{
return (false, $"Neustart fehlgeschlagen ({mode})",
$"Kein verifizierter Prozess-Neustart für Container '{containerName}' " +
$"(Domain '{_connection.DomainName}').\n" +
$"Fehler: {error}\nAusgabe: {output}\nSonicHome={_connection.SonicHome}");
}
await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken);
return (true, $"Container '{containerName}' neugestartet ({mode})",
$"Domain={_connection.DomainName}; verifiziert.\n{output}");
}
private async Task<(bool, string, string?)> ImportXapiViaScriptAsync(
string containerName, string xapiSourcePath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript))
{
return (false, "XApi-Import fehlgeschlagen",
"WinRmXapiImportScript ist nicht konfiguriert.");
}
string script = WinRmExecutor.ApplyScriptTemplate(
_connection.WinRmXapiImportScript,
containerName,
_connection.DomainName,
_connection.SonicHome,
xapiSourcePath,
_connection.ConnectionUrl,
_connection.Username,
_connection.Password);
(bool importOk, string? importOut, string? importErr) =
await _scriptRunner!.RunScriptAsync(script, cancellationToken);
if (!importOk)
{
return (false, "XApi-Import fehlgeschlagen", importErr ?? importOut);
}
(bool restartOk, string restartStatus, string? restartDetail) =
await RestartViaScriptAsync(containerName, cancellationToken);
return restartOk
? (true, "XApi importiert + Container neugestartet",
$"Import: {importOut} | Restart: {restartDetail}")
: (false, restartStatus, restartDetail);
}
// ---------------------------------------------------------------
// HTTP-Implementierungen
// ---------------------------------------------------------------
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaHttpAsync(
CancellationToken cancellationToken)
{
try
{
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
if (basePath is null)
{
return (false, [], "Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen?");
}
using HttpResponseMessage response = await _http!.GetAsync(basePath, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return (false, [], $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
return (true, ParseContainerNames(json), null);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return (false, [], ex.Message);
}
}
private async Task<(bool, string, string?)> RestartViaHttpAsync(
string containerName, CancellationToken cancellationToken)
{
string encoded = Uri.EscapeDataString(containerName);
try
{
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
if (basePath is null)
{
return (false, "Neustart fehlgeschlagen",
"Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen.");
}
if (!string.IsNullOrWhiteSpace(_connection.ContainerRestartPath))
{
string customPath = ApplyTemplate(_connection.ContainerRestartPath, encoded);
(bool ok, _, string? d) = await PostAsync(customPath, cancellationToken);
if (ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", d); }
}
(bool r1Ok, _, string? r1d) = await PostAsync($"{basePath}/{encoded}/restart", cancellationToken);
if (r1Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", r1d); }
(bool r2Ok, _, string? r2d) = await PutStateAsync(basePath, encoded, "running", cancellationToken);
if (r2Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet (State-API)", r2d); }
return await StopThenStartAsync(basePath, encoded, cancellationToken);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { return (false, "Neustart fehlgeschlagen", ex.Message); }
}
private async Task<(bool, string, string?)> ImportXapiViaHttpAsync(
string containerName, string xapiSourcePath, CancellationToken cancellationToken)
{
if (!File.Exists(xapiSourcePath))
{
return (false, "XApi-Import fehlgeschlagen", $"Quelldatei nicht gefunden: {xapiSourcePath}");
}
string encoded = Uri.EscapeDataString(containerName);
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
if (basePath is null) return (false, "XApi-Import fehlgeschlagen", "Kein HTTP-API-Pfad.");
string path = $"{basePath}/{encoded}/xapi/import";
await using FileStream fs = File.OpenRead(xapiSourcePath);
string mt = xapiSourcePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "application/zip" : "application/xml";
using StreamContent content = new(fs);
content.Headers.ContentType = new MediaTypeHeaderValue(mt);
using HttpResponseMessage rsp = await _http!.PostAsync(path, content, cancellationToken);
if (!rsp.IsSuccessStatusCode)
{
string body = await rsp.Content.ReadAsStringAsync(cancellationToken);
return (false, "XApi-Import fehlgeschlagen", $"HTTP {(int)rsp.StatusCode}: {Truncate(body)}");
}
(bool restartOk, string rs, string? rd) = await RestartViaHttpAsync(containerName, cancellationToken);
return restartOk
? (true, "XApi importiert + Container neugestartet", rd)
: (false, rs, rd);
}
// ---------------------------------------------------------------
// HTTP-Pfad-Erkennung
// ---------------------------------------------------------------
private async Task<string?> ResolveContainerBasePathAsync(CancellationToken cancellationToken)
{
if (_resolvedContainerBasePath is not null) return _resolvedContainerBasePath;
foreach (string p in GetCandidatePaths())
{
try
{
using HttpResponseMessage r = await _http!.GetAsync(p, cancellationToken);
if (r.IsSuccessStatusCode || r.StatusCode == HttpStatusCode.Unauthorized)
{
_resolvedContainerBasePath = p;
return p;
}
}
catch (HttpRequestException) { }
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { }
}
return null;
}
private IEnumerable<string> GetCandidatePaths()
{
if (!string.IsNullOrWhiteSpace(_connection.ContainerListPath))
yield return ApplyTemplate(_connection.ContainerListPath, string.Empty).TrimEnd('/');
if (!string.IsNullOrWhiteSpace(_connection.ApiBasePath))
{
string d = Uri.EscapeDataString(_connection.DomainName);
yield return $"{_connection.ApiBasePath.TrimEnd('/')}/domains/{d}/containers";
}
foreach (string pattern in CandidateContainerPaths)
yield return pattern.Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase);
}
// ---------------------------------------------------------------
// HTTP-Aktions-Helfer
// ---------------------------------------------------------------
private async Task<(bool, string, string?)> PostAsync(string path, CancellationToken ct)
{
using HttpResponseMessage r = await _http!.PostAsync(path, null, ct);
if (r.IsSuccessStatusCode)
return (true, $"OK ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}");
string body = await r.Content.ReadAsStringAsync(ct);
return (false, $"Fehler ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}: {Truncate(body)}");
}
private async Task<(bool, string, string?)> PutStateAsync(
string basePath, string encoded, string state, CancellationToken ct)
{
string path = $"{basePath}/{encoded}";
using StringContent body = new($"{{\"state\":\"{state}\"}}", Encoding.UTF8, "application/json");
using HttpResponseMessage r = await _http!.PutAsync(path, body, ct);
if (r.IsSuccessStatusCode)
return (true, $"State={state}", $"PUT {path} state={state} → {(int)r.StatusCode}");
string b = await r.Content.ReadAsStringAsync(ct);
return (false, "State fehlgeschlagen", $"PUT {path} → {(int)r.StatusCode}: {Truncate(b)}");
}
private async Task<(bool, string, string?)> StopThenStartAsync(
string basePath, string encoded, CancellationToken ct)
{
string stopPath = string.IsNullOrWhiteSpace(_connection.ContainerStopPath)
? $"{basePath}/{encoded}/stop"
: ApplyTemplate(_connection.ContainerStopPath, encoded);
string startPath = string.IsNullOrWhiteSpace(_connection.ContainerStartPath)
? $"{basePath}/{encoded}/start"
: ApplyTemplate(_connection.ContainerStartPath, encoded);
(bool sOk, _, string? sd) = await PostAsync(stopPath, ct);
if (!sOk) return (false, "Container-Stop fehlgeschlagen", sd);
await Task.Delay(TimeSpan.FromSeconds(3), ct);
(bool stOk, _, string? std) = await PostAsync(startPath, ct);
if (!stOk) return (false, "Container-Start fehlgeschlagen", std);
await DelayAsync(ct);
return (true, "Container neugestartet (Stop+Start)", $"{sd} | {std}");
}
private async Task DelayAsync(CancellationToken ct)
{
int d = Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120);
if (d > 0) await Task.Delay(TimeSpan.FromSeconds(d), ct);
}
private string ApplyTemplate(string template, string encodedName)
=> template
.Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase)
.Replace("{container}", encodedName, StringComparison.OrdinalIgnoreCase);
private static Uri BuildHttpBaseUri(string connectionUrl, int httpPort)
{
try { return new Uri($"http://{new Uri(connectionUrl).Host}:{httpPort}"); }
catch { return new Uri(connectionUrl); }
}
private static string ExtractHost(string connectionUrl)
{
try { return new Uri(connectionUrl).Host; }
catch { return connectionUrl; }
}
private static List<string> ParseContainerNames(string json)
{
try
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
List<string> names = [];
IEnumerable<JsonElement> elements = root.ValueKind == JsonValueKind.Array
? root.EnumerateArray()
: root.ValueKind == JsonValueKind.Object
? new[] { "containers", "data", "items", "result" }
.Where(k => root.TryGetProperty(k, out _))
.SelectMany(k =>
{
root.TryGetProperty(k, out JsonElement a);
return a.ValueKind == JsonValueKind.Array
? a.EnumerateArray()
: Enumerable.Empty<JsonElement>();
})
: [];
foreach (JsonElement el in elements)
{
string? name = el.ValueKind == JsonValueKind.String
? el.GetString()
: new[] { "name", "containerName", "id", "configId" }
.Where(k => el.TryGetProperty(k, out _))
.Select(k => { el.TryGetProperty(k, out JsonElement p); return p.GetString(); })
.FirstOrDefault();
if (name is not null) names.Add(name);
}
return names;
}
catch { return []; }
}
private static string Truncate(string v, int max = 400)
=> v.Length <= max ? v : v[..max] + "…";
private static string Truncate(string? s, int max = 180)
=> string.IsNullOrWhiteSpace(s) ? string.Empty
: s.Length <= max ? s : s[..max] + "…";
public void Dispose()
{
_http?.Dispose();
// nichts zu dispose'n
}
}
@@ -1,135 +0,0 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class TlsCertificateProbe
{
private readonly int _timeoutSeconds;
private readonly int _retryCount;
public TlsCertificateProbe(int timeoutSeconds = 8, int retryCount = 2)
{
_timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 60);
_retryCount = Math.Clamp(retryCount, 0, 5);
}
public async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeAsync(
DeploymentTarget target,
string expectedFingerprintSha256,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(target.TlsHost))
{
return (true, "TLS übersprungen", "Kein TlsHost konfiguriert.", null);
}
int port = target.TlsPort is > 0 and <= 65535 ? target.TlsPort.Value : 443;
// TlsServerName überschreibt den SNI-Hostnamen wenn gesetzt (wichtig bei IP-Adressen)
string serverName = string.IsNullOrWhiteSpace(target.TlsServerName)
? target.TlsHost
: target.TlsServerName;
string expected = NormalizeFingerprint(expectedFingerprintSha256);
Exception? lastError = null;
for (int attempt = 0; attempt <= _retryCount; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await ProbeOnceAsync(
target.TlsHost,
port,
serverName,
expected,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastError = ex;
if (attempt < _retryCount)
{
await Task.Delay(400, cancellationToken);
}
}
}
return (
false,
"TLS nicht erreichbar",
lastError?.Message ?? $"Keine Verbindung zu {target.TlsHost}:{port}",
null);
}
private async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeOnceAsync(
string host,
int port,
string serverName,
string expectedFingerprint,
CancellationToken cancellationToken)
{
using TcpClient client = new();
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds));
await client.ConnectAsync(host, port, timeoutCts.Token);
await using SslStream sslStream = new(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback: static (_, _, _, _) => true);
await sslStream.AuthenticateAsClientAsync(
new SslClientAuthenticationOptions
{
TargetHost = serverName, // SNI: muss zum CN/SAN im Zertifikat passen
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
| System.Security.Authentication.SslProtocols.Tls13
},
timeoutCts.Token);
if (sslStream.RemoteCertificate is null)
{
return (false, "TLS Fail", "Kein Remote-Zertifikat erhalten.", null);
}
using X509Certificate2 remote = new(sslStream.RemoteCertificate);
string actual = Convert.ToHexString(SHA256.HashData(remote.RawData));
if (string.IsNullOrEmpty(expectedFingerprint))
{
// Kein erwarteter Fingerprint konfiguriert nur Konnektivität prüfen
return (true, "TLS Pass (kein Fingerprint-Vergleich)", $"{host}:{port} erreichbar. Fingerprint={actual}", actual);
}
if (string.Equals(actual, expectedFingerprint, StringComparison.OrdinalIgnoreCase))
{
return (true, "TLS Pass", $"{host}:{port} Fingerprint stimmt überein.", actual);
}
return (
false,
"TLS Fail",
$"{host}:{port} Fingerprint weicht ab. Erwartet={expectedFingerprint}, Ist={actual}",
actual);
}
private static string NormalizeFingerprint(string fingerprint)
{
return fingerprint
.Replace(":", string.Empty, StringComparison.Ordinal)
.Replace(" ", string.Empty, StringComparison.Ordinal)
.ToUpperInvariant();
}
}
@@ -1,268 +0,0 @@
using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Führt PowerShell-Befehle lokal oder via WinRM (Invoke-Command) auf dem Sonic-Server aus.
/// Schreibt Skripte als .ps1 (UTF-8 ohne BOM) und startet sie mit -File,
/// um den bekannten stdin/BOM-Fehler zu vermeiden
/// ("$ErrorActionPreference wurde nicht als Name eines Cmdlet erkannt").
/// </summary>
public sealed class WinRmExecutor
{
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
private readonly SonicConnection _connection;
public WinRmExecutor(SonicConnection connection)
{
_connection = connection;
}
private bool HasExplicitWinRmCredentials
=> !string.IsNullOrWhiteSpace(_connection.WinRmUsername);
public async Task<(bool Success, string? Error)> TestConnectionAsync(
CancellationToken cancellationToken = default)
{
if (_connection.EffectiveManagementMode == 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);
try
{
using System.Net.Sockets.TcpClient tcp = new();
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 5, 30)));
await tcp.ConnectAsync(host, _connection.WinRmPort, cts.Token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
return (false,
$"WinRM-Port {_connection.WinRmPort} auf '{host}' nicht erreichbar: {ex.Message}\n" +
"Auf dem Zielrechner: Enable-PSRemoting -Force\n" +
"Oder ManagementMode=LocalCmd setzen und die App auf dem Sonic-PC starten.");
}
(bool sessionOk, _, string? sessionError) = await RunScriptAsync(
"$env:COMPUTERNAME", cancellationToken);
return sessionOk
? (true, null)
: (false, sessionError ?? "WinRM-Verbindung fehlgeschlagen.");
}
public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync(
string scriptBlock,
CancellationToken cancellationToken = default)
{
string fullScript = _connection.EffectiveManagementMode == SonicManagementMode.LocalCmd
? BuildLocalScript(scriptBlock)
: BuildRemoteScript(ExtractHost(_connection.ConnectionUrl), scriptBlock);
string tempFile = Path.Combine(
Path.GetTempPath(),
$"esb-winrm-{Guid.NewGuid():N}.ps1");
await File.WriteAllTextAsync(tempFile, fullScript, Utf8NoBom, cancellationToken);
try
{
ProcessStartInfo psi = new()
{
FileName = "powershell.exe",
Arguments = "-NonInteractive -NoProfile -ExecutionPolicy Bypass -File \"" + tempFile + "\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
using Process process = new() { StartInfo = psi };
if (!process.Start())
{
return (false, null, "PowerShell-Prozess konnte nicht gestartet werden.");
}
using CancellationTokenSource timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 10, 600)));
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
return (false, null, $"Ausführung Timeout nach {_connection.TimeoutSeconds}s.");
}
string stdout = (await stdoutTask).Trim();
string stderr = (await stderrTask).Trim();
if (process.ExitCode == 0)
{
return (true, stdout, stderr.Length > 0 ? stderr : null);
}
string rawError = stderr.Length > 0
? stderr
: $"PowerShell ExitCode={process.ExitCode}";
string error = _connection.EffectiveManagementMode == SonicManagementMode.WinRm
? FormatWinRmFailure(rawError)
: Truncate(rawError);
return (false, stdout.Length > 0 ? stdout : null, error);
}
finally
{
try { File.Delete(tempFile); } catch { /* ignore */ }
}
}
private static string BuildLocalScript(string scriptBlock)
=> "$ErrorActionPreference = 'Stop'\n" + scriptBlock;
/// <summary>
/// Remote-Payload als Base64 einbetten und per [scriptblock]::Create ausführen.
/// Vermeidet verschachteltes ScriptBlock-Brace-Nesting und lokale $-Expansion.
/// WinRM-Credentials: nur <see cref="SonicConnection.WinRmUsername"/> / WinRmPassword.
/// Leer = aktueller Windows-Benutzer (ohne -Credential).
/// </summary>
private string BuildRemoteScript(string host, string scriptBlock)
{
string escapedHost = host.Replace("'", "''");
string remoteB64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock));
StringBuilder sb = new();
sb.Append("$ErrorActionPreference = 'Stop'\n");
if (HasExplicitWinRmCredentials)
{
string escapedPwd = (_connection.WinRmPassword ?? string.Empty).Replace("'", "''");
string escapedUser = _connection.WinRmUsername.Replace("'", "''");
sb.Append($"$secPwd = ConvertTo-SecureString '{escapedPwd}' -AsPlainText -Force\n");
sb.Append($"$cred = New-Object System.Management.Automation.PSCredential('{escapedUser}', $secPwd)\n");
}
sb.Append($"$remoteB64 = '{remoteB64}'\n");
sb.Append("$remoteScript = [System.Text.Encoding]::Unicode.GetString(");
sb.Append("[System.Convert]::FromBase64String($remoteB64))\n");
sb.Append("$sb = [scriptblock]::Create($remoteScript)\n");
sb.Append($"Invoke-Command -ComputerName '{escapedHost}' -Port {_connection.WinRmPort} ");
if (HasExplicitWinRmCredentials)
{
sb.Append("-Credential $cred ");
}
sb.Append("-ScriptBlock $sb -ErrorAction Stop");
return sb.ToString();
}
/// <summary>
/// Erkennt typische WinRM-/Windows-Auth-Fehler und liefert eine klare Handlungsanweisung.
/// Username/Password in appsettings sind Sonic-SMC nicht Windows/WinRM.
/// </summary>
private string FormatWinRmFailure(string rawError)
{
string truncated = Truncate(rawError);
if (!LooksLikeWinRmAuthFailure(rawError))
{
return truncated;
}
string authHint = HasExplicitWinRmCredentials
? "WinRmUsername/WinRmPassword prüfen (Windows-Konto mit WinRM-Rechten auf dem Zielrechner)."
: "Aktueller Windows-Benutzer hat keine WinRM-Berechtigung auf dem Zielrechner " +
"(oder Kerberos/CredSSP fehlt). WinRmUsername/WinRmPassword setzen " +
"oder App unter einem berechtigten Windows-Konto starten.";
return
"WinRM-Authentifizierung fehlgeschlagen: Windows-Anmeldedaten falsch oder fehlend.\n" +
"Hinweis: Username/Password in appsettings sind Sonic-SMC-/Domain-Manager-Logins " +
"NICHT für WinRM/Windows.\n" +
authHint + "\n" +
"Alternativen:\n" +
" • ManagementMode=LocalCmd setzen und die App direkt auf dem Sonic-Server starten (kein WinRM).\n" +
" • WinRmUsername/WinRmPassword mit gültigem Windows-Konto befüllen.\n" +
$"Details: {truncated}";
}
private static bool LooksLikeWinRmAuthFailure(string error)
{
if (string.IsNullOrEmpty(error))
{
return false;
}
ReadOnlySpan<string> markers =
[
"Benutzername oder das Kennwort ist falsch",
"username or password is incorrect",
"Access is denied",
"Zugriff verweigert",
"Logon failure",
"Anmeldefehler",
"PSRemotingTransportException",
"UnauthorizedAccess",
"WinRM cannot process the request",
"der remotecomputer hat den netzwerkdatenverkehr verweigert"
];
foreach (string marker in markers)
{
if (error.Contains(marker, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public static string ApplyScriptTemplate(
string template,
string containerName,
string domainName = "",
string sonicHome = "",
string xapiPath = "",
string connectionUrl = "",
string username = "",
string password = "")
=> template
.Replace("{container}", containerName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{domain}", domainName.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{sonicHome}", sonicHome.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{xapiPath}", xapiPath.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{connectionUrl}", connectionUrl.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{username}", username.Replace("'", "''"), StringComparison.OrdinalIgnoreCase)
.Replace("{password}", password.Replace("'", "''"), StringComparison.OrdinalIgnoreCase);
private static string ExtractHost(string connectionUrl)
{
try { return new Uri(connectionUrl).Host; }
catch { return connectionUrl; }
}
private static string Truncate(string s, int max = 600)
=> s.Length <= max ? s : s[..max] + "…";
}
@@ -1,157 +0,0 @@
/*
ESB Certificate Manager Zielkonfiguration (SQL Server)
Offline vorbereitet; zur späteren Nutzung auf dem Ziel-SQL-Server ausführen.
*/
IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SonicConnection
(
Id INT NOT NULL IDENTITY(1, 1),
Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTarget
DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain-Name, z.B. "proalpha-test"
ManagementUrl NVARCHAR(512) NOT NULL, -- HTTP-URL der Management Console, z.B. "http://host:8080"
ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBasePath DEFAULT (N'/api/v1'),
Username NVARCHAR(128) NOT NULL,
-- Passwort wird in der Anwendung verschlüsselt gespeichert; hier nur als Verweis
PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''),
TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30),
PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15),
IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_IsActive DEFAULT (1),
CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id),
CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name)
);
END
GO
IF OBJECT_ID(N'dbo.DeploymentTarget', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentTarget
(
Id INT NOT NULL IDENTITY(1, 1),
Name NVARCHAR(128) NOT NULL,
Environment NVARCHAR(64) NOT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_DeploymentTarget_IsActive DEFAULT (1),
TargetDirectory NVARCHAR(512) NOT NULL,
CertificateFileName NVARCHAR(260) NOT NULL,
ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_Container DEFAULT (N''),
RestartType NVARCHAR(32) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartType DEFAULT (N'None'),
RestartCommand NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartCmd DEFAULT (N''),
RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartArgs DEFAULT (N''),
RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DeploymentTarget_RestartTimeout DEFAULT (60),
-- Sonic ESB Management
SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N''),
XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N''),
-- TLS-Probe
TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DeploymentTarget_TlsHost DEFAULT (N''),
TlsPort INT NULL,
SortOrder INT NOT NULL CONSTRAINT DF_DeploymentTarget_SortOrder DEFAULT (0),
CONSTRAINT PK_DeploymentTarget PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_DeploymentTarget_RestartType CHECK (
RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi')
)
);
END
ELSE
BEGIN
-- Neue Spalten zu bestehender Tabelle hinzufügen (idempotent)
IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget')
AND name = N'SonicConnectionName')
BEGIN
ALTER TABLE dbo.DeploymentTarget
ADD SonicConnectionName NVARCHAR(128) NOT NULL
CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N'');
END
IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget')
AND name = N'XapiSourcePath')
BEGIN
ALTER TABLE dbo.DeploymentTarget
ADD XapiSourcePath NVARCHAR(512) NOT NULL
CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N'');
END
-- CHECK-Constraint um neue RestartType-Werte erweitern
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.DeploymentTarget')
AND name = N'CK_DeploymentTarget_RestartType')
BEGIN
ALTER TABLE dbo.DeploymentTarget DROP CONSTRAINT CK_DeploymentTarget_RestartType;
ALTER TABLE dbo.DeploymentTarget ADD CONSTRAINT CK_DeploymentTarget_RestartType
CHECK (RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi'));
END
END
GO
IF OBJECT_ID(N'dbo.DeploymentRunHistory', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentRunHistory
(
Id BIGINT NOT NULL IDENTITY(1, 1),
StartedAtUtc DATETIME2(3) NOT NULL,
FinishedAtUtc DATETIME2(3) NULL,
UserName NVARCHAR(128) NOT NULL,
MachineName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N''),
OverallSuccess BIT NULL,
Summary NVARCHAR(2000) NULL,
CONSTRAINT PK_DeploymentRunHistory PRIMARY KEY CLUSTERED (Id)
);
END
ELSE
BEGIN
IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.DeploymentRunHistory')
AND name = N'MachineName')
BEGIN
ALTER TABLE dbo.DeploymentRunHistory
ADD MachineName NVARCHAR(128) NOT NULL
CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N'');
END
END
GO
IF OBJECT_ID(N'dbo.DeploymentRunDetail', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentRunDetail
(
Id BIGINT NOT NULL IDENTITY(1, 1),
RunId BIGINT NOT NULL,
TargetId INT NOT NULL,
TargetName NVARCHAR(128) NOT NULL,
Success BIT NOT NULL,
StatusText NVARCHAR(256) NOT NULL,
Detail NVARCHAR(2000) NULL,
Steps NVARCHAR(MAX) NULL,
CONSTRAINT PK_DeploymentRunDetail PRIMARY KEY CLUSTERED (Id),
CONSTRAINT FK_DeploymentRunDetail_Run
FOREIGN KEY (RunId) REFERENCES dbo.DeploymentRunHistory (Id)
);
END
GO
/*
Beispiel-Insert:
INSERT INTO dbo.SonicConnection (Name, DomainName, ManagementUrl, Username, PasswordHash)
VALUES (N'DE-Test', N'proalpha-test', N'http://dekun-painwbdet:8080', N'Administrator', N'<encrypted>');
INSERT INTO dbo.DeploymentTarget
(
Name, Environment, IsActive, TargetDirectory, CertificateFileName,
ContainerName, RestartType, SonicConnectionName,
TlsHost, TlsPort, SortOrder
)
VALUES
(
N'DE-Test Container A', N'TEST', 1, N'\\share\esb\certs\a', N'esb-cert.cer',
N'sonic-container-a', N'SonicContainer', N'DE-Test',
N'dekun-painwbdet', 13070, 10
),
(
N'DE-Test Container B (XApi)', N'TEST', 1, N'\\share\esb\certs\b', N'esb-cert.cer',
N'sonic-container-b', N'SonicContainerWithXapi', N'DE-Test',
N'dekun-painwbdet', 13070, 20
);
*/
@@ -1,193 +0,0 @@
/*
ESB Certificate Manager vollständiges Datenbankschema v2
Idempotent; kann auf einem leeren Schema oder nach 001_CreateSchema.sql ausgeführt werden.
Tabellen:
dbo.SonicConnection Sonic-ESB-Management-Instanzen
dbo.DeploymentTargets Deployment-Ziele mit allen Konfigurationsfeldern
dbo.DeploymentRuns Ein Eintrag pro Deployment-Lauf
dbo.DeploymentTargetResults Detailergebnis pro Ziel und Lauf
Always Encrypted (optional):
Zur Nutzung von Always Encrypted auf der Spalte PasswordHash in SonicConnection
die Blöcke unterhalb des Kommentars "-- ALWAYS ENCRYPTED" auskommentieren
und den Schlüsselnamen anpassen.
*/
-- ============================================================
-- SonicConnection
-- ============================================================
IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SonicConnection
(
Id INT NOT NULL IDENTITY(1, 1),
Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTargets
DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain, z.B. "proalpha-test"
ConnectionUrl NVARCHAR(512) NOT NULL, -- Sonic-Broker-URL, z.B. "tcp://dekun-painwbdet:13070"
ManagementHttpPort INT NOT NULL CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080),
ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBase DEFAULT (N'/api/v1'),
Username NVARCHAR(128) NOT NULL,
PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''),
-- ALWAYS ENCRYPTED: PasswordEncrypted NVARCHAR(512) ENCRYPTED WITH (
-- COLUMN_ENCRYPTION_KEY = CEK_SonicPwd,
-- ENCRYPTION_TYPE = DETERMINISTIC,
-- ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256'
-- ) NULL,
TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30),
PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15),
IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_Active DEFAULT (1),
CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id),
CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name)
);
END
ELSE
BEGIN
-- ConnectionUrl-Spalte nachrüsten (Migration von ManagementUrl)
IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ConnectionUrl')
BEGIN
ALTER TABLE dbo.SonicConnection ADD ConnectionUrl NVARCHAR(512) NOT NULL
CONSTRAINT DF_SonicConnection_ConnUrl DEFAULT (N'');
END
IF NOT EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ManagementHttpPort')
BEGIN
ALTER TABLE dbo.SonicConnection ADD ManagementHttpPort INT NOT NULL
CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080);
END
END
GO
-- ============================================================
-- DeploymentTargets
-- ============================================================
IF OBJECT_ID(N'dbo.DeploymentTargets', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentTargets
(
Id INT NOT NULL IDENTITY(1, 1),
Name NVARCHAR(100) NOT NULL,
Environment NVARCHAR(64) NOT NULL CONSTRAINT DF_DT_Env DEFAULT (N''),
IsActive BIT NOT NULL CONSTRAINT DF_DT_IsActive DEFAULT (1),
-- Zertifikat-Ablage
CertificateTargetPath NVARCHAR(500) NOT NULL,
CertificateFileName NVARCHAR(260) NOT NULL CONSTRAINT DF_DT_CertFile DEFAULT (N''),
-- Neustart-Konfiguration
RestartType NVARCHAR(30) NOT NULL CONSTRAINT DF_DT_RestartType DEFAULT (N'None'),
RestartHost NVARCHAR(255) NULL,
RestartCommand NVARCHAR(2000) NULL,
RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DT_RestartArgs DEFAULT (N''),
RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DT_RestartTimeout DEFAULT (60),
-- Sonic ESB
SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N''),
ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N''),
XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N''),
-- TLS-Probe
TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsHost DEFAULT (N''),
TlsPort INT NOT NULL CONSTRAINT DF_DT_TlsPort DEFAULT (443),
TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N''),
ExpectedFingerprint NVARCHAR(128) NULL,
SortOrder INT NOT NULL CONSTRAINT DF_DT_SortOrder DEFAULT (0),
CONSTRAINT PK_DeploymentTargets PRIMARY KEY CLUSTERED (Id),
CONSTRAINT CK_DeploymentTargets_RestartType CHECK (
RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi')
)
);
END
ELSE
BEGIN
-- Neue Spalten idempotent nachrüsten
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'TlsServerName')
ALTER TABLE dbo.DeploymentTargets ADD TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N'');
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ExpectedFingerprint')
ALTER TABLE dbo.DeploymentTargets ADD ExpectedFingerprint NVARCHAR(128) NULL;
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'SonicConnectionName')
ALTER TABLE dbo.DeploymentTargets ADD SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N'');
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ContainerName')
ALTER TABLE dbo.DeploymentTargets ADD ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N'');
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'XapiSourcePath')
ALTER TABLE dbo.DeploymentTargets ADD XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N'');
END
GO
-- ============================================================
-- DeploymentRuns (ein Datensatz pro Deployment-Lauf)
-- ============================================================
IF OBJECT_ID(N'dbo.DeploymentRuns', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentRuns
(
Id UNIQUEIDENTIFIER NOT NULL,
StartedAtUtc DATETIME2(3) NOT NULL,
FinishedAtUtc DATETIME2(3) NULL,
SourceFile NVARCHAR(500) NOT NULL,
SourceFingerprint NVARCHAR(128) NOT NULL,
StartedBy NVARCHAR(255) NOT NULL,
MachineName NVARCHAR(255) NOT NULL CONSTRAINT DF_DR_Machine DEFAULT (N''),
OverallStatus NVARCHAR(30) NOT NULL, -- 'Running' | 'Success' | 'PartialFailure' | 'Failure'
ErrorMessage NVARCHAR(MAX) NULL,
CONSTRAINT PK_DeploymentRuns PRIMARY KEY CLUSTERED (Id)
);
END
GO
-- ============================================================
-- DeploymentTargetResults (ein Datensatz pro Ziel und Lauf)
-- ============================================================
IF OBJECT_ID(N'dbo.DeploymentTargetResults', N'U') IS NULL
BEGIN
CREATE TABLE dbo.DeploymentTargetResults
(
Id INT NOT NULL IDENTITY(1, 1),
DeploymentRunId UNIQUEIDENTIFIER NOT NULL,
TargetId INT NOT NULL,
TargetName NVARCHAR(128) NOT NULL,
StartedAtUtc DATETIME2(3) NOT NULL,
FinishedAtUtc DATETIME2(3) NULL,
CopySucceeded BIT NOT NULL CONSTRAINT DF_DTR_Copy DEFAULT (0),
RestartSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Restart DEFAULT (0),
TlsSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Tls DEFAULT (0),
ObservedFingerprint NVARCHAR(128) NULL,
Status NVARCHAR(30) NOT NULL,
ErrorMessage NVARCHAR(MAX) NULL,
CONSTRAINT PK_DeploymentTargetResults PRIMARY KEY CLUSTERED (Id),
CONSTRAINT FK_DTR_Run FOREIGN KEY (DeploymentRunId) REFERENCES dbo.DeploymentRuns (Id)
);
CREATE NONCLUSTERED INDEX IX_DTR_RunId ON dbo.DeploymentTargetResults (DeploymentRunId);
END
GO
-- ============================================================
-- Beispieldaten
-- ============================================================
/*
INSERT INTO dbo.SonicConnection (Name, DomainName, ConnectionUrl, Username, PasswordHash)
VALUES (N'DE-Test', N'proalpha-test', N'tcp://dekun-painwbdet:13070', N'Administrator', N'<encrypted>');
INSERT INTO dbo.DeploymentTargets
(Name, Environment, IsActive, CertificateTargetPath, CertificateFileName,
RestartType, SonicConnectionName, ContainerName,
TlsHost, TlsPort, TlsServerName, SortOrder)
VALUES
(N'DE-Test Container A', N'TEST', 1,
N'\\dekun-painwbdet\sonic\certs', N'server.pfx',
N'SonicContainer', N'DE-Test', N'sonic-container-a',
N'dekun-painwbdet', 443, N'esb-test.firma.local', 10),
(N'DE-Test Container B (XApi)', N'TEST', 1,
N'\\dekun-painwbdet\sonic\certs', N'server.pfx',
N'SonicContainerWithXapi', N'DE-Test', N'sonic-container-b',
N'dekun-painwbdet', 443, N'esb-test.firma.local', 20);
*/
@@ -1,28 +0,0 @@
@echo off
REM Prueft ob JMSConnectorAddress mit den Sonic-Client-JARs ladbar ist.
set LIB=C:\DEV\MQ10.0\lib
set JAVA=C:\Program Files (x86)\Java\jre1.8.0_501\bin\java.exe
if not exist "%JAVA%" (
echo Java nicht gefunden: %JAVA%
exit /b 1
)
if not exist "%LIB%\mfcontext.jar" (
echo mfcontext.jar fehlt unter %LIB%
exit /b 1
)
set CP=%LIB%\mgmt_client.jar;%LIB%\mgmt_config.jar;%LIB%\sonic_mgmt_client.jar;%LIB%\mfcontext.jar;%LIB%\sonic_Client.jar;%LIB%\sonic_Client_ext.jar
echo Java: %JAVA%
echo CP: %CP%
echo.
"%JAVA%" -cp "%CP%" -version
echo.
"%JAVA%" -cp "%CP%" com.sonicsw.mf.jmx.client.JMSConnectorAddress 2>&1
echo ExitCode=%ERRORLEVEL%
echo.
echo Erwartet: oft "main method" / NoSuchMethodError ODER Usage - Hauptsache KEIN ClassNotFoundException.
exit /b 0
@@ -9,7 +9,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
</ItemGroup>
@@ -24,12 +23,6 @@
<None Update="Data\targets.sample.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Demo\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Sql\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Tools\**\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
@@ -1,10 +1,7 @@
{
"ConnectionString": "",
"UseOfflineSampleData": true,
"SampleTargetsPath": "Data/targets.sample.json",
"LogDirectory": "Logs",
"TlsTimeoutSeconds": 8,
"TlsRetryCount": 2,
"SonicConnections": [
{
"Name": "DE-Test",
@@ -12,7 +9,6 @@
"ConnectionUrl": "tcp://dekun-painwbdet:13070",
"Username": "Administrator",
"Password": "Administrator",
"ManagementMode": "MfApi",
"SonicHome": "C:\\DEV\\MQ10.0",
"JavaHome": "C:\\Program Files (x86)\\Java\\jre1.8.0_501",
"JavaPath": "C:\\Program Files (x86)\\Java\\jre1.8.0_501\\bin\\java.exe",
@@ -20,20 +16,8 @@
"KnownContainers": [
"ct-ZADBService"
],
"WinRmUsername": "",
"WinRmPassword": "",
"WinRmPort": 5985,
"TimeoutSeconds": 120,
"PostRestartDelaySeconds": 20,
"ManagementHttpPort": 8080,
"ApiBasePath": "/api/v1",
"ContainerListPath": "",
"ContainerRestartPath": "",
"ContainerStopPath": "",
"ContainerStartPath": "",
"WinRmRestartScript": "",
"WinRmContainerListScript": "",
"WinRmXapiImportScript": ""
"PostRestartDelaySeconds": 20
}
]
}