Initial commit: ESB Certificate Manager.

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 08:10:13 +02:00
co-authored by Cursor
commit ed8e2822d1
36 changed files with 5111 additions and 0 deletions
@@ -0,0 +1,9 @@
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
public interface ITargetRepository
{
Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(CancellationToken cancellationToken = default);
string SourceDescription { get; }
}
@@ -0,0 +1,46 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
public sealed class JsonTargetRepository : ITargetRepository
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly string _filePath;
public JsonTargetRepository(string filePath)
{
_filePath = filePath;
}
public string SourceDescription => $"Offline-Sample ({Path.GetFileName(_filePath)})";
public async Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(
CancellationToken cancellationToken = default)
{
if (!File.Exists(_filePath))
{
throw new FileNotFoundException(
$"Sample-Zieldatei wurde nicht gefunden: {_filePath}",
_filePath);
}
await using FileStream stream = File.OpenRead(_filePath);
List<DeploymentTarget>? targets = await JsonSerializer.DeserializeAsync<List<DeploymentTarget>>(
stream,
JsonOptions,
cancellationToken);
return (targets ?? [])
.Where(t => t.IsActive)
.OrderBy(t => t.SortOrder)
.ThenBy(t => t.Name)
.ToList();
}
}
@@ -0,0 +1,167 @@
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);
}
}
@@ -0,0 +1,111 @@
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;
}
}
@@ -0,0 +1,44 @@
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Data;
public static class TargetRepositoryFactory
{
public static async Task<(ITargetRepository Repository, string LoadMessage)> CreateAsync(
AppSettings settings,
CancellationToken cancellationToken = default)
{
string samplePath = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, settings.SampleTargetsPath));
if (settings.UseOfflineSampleData)
{
ITargetRepository jsonRepo = new JsonTargetRepository(samplePath);
_ = await jsonRepo.GetActiveTargetsAsync(cancellationToken);
return (jsonRepo, $"Ziele geladen aus {jsonRepo.SourceDescription}.");
}
if (string.IsNullOrWhiteSpace(settings.ConnectionString))
{
return OfflineSample(samplePath, "Keine SQL-Verbindung konfiguriert Offline-Sample wird verwendet.");
}
try
{
SqlTargetRepository sqlRepo = new(settings.ConnectionString);
_ = await sqlRepo.GetActiveTargetsAsync(cancellationToken);
return (sqlRepo, $"Ziele geladen aus {sqlRepo.SourceDescription}.");
}
catch (Exception ex)
{
return OfflineSample(
samplePath,
$"SQL nicht erreichbar ({ex.Message}). Offline-Sample wird verwendet.");
}
}
private static (ITargetRepository Repository, string LoadMessage) OfflineSample(
string samplePath,
string message)
=> (new JsonTargetRepository(samplePath), message);
}
@@ -0,0 +1,82 @@
[
{
"Id": 1,
"Name": "ESB Local A",
"Environment": "DEV",
"IsActive": true,
"TargetDirectory": "DeploySandbox/target-a",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-a",
"RestartType": "None",
"RestartCommand": "",
"RestartArguments": "",
"RestartTimeoutSeconds": 30,
"SonicConnectionName": "",
"XapiSourcePath": "",
"TlsHost": "",
"TlsPort": null,
"TlsServerName": "",
"ExpectedFingerprint": null,
"SortOrder": 10
},
{
"Id": 2,
"Name": "ESB Local B",
"Environment": "DEV",
"IsActive": true,
"TargetDirectory": "DeploySandbox/target-b",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-b",
"RestartType": "Command",
"RestartCommand": "cmd.exe",
"RestartArguments": "/c echo Restart simulated for sonic-container-b",
"RestartTimeoutSeconds": 15,
"SonicConnectionName": "",
"XapiSourcePath": "",
"TlsHost": "",
"TlsPort": null,
"TlsServerName": "",
"ExpectedFingerprint": null,
"SortOrder": 20
},
{
"Id": 3,
"Name": "DE-Test Container A (Sonic)",
"Environment": "TEST",
"IsActive": false,
"TargetDirectory": "DeploySandbox/target-c",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-a",
"RestartType": "SonicContainer",
"RestartCommand": "",
"RestartArguments": "",
"RestartTimeoutSeconds": 60,
"SonicConnectionName": "DE-Test",
"XapiSourcePath": "",
"TlsHost": "dekun-painwbdet",
"TlsPort": 443,
"TlsServerName": "esb-test.firma.local",
"ExpectedFingerprint": null,
"SortOrder": 30
},
{
"Id": 4,
"Name": "DE-Test Container B (Sonic + XApi)",
"Environment": "TEST",
"IsActive": false,
"TargetDirectory": "DeploySandbox/target-d",
"CertificateFileName": "esb-cert.cer",
"ContainerName": "sonic-container-b",
"RestartType": "SonicContainerWithXapi",
"RestartCommand": "",
"RestartArguments": "",
"RestartTimeoutSeconds": 60,
"SonicConnectionName": "DE-Test",
"XapiSourcePath": "Assets/xapi-resources.xml",
"TlsHost": "dekun-painwbdet",
"TlsPort": 443,
"TlsServerName": "esb-test.firma.local",
"ExpectedFingerprint": null,
"SortOrder": 40
}
]