Keep original UI; drop unused deploy/XApi/WinRM/TLS/SQL paths.

Form1 design unchanged; only MfApi restart and certificate recognition remain active.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 11:09:46 +02:00
co-authored by Cursor
parent 7a9cdb6ecf
commit d1b95bcb9d
17 changed files with 70 additions and 2628 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.");
}
ITargetRepository jsonRepo = new JsonTargetRepository(samplePath);
_ = await jsonRepo.GetActiveTargetsAsync(cancellationToken);
return (jsonRepo, $"Ziele geladen aus {jsonRepo.SourceDescription}.");
}
private static (ITargetRepository Repository, string LoadMessage) OfflineSample(
string samplePath,
string message)
=> (new JsonTargetRepository(samplePath), message);
}
@@ -4,8 +4,8 @@
"Name": "DE-Test / ct-ZADBService",
"Environment": "TEST",
"IsActive": true,
"TargetDirectory": "DeploySandbox/target-esb",
"CertificateFileName": "esb-cert.cer",
"TargetDirectory": "",
"CertificateFileName": "",
"ContainerName": "ct-ZADBService",
"RestartType": "SonicContainer",
"RestartCommand": "",
@@ -18,25 +18,5 @@
"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
}
]
+14 -85
View File
@@ -1232,15 +1232,17 @@ namespace ZA.CoreService.ESBCertificateManager
btnValidate.Anchor = AnchorStyles.Top | AnchorStyles.Right;
btnValidate.Click += async (_, _) => await RunValidationAsync();
btnRestartOnly = CreateSecondaryButton("ESB neu starten");
btnRestartOnly.Size = new Size(150, 42);
// Primäraktion: Neustart (Deploy vorerst deaktiviert Design/Layout bleibt)
btnRestartOnly = CreatePrimaryButton("ESB neu starten");
btnRestartOnly.Size = new Size(170, 42);
btnRestartOnly.Anchor = AnchorStyles.Top | AnchorStyles.Right;
btnRestartOnly.Click += async (_, _) => await RunRestartOnlyAsync();
btnDeploy = CreatePrimaryButton("Deployment starten");
btnDeploy.Size = new Size(170, 42);
btnDeploy.Anchor = AnchorStyles.Top | AnchorStyles.Right;
btnDeploy.Click += async (_, _) => await RunDeploymentAsync();
btnDeploy.Visible = false;
btnDeploy.Enabled = false;
panel.Controls.Add(btnCancelRun);
panel.Controls.Add(btnValidate);
@@ -1249,8 +1251,7 @@ namespace ZA.CoreService.ESBCertificateManager
panel.Resize += (_, _) =>
{
btnDeploy.Left = panel.Width - btnDeploy.Width;
btnRestartOnly.Left = btnDeploy.Left - btnRestartOnly.Width - 12;
btnRestartOnly.Left = panel.Width - btnRestartOnly.Width;
btnValidate.Left = btnRestartOnly.Left - btnValidate.Width - 12;
btnCancelRun.Left = btnValidate.Left - btnCancelRun.Width - 12;
};
@@ -1383,8 +1384,10 @@ namespace ZA.CoreService.ESBCertificateManager
}
btnCancelRun.Enabled = _isOperationRunning;
btnValidate.Enabled = idle && hasCertificate && hasTargets;
btnDeploy.Enabled = idle && hasCertificate && hasSelection;
// Prüfung nur für Neustart-Voraussetzungen (Zertifikat optional)
btnValidate.Enabled = idle && hasTargets;
btnDeploy.Enabled = false;
btnDeploy.Visible = false;
if (btnRestartOnly is not null)
{
btnRestartOnly.Enabled = idle && hasSelection;
@@ -1437,10 +1440,7 @@ namespace ZA.CoreService.ESBCertificateManager
}
List<DeploymentTarget> selected = GetSelectedTargets();
PreflightValidationResult result = _orchestrator.ValidatePreflight(
txtCertificatePath.Text,
_loadedCertificateInfo,
selected);
PreflightValidationResult result = _orchestrator.ValidateRestartOnly(selected);
HashSet<int> failedTargetIds = result.Issues
.Where(i => i.TargetId is not null)
@@ -1581,81 +1581,10 @@ namespace ZA.CoreService.ESBCertificateManager
}
}
private async Task RunDeploymentAsync()
private Task RunDeploymentAsync()
{
if (_isOperationRunning || _loadedCertificateInfo is null)
{
return;
}
List<DeploymentTarget> selected = GetSelectedTargets();
PreflightValidationResult preflight = _orchestrator.ValidatePreflight(
txtCertificatePath.Text,
_loadedCertificateInfo,
selected);
if (!preflight.IsValid)
{
ShowPreflightIssues(
preflight,
"Deployment blockiert Vorabprüfung fehlgeschlagen.",
"Deployment");
return;
}
_runCts?.Dispose();
_runCts = new CancellationTokenSource();
SetOperationRunning(true);
UpdateToNextStep(3);
SetStatus($"Deployment läuft für {selected.Count} Ziel(e)…", isError: false);
Progress<TargetProgressUpdate> progress = new(update =>
{
SetTargetRowStatus(update.TargetId, update.StatusText);
SetStatus(update.StatusText, isError: update.SuccessHint == false);
});
try
{
DeploymentRunResult runResult = await _orchestrator.RunAsync(
txtCertificatePath.Text,
_loadedCertificateInfo,
selected,
progress,
_runCts.Token);
foreach (TargetStepResult targetResult in runResult.TargetResults)
{
SetTargetRowStatus(targetResult.TargetId, targetResult.StatusText);
}
UpdateToNextStep(4);
SetStatus(
runResult.OverallSuccess
? $"Deployment erfolgreich ({runResult.TargetResults.Count} Ziel(e))."
: "Deployment mit Fehlern beendet. Details in der Status-Spalte / Log.",
isError: !runResult.OverallSuccess);
}
catch (OperationCanceledException)
{
SetStatus("Deployment abgebrochen.", isError: true);
}
catch (Exception ex)
{
SetStatus($"Deployment fehlgeschlagen: {ex.Message}", isError: true);
MessageBox.Show(
this,
ex.Message,
"Deployment",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
finally
{
SetOperationRunning(false);
_runCts?.Dispose();
_runCts = null;
}
// Deploy/Kopieren absichtlich deaktiviert UI-Design bleibt.
return Task.CompletedTask;
}
private Panel CreateCard()
@@ -65,16 +65,8 @@ public sealed class SonicConnection
=> 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>Anzeigetext für UI.</summary>
public string ManagementModeDisplay => "MfApi (SMC Management Application API)";
/// <summary>
/// True wenn der Host aus ConnectionUrl dieser Maschine entspricht
@@ -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>
/// Orchestriert nur noch Container-Neustart (kein Deploy/TLS/SQL).
/// </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,7 +29,7 @@ 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)
{
@@ -60,16 +48,7 @@ public sealed class DeploymentOrchestrator
};
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}");
}
$"Neustart beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}");
return runResult;
}
@@ -87,7 +66,8 @@ public sealed class DeploymentOrchestrator
(bool restartOk, string restartStatus, string? restartDetail) =
await _restartExecutor.ExecuteAsync(target, cancellationToken);
RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail);
steps.Add($"{restartStatus}: {restartDetail}");
logger.Write($"[{target.Name}] Restart: {restartStatus} | {restartDetail}");
TargetStepResult targetResult = new()
{
@@ -108,156 +88,6 @@ public sealed class DeploymentOrchestrator
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,
StatusText = status,
Detail = detail,
Steps = steps,
StartedAt = startedAt,
FinishedAt = DateTimeOffset.Now,
CopySucceeded = copySucceeded,
RestartSucceeded = restartSucceeded,
TlsSucceeded = false
};
}
}
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);
result.Issues.Add(new ValidationIssue
{
Message = $"Ziel '{target.Name}': SonicConnectionName fehlt.",
TargetId = 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
});
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] + "…";
}
@@ -1,339 +1,66 @@
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.
/// Sonic MfApi (wie SMC-Neustart) ohne WinRM/HTTP/XApi.
/// </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"));
}
_mfApi = new SonicMfApiExecutor(connection);
}
// ---------------------------------------------------------------
// Ö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(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
(bool ok, string? error) = await _mfApi.TestConnectionAsync(cancellationToken);
if (ok)
{
(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}");
}
if (_connection.KnownContainers.Count > 0
|| await IsTcpReachableAsync(_connection.ConnectionUrl, cancellationToken))
{
return (true, null,
$"MfApi ohne Java KnownContainers/TCP; Hinweis: {Truncate(error ?? string.Empty, 180)}");
}
}
return (false, error, null);
return (true, null, $"MfApi {_connection.ConnectionUrl} Domain={_connection.DomainName}");
}
if (UsesScripts)
if (_connection.KnownContainers.Count > 0
&& await IsTcpReachableAsync(_connection.ConnectionUrl, cancellationToken))
{
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);
return (true, null,
$"MfApi-Ping fehlgeschlagen, KnownContainers/TCP ok. Hinweis: {Truncate(error)}");
}
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);
}
return (false, error ?? "MfApi-Verbindung fehlgeschlagen", 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)
{
@@ -342,26 +69,12 @@ public sealed class SonicManagementClient : IDisposable
cancellationToken);
return (true, $"Container '{containerName}' neugestartet (MfApi)",
$"Domain={_connection.DomainName}; ConnectionUrl={_connection.ConnectionUrl}\n{output}");
$"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" +
$"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)
@@ -369,12 +82,10 @@ public sealed class SonicManagementClient : IDisposable
try
{
Uri uri = new(connectionUrl);
string host = uri.Host;
int port = uri.Port > 0 ? uri.Port : 2506;
using System.Net.Sockets.TcpClient tcp = new();
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(host, port, cts.Token);
await tcp.ConnectAsync(uri.Host, uri.Port > 0 ? uri.Port : 2506, cts.Token);
return true;
}
catch
@@ -383,390 +94,11 @@ 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();
}
}
@@ -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);
*/
@@ -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>