Files
123123/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs
T
GizzlerandCursor ea6e760434 Fix MfApi Java discovery via SonicHome JRE and ship prebuilt tool.
MfApi restart/list no longer requires system JAVA_HOME or javac when Sonic ships a JRE and Tools/SonicMfContainerTool.jar is present.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 09:00:45 +02:00

668 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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):
/// Offizielle Sonic MF Management Runtime API über ConnectionUrl + SMC-Logins
/// (JMSConnectorClient → MFProxyFactory.createAgentProxy → IAgentProxy.restart).
///
/// Modus = WinRm / LocalCmd (optionaler Fallback):
/// PowerShell/CMD stopcontainer/startcontainer (nicht Domain-Manager-nativ).
///
/// Modus = HttpApi:
/// HTTP REST API mit automatischer Pfad-Erkennung.
/// </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;
public SonicManagementClient(SonicConnection connection)
{
_connection = connection;
if (UsesMfApi)
{
_mfApi = new SonicMfApiExecutor(connection);
}
else if (UsesScripts)
{
_scriptRunner = new WinRmExecutor(connection);
}
else
{
HttpClientHandler handler = new()
{
ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
_http = new HttpClient(handler)
{
BaseAddress = BuildHttpBaseUri(connection.ConnectionUrl, connection.ManagementHttpPort),
Timeout = TimeSpan.FromSeconds(Math.Clamp(connection.TimeoutSeconds, 5, 300))
};
string credentials = Convert.ToBase64String(
Encoding.UTF8.GetBytes($"{connection.Username}:{connection.Password}"));
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
_http.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
}
// ---------------------------------------------------------------
// Öffentliche API
// ---------------------------------------------------------------
/// <summary>
/// Prüft die Verbindung zur Management Console.
/// MfApi: Domain-Manager über ConnectionUrl + SMC-Credentials.
/// WinRm/LocalCmd: Script-Laufzeit.
/// Http: Probe gegen bekannte API-Pfade.
/// </summary>
public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
(bool ok, string? error) = await _mfApi!.TestConnectionAsync(cancellationToken);
string modeLabel =
$"MfApi {_connection.ConnectionUrl} Domain={_connection.DomainName}";
return (ok, error, ok ? modeLabel : null);
}
if (UsesScripts)
{
(bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken);
string modeLabel = Mode == SonicManagementMode.LocalCmd
? $"LocalCmd (SonicHome={_connection.SonicHome})"
: $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}";
return (ok, error, ok ? modeLabel : null);
}
try
{
string? path = await ResolveContainerBasePathAsync(cancellationToken);
if (path is null)
{
return (false,
$"Keine Sonic HTTP-API unter {_http!.BaseAddress} gefunden.\n" +
$"Geprüfte Pfade: {string.Join(", ", GetCandidatePaths())}\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen (ConnectionUrl + SMC-Logins).",
null);
}
return (true, null, path);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return (false,
$"HTTP-Timeout nach {_connection.TimeoutSeconds}s Port {_connection.ManagementHttpPort} nicht erreichbar.\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen.",
null);
}
catch (Exception ex)
{
return (false, ex.Message, null);
}
}
/// <summary>
/// Listet alle Container der Domain auf.
/// </summary>
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
return await GetContainersViaMfApiAsync(cancellationToken);
}
if (UsesScripts)
{
return await GetContainersViaScriptAsync(cancellationToken);
}
return await GetContainersViaHttpAsync(cancellationToken);
}
/// <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)
{
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);
if (!ok)
{
return (false, "Neustart fehlgeschlagen (MfApi)",
$"IAgentProxy.restart für '{containerName}' (Domain '{_connection.DomainName}') " +
$"über {_connection.ConnectionUrl} fehlgeschlagen.\n" +
$"Fehler: {error}\nAusgabe: {output}\n" +
"Voraussetzungen: Domain Manager erreichbar, SMC-Logins korrekt, " +
"JRE (JavaHome/SonicHome\\jre/JAVA_HOME) + Sonic-Client-JARs (SonicHome\\lib), Container online.");
}
await Task.Delay(
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
cancellationToken);
return (true, $"Container '{containerName}' neugestartet (MfApi)",
$"Domain={_connection.DomainName}; ConnectionUrl={_connection.ConnectionUrl}\n{output}");
}
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] + "…";
public void Dispose()
{
_http?.Dispose();
}
}