Files
123123/ZA.CoreService.ESBCertificateManager/Services/SonicManagementClient.cs
T
GizzlerandCursor a89a7ad4b6 Fix MfApi ClassNotFound by passing Java classpath via ArgumentList.
Use ct-ZADBService and explicit MfClientLibPath so SMC restart finds client JARs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:16:01 +02:00

771 lines
31 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, 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.
/// </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);
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);
}
if (UsesScripts)
{
if (Mode == SonicManagementMode.LocalCmd)
{
(bool binOk, string? binError, string? binDetail) = new SonicBinRestartExecutor(_connection).Probe();
return binOk
? (true, null, $"LocalCmd/SonicBin {binDetail}")
: (false, binError, null);
}
(bool ok, string? error) = await _scriptRunner!.TestConnectionAsync(cancellationToken);
return ok
? (true, null, $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}")
: (false, error, null);
}
try
{
string? path = await ResolveContainerBasePathAsync(cancellationToken);
if (path is null)
{
return (false,
$"Keine Sonic HTTP-API unter {_http!.BaseAddress} gefunden.\n" +
$"Geprüfte Pfade: {string.Join(", ", GetCandidatePaths())}\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen (ConnectionUrl + SMC-Logins).",
null);
}
return (true, null, path);
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return (false,
$"HTTP-Timeout nach {_connection.TimeoutSeconds}s Port {_connection.ManagementHttpPort} nicht erreichbar.\n" +
$"Tipp: ManagementMode auf 'MfApi' setzen.",
null);
}
catch (Exception ex)
{
return (false, ex.Message, null);
}
}
/// <summary>
/// Listet alle Container der Domain auf.
/// </summary>
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
CancellationToken cancellationToken = default)
{
if (UsesMfApi)
{
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)
{
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);
if (ok)
{
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}");
}
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: stopcontainer.bat wird nicht verwendet (SMC-only).");
}
private static async Task<bool> IsTcpReachableAsync(string connectionUrl, CancellationToken cancellationToken)
{
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);
return true;
}
catch
{
return false;
}
}
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();
}
}