Files
123123/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs
T

522 lines
20 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 = WinRm (Standard):
/// PowerShell Remoting (Invoke-Command) → CMD stop/startcontainer auf dem Sonic-Server.
///
/// Modus = LocalCmd:
/// Dieselbe CMD/PowerShell-Logik lokal (Test direkt auf dem Sonic-PC).
///
/// 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;
// 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 bool UsesScripts =>
_connection.ManagementMode is SonicManagementMode.WinRm or SonicManagementMode.LocalCmd;
public SonicManagementClient(SonicConnection connection)
{
_connection = connection;
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.
/// WinRm: TCP-Ping auf WinRM-Port + Test-PSSession.
/// Http: Probe gegen bekannte API-Pfade.
/// </summary>
public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync(
CancellationToken cancellationToken = default)
{
if (UsesScripts)
{
(bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken);
string modeLabel = _connection.ManagementMode == 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 'WinRm' setzen falls kein HTTP-API vorhanden.",
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 'WinRm' 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 (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 (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 (UsesScripts)
{
return await ImportXapiViaScriptAsync(containerName, xapiSourcePath, cancellationToken);
}
return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken);
}
// ---------------------------------------------------------------
// 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 ({_connection.ManagementMode}): {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 = _connection.ManagementMode.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();
}
}