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; /// /// 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. /// 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 // --------------------------------------------------------------- /// /// Prüft die Verbindung zur Management Console. /// MfApi: Domain-Manager über ConnectionUrl + SMC-Credentials. /// WinRm/LocalCmd: Script-Laufzeit. /// Http: Probe gegen bekannte API-Pfade. /// 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); } } /// /// Listet alle Container der Domain auf. /// public async Task<(bool Success, IReadOnlyList ContainerNames, string? Error)> GetContainersAsync( CancellationToken cancellationToken = default) { if (UsesMfApi) { return await GetContainersViaMfApiAsync(cancellationToken); } if (UsesScripts) { return await GetContainersViaScriptAsync(cancellationToken); } return await GetContainersViaHttpAsync(cancellationToken); } /// /// Startet den Container neu. /// 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); } /// /// Importiert XApi-Ressourcen und startet den Container neu. /// 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?)> GetContainersViaMfApiAsync( CancellationToken cancellationToken) { (bool ok, IReadOnlyList 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 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}"); } // Kein Java / keine Client-JARs → offizieller Doku-Weg über stop/startcontainer.bat bool softFail = error?.Contains("Java nicht gefunden", StringComparison.OrdinalIgnoreCase) == true || error?.Contains("Client-JARs", StringComparison.OrdinalIgnoreCase) == true || error?.Contains("lib", StringComparison.OrdinalIgnoreCase) == true; if (softFail) { SonicBinRestartExecutor bin = new(_connection); (bool binOk, string binStatus, string? binDetail) = await bin.RestartAsync(containerName, cancellationToken); if (binOk) { return (true, binStatus, $"MfApi nicht nutzbar ({Truncate(error ?? string.Empty, 160)}) – Fallback SonicBin:\n{binDetail}"); } return (false, "Neustart fehlgeschlagen (MfApi→SonicBin)", $"MfApi: {error}\n\nSonicBin-Fallback: {binDetail}\n" + $"SonicHome='{_connection.SonicHome}' muss stopcontainer.bat enthalten."); } return (false, "Neustart fehlgeschlagen (MfApi)", $"IAgentProxy.restart für '{containerName}' (Domain '{_connection.DomainName}') " + $"über {_connection.ConnectionUrl} fehlgeschlagen.\n" + $"Fehler: {error}\nAusgabe: {output}"); } private static async Task 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?)> 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 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?)> 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 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 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 ParseContainerNames(string json) { try { using JsonDocument doc = JsonDocument.Parse(json); JsonElement root = doc.RootElement; List names = []; IEnumerable 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(); }) : []; 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(); } }