diff --git a/CHANGELOG.md b/CHANGELOG.md index be1493f..1f8392b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.2.0] - 2026-08-20 + +### Added + +- Status-bar messages for port-forward start, stop, restore, and failure. +- Local / Remote labels on the port-forward bar so the two port fields are explicit. +- Rebind the host port of an existing forward from **Network → Port Forwarding** (Local field + Rebind). +- Double-click a live port-forward in **Network → Port Forwarding** to open `http://127.0.0.1:{local}` in the default browser. + +### Changed + +- Active port-forwards are listed in **Network → Port Forwarding** (stop from the table). They are no longer shown in the status bar. +- Resource table footer, YAML/Data edit bars, and workload limit apply buttons show only for a selected row that can use them. Empty toolbars are hidden. +- Enabled port-forwards are saved in `appsettings.json` (`Configuration:PortForwards`) and restored when a cluster reconnects. + +### Fixed + +- Service port-forward resolves a backend pod from `spec.selector` and maps the service port to `targetPort`, matching kubectl. +- Service related-pods follow `spec.selector` only, so Helm siblings (for example Longhorn CSI vs UI) are not mixed; named `targetPort` is resolved on a pod that actually declares it. +- Port-forward tunnels use the Kubernetes port-forward multiplex protocol (`StreamType.PortForward`, channel 0, one stream per local connection) instead of exec stdin/stdout. +- Port Forwarding **Remote** is the requested service/pod port (for example `80`), not the mapped container port (`8000` for Longhorn UI). Open `http://127.0.0.1:{local}`. +- Port-forward listens on both IPv4 and IPv6 loopback so `localhost` works on Windows. The IPv6 listener is IPv6-only so it does not collide with IPv4 on Windows. +- Port-forward opens multiplex streams before starting the demuxer, then copies with blocking socket I/O, matching the Kubernetes C# client port-forward example. +- Persisted port-forwards re-resolve a Running pod by Service/workload owner or stable labels after replica recreation, including on each new local connection. + ## [0.1.1] - 2026-08-19 ### Fixed diff --git a/README.md b/README.md index f44002c..69832f3 100644 --- a/README.md +++ b/README.md @@ -3,30 +3,79 @@ ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-50.9%25-yellowgreen) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-41.5%25-yellowgreen) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-56.7%25-yellowgreen) +![.NET](https://img.shields.io/badge/.NET-10-512BD4) +![License](https://img.shields.io/badge/License-Apache%202.0-blue) +![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) -Cross-platform Kubernetes desktop console (Avalonia). Lens-style catalog, navigator, resource tables, YAML apply, logs, exec, port-forward, Helm releases, and Dapr CRDs. Talks to the cluster through the official **KubernetesClient** NuGet package and the same kubeconfig/RBAC as kubectl. +Desktop console for Kubernetes clusters. Native Avalonia app on Windows and Linux: browse resources, apply YAML, follow logs, exec into pods, and reach workload UIs through localhost. + +Cluster access uses the official Kubernetes .NET client and the same kubeconfig and RBAC as any other API client. Contexts are edited in-process; the kubeconfig file on disk is the source of truth. See [LICENSE.md](LICENSE.md) (Apache 2.0). Changes: [CHANGELOG.md](CHANGELOG.md). Contributing: [CONTRIBUTING.md](CONTRIBUTING.md). +If you find this project useful, please consider supporting its development: + +[Buy Me A Coffee](https://www.buymeacoffee.com/maksitcom) + +## Highlights + +Capabilities that are first-class in ClusterConsole, not afterthoughts: + +- **Port-forward, then open the UI** — forwards live under **Network → Port Forwarding**. Double-click a live row to open `http://127.0.0.1:{port}` in the default browser. Enabled forwards persist, restore on reconnect, survive pod recreation (owner or stable labels), and can rebind the local port without recreating the tunnel. +- **Local Ollama chat on the selection** — diagnose the highlighted resource with an on-machine model. The assistant can read cluster issues, YAML, logs, and events. Nothing is sent to a cloud AI API. Chat cannot apply, restart, or delete. +- **Dapr in the navigator** — Components, Configurations, Subscriptions, Resiliency, HTTPEndpoints, sidecars, and control-plane pods as catalog views, not a generic CRD dump. +- **Volume files** — browse, edit, download, and upload files on persistent volumes and claims from the desktop. +- **Limits you can fix** — overview shows container CPU and memory against node capacity and can patch limits that oversubscribe the node. +- **Connections stay in the app** — wizard to add or update a context (token, client certificate, or basic auth) and switch current-context without a separate kubeconfig editor. + +## Features + +- **Contexts** — catalog of kubeconfig contexts; mark and switch the current context +- **Navigator** — Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources +- **Resource tables** — list and refresh any catalogued type; per-column filters; namespace scope persisted per context +- **Inspect and apply** — YAML view, apply, create, delete; force-delete (grace period 0 and strip finalizers), including namespaces whose objects are already gone +- **Workloads** — scale, restart, CronJob trigger; node cordon and drain +- **Pods** — follow logs, exec +- **Applications** — one row per instance and namespace from standard application labels +- **Helm** — releases discovered from cluster secrets +- **Metrics** — CPU and memory columns when the metrics API is available + ## Requirements - [.NET 10 SDK](https://dotnet.microsoft.com/download) -- A kubeconfig (`KUBECONFIG` or `~/.kube/config`) +- A kubeconfig (`KUBECONFIG` or `~/.kube/config`) with permission to the target cluster - Windows or Linux +- Optional: a local [Ollama](https://ollama.com) daemon for Chat -## Build +## Getting started From `src/` so `global.json` applies: ```powershell cd src dotnet build MaksIT.ClusterConsole.slnx +dotnet run --project MaksIT.ClusterConsole.UI ``` -Run the UI: +Connect a context from the catalog, pick a navigator item, then use the table, details pane, and footer actions for the selected row. -```powershell -dotnet run --project MaksIT.ClusterConsole.UI +## Configuration + +Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied next to the UI). Notable keys under `Configuration`: + +| Key | Role | +|-----|------| +| `OllamaEndpoint` | Chat API, default `http://127.0.0.1:11434` | +| `OllamaModel` | Chat model, default `qwen3:8b` | +| `PortForwards` | Enabled localhost forwards; restored when the cluster reconnects | +| `Layout` | Window and pane sizes, last navigator item, column state | + +Port-forwards are saved when you start them in the UI. Chat cannot apply, restart, or delete. + +Pull the default Chat model once: + +```bash +ollama pull qwen3:8b ``` ## Tests @@ -35,23 +84,13 @@ dotnet run --project MaksIT.ClusterConsole.UI utils\Invoke-TestEngine.bat ``` -Or from `src/`: +From `src/`: ```powershell dotnet test MaksIT.ClusterConsole.Tests ``` -## Local Ollama chat - -Default model is **`qwen3:8b`** (~5.2GB) — the strongest Qwen chat/reasoning tag that still fits an RTX 3060 12GB with KV cache headroom. Endpoint `http://127.0.0.1:11434` (override `Configuration:OllamaEndpoint` / `OllamaModel` in `appsettings.json`). - -```bash -ollama pull qwen3:8b -``` - -Fallback if tool calling is weak: `qwen2.5:7b`. Do not use `qwen3:14b` or `deepseek-r1:14b` as the default on 12GB (stretch only). - -Open a resource table, pick a row, open **Chat**, then ask e.g. `What is wrong with this pod?`. The assistant can read issues, YAML, logs, and events. It cannot apply, restart, or delete. +Tests use kubeconfig fixtures and do not require a live cluster. Coverage shields at the top of this file are maintained by the test engine (**CoverageBadges**). ## Release @@ -59,22 +98,24 @@ Open a resource table, pick a row, open **Chat**, then ask e.g. `What is wrong w 2. Tag `v{version}` on `main`. 3. Run `utils\Invoke-ReleasePackage.bat`. -## What it is +## Solution layout -- Catalog of kubeconfig contexts (`*` marks kubectl `current-context`) -- Connections editor and wizard to add/upsert a context (token, cert, k3s data, or basic auth) and switch kubectl `current-context` -- Navigator: icon categories with collapsible sub-items (Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources) -- Generic GVR browser (list/watch-by-refresh, YAML, apply, delete) with per-column header filters (type to filter rows and the value list; checkboxes; double-click a value to keep only that one). Namespace scope is the Namespace column filter (persisted per context). -- Workload scale/restart, node cordon/drain, CronJob trigger -- Force delete (grace period 0 + strip finalizers); force-delete namespace from the Namespaces view, including orphaned sandboxes whose Namespace object is already gone -- Pod logs (follow), exec, port-forward dock -- Helm releases from secrets (`owner=helm`) -- Applications view: one row per instance/namespace from `app.kubernetes.io/instance` (or `name` if instance is missing) -- Dapr Components/Configurations/Subscriptions/Resiliency/HTTPEndpoints, sidecars, control-plane pods -- CPU/MEM columns when `metrics.k8s.io` is available -- Overview resource-limits table: inspect and patch container CPU/memory limits when they exceed node capacity -- **Chat** tab (local Ollama): diagnose the selected resource with read-only cluster tools +```text +utils/ # RepoUtils test and release engines +src/ + MaksIT.ClusterConsole.slnx + MaksIT.ClusterConsole.Client/ # Kubernetes API client + MaksIT.ClusterConsole.Shared/ # catalog, workspace, configuration + MaksIT.ClusterConsole.UI/ # Avalonia desktop host + MaksIT.ClusterConsole.Tests/ +``` -## What it is not +## Scope -kubectl CLI wrapper, Lens extensions, Vault users/scopes, k9s plugins, Dapr state/JetStream admin. Kubeconfig editing is in-process (same file kubectl uses), not a kubectl.exe wrapper. +ClusterConsole is a desktop operator console for the Kubernetes API. It is not a CLI, a cluster installer, or a replacement for admission, GitOps, or secret-management systems. Helm listing and Dapr views cover objects in the cluster; they do not install charts or administer Dapr building blocks. + +## License + +Apache 2.0 — see [LICENSE.md](LICENSE.md). + +© Maksym Sadovnychyy (MAKS-IT) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b8c2d00..ffc8ae1 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.1.1 + 0.2.0 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Client/ClusterModels.cs b/src/MaksIT.ClusterConsole.Client/ClusterModels.cs index 04d0eb1..1780ec2 100644 --- a/src/MaksIT.ClusterConsole.Client/ClusterModels.cs +++ b/src/MaksIT.ClusterConsole.Client/ClusterModels.cs @@ -100,27 +100,45 @@ public sealed record HelmReleaseInfo( string AppVersion, DateTimeOffset? Updated); +public sealed record PortForwardEndpoint(string PodName, string Namespace, int ContainerPort); + public sealed class PortForwardHandle : IDisposable { private readonly IDisposable _inner; private readonly Action? _onDispose; - public PortForwardHandle(string podName, string @namespace, int containerPort, int localPort, IDisposable inner, Action? onDispose = null) { + public PortForwardHandle( + string podName, + string @namespace, + int containerPort, + int localPort, + IDisposable inner, + Action? onDispose = null, + int requestedPort = 0) { PodName = podName; Namespace = @namespace; ContainerPort = containerPort; LocalPort = localPort; + RequestedPort = requestedPort > 0 ? requestedPort : containerPort; _inner = inner; _onDispose = onDispose; } - public string PodName { get; } + public string PodName { get; private set; } - public string Namespace { get; } + public string Namespace { get; private set; } - public int ContainerPort { get; } + public int ContainerPort { get; private set; } + + public int RequestedPort { get; } public int LocalPort { get; } + public void Retarget(string podName, string @namespace, int containerPort) { + PodName = podName; + Namespace = @namespace; + ContainerPort = containerPort; + } + public void Dispose() { _onDispose?.Invoke(); _inner.Dispose(); diff --git a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs index 278f6b6..7fd0c36 100644 --- a/src/MaksIT.ClusterConsole.Client/ClusterSession.cs +++ b/src/MaksIT.ClusterConsole.Client/ClusterSession.cs @@ -371,32 +371,28 @@ public sealed class ClusterSession : IClusterSession { string @namespace, int containerPort, int localPort, + int requestedPort = 0, + Func>>? resolveTarget = null, CancellationToken cancellationToken = default) { try { - var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync( - podName, - @namespace, - [containerPort], - cancellationToken: cancellationToken).ConfigureAwait(false); - - var demux = new StreamDemuxer(webSocket); - demux.Start(); - var listener = new TcpListener(IPAddress.Loopback, localPort); - listener.Start(); + var listeners = BindLoopback(localPort); var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _ = AcceptAsync(listener, demux, containerPort, cts.Token); - - return Result.Ok(new PortForwardHandle( + var handle = new PortForwardHandle( podName, @namespace, containerPort, localPort, - demux, + cts, () => { cts.Cancel(); - listener.Stop(); - webSocket.Dispose(); - })); + foreach (var listener in listeners) + listener.Stop(); + }, + requestedPort); + foreach (var listener in listeners) + _ = AcceptAsync(listener, handle, resolveTarget, cts.Token); + + return Result.Ok(handle); } catch (Exception ex) { return KubernetesResult.Map(ex); @@ -1024,31 +1020,149 @@ public sealed class ClusterSession : IClusterSession { private static string? Label(IDictionary? labels, string key) => labels is not null && labels.TryGetValue(key, out var value) ? value : null; - private static async Task AcceptAsync(TcpListener listener, StreamDemuxer demux, int port, CancellationToken cancellationToken) { + private static List BindLoopback(int port) { + SocketException? last = null; + var listeners = new List(2); + foreach (var address in new[] { IPAddress.Loopback, IPAddress.IPv6Loopback }) { + try { + var listener = new TcpListener(address, port); + if (address.AddressFamily == AddressFamily.InterNetworkV6) + listener.Server.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, true); + + listener.Start(); + listeners.Add(listener); + } + catch (SocketException ex) { + last = ex; + } + } + + if (listeners.Count == 0) + throw last ?? new SocketException((int)SocketError.AddressNotAvailable); + + return listeners; + } + + private async Task AcceptAsync( + TcpListener listener, + PortForwardHandle handle, + Func>>? resolveTarget, + CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { var client = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false); - _ = Task.Run(() => PumpAsync(client, demux, port, cancellationToken), cancellationToken); + client.NoDelay = true; + _ = PumpConnectionAsync(client, handle, resolveTarget, cancellationToken); } } catch (OperationCanceledException) { } catch (ObjectDisposedException) { } + catch (SocketException) { + } } - private static async Task PumpAsync(TcpClient tcp, StreamDemuxer demux, int port, CancellationToken cancellationToken) { + private async Task PumpConnectionAsync( + TcpClient tcp, + PortForwardHandle handle, + Func>>? resolveTarget, + CancellationToken cancellationToken) { + StreamDemuxer? demux = null; try { - var remote = demux.GetStream(ChannelIndex.StdOut, ChannelIndex.StdIn); - var local = tcp.GetStream(); + var podName = handle.PodName; + var @namespace = handle.Namespace; + var containerPort = handle.ContainerPort; + if (resolveTarget is not null) { + var resolved = await resolveTarget(cancellationToken).ConfigureAwait(false); + if (!resolved.IsSuccess || resolved.Value is null) { + tcp.Dispose(); + return; + } + + podName = resolved.Value.PodName; + @namespace = resolved.Value.Namespace; + containerPort = resolved.Value.ContainerPort; + handle.Retarget(podName, @namespace, containerPort); + } + + var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync( + podName, + @namespace, + [containerPort], + WebSocketProtocol.V4BinaryWebsocketProtocol, + cancellationToken: cancellationToken).ConfigureAwait(false); + demux = new StreamDemuxer(webSocket, StreamType.PortForward, ownsSocket: true); + var stream = demux.GetStream((byte?)0, (byte?)0); + var errors = demux.GetStream((byte?)1, null); + demux.Start(); + _ = Task.Run(() => Drain(errors), cancellationToken); + var socket = tcp.Client; + using var copyCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await Task.WhenAny( - local.CopyToAsync(remote, cancellationToken), - remote.CopyToAsync(local, cancellationToken)).ConfigureAwait(false); + Task.Run(() => CopySocketToStream(socket, stream, copyCts.Token), copyCts.Token), + Task.Run(() => CopyStreamToSocket(stream, socket, copyCts.Token), copyCts.Token)).ConfigureAwait(false); + copyCts.Cancel(); + } + catch (OperationCanceledException) { } catch { } finally { tcp.Dispose(); + demux?.Dispose(); + } + } + + private static void CopySocketToStream(Socket socket, Stream stream, CancellationToken cancellationToken) { + var buffer = new byte[16 * 1024]; + try { + while (!cancellationToken.IsCancellationRequested && socket.Connected) { + var read = socket.Receive(buffer); + if (read == 0) + break; + + stream.Write(buffer, 0, read); + } + } + catch (SocketException) { + } + catch (ObjectDisposedException) { + } + catch (IOException) { + } + } + + private static void CopyStreamToSocket(Stream stream, Socket socket, CancellationToken cancellationToken) { + var buffer = new byte[16 * 1024]; + try { + while (!cancellationToken.IsCancellationRequested && socket.Connected) { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) + break; + + var sent = 0; + while (sent < read) + sent += socket.Send(buffer, sent, read - sent, SocketFlags.None); + } + } + catch (SocketException) { + } + catch (ObjectDisposedException) { + } + catch (IOException) { + } + } + + private static void Drain(Stream stream) { + var buffer = new byte[256]; + try { + while (stream.Read(buffer, 0, buffer.Length) > 0) { + } + } + catch (ObjectDisposedException) { + } + catch (IOException) { } } } diff --git a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs index 653d53d..e68b365 100644 --- a/src/MaksIT.ClusterConsole.Client/IClusterSession.cs +++ b/src/MaksIT.ClusterConsole.Client/IClusterSession.cs @@ -67,6 +67,8 @@ public interface IClusterSession : IDisposable { string @namespace, int containerPort, int localPort, + int requestedPort = 0, + Func>>? resolveTarget = null, CancellationToken cancellationToken = default); Task>> ListCustomResourceDefinitionsAsync( diff --git a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs index 995edb1..1cf1db2 100644 --- a/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs +++ b/src/MaksIT.ClusterConsole.Shared/ClusterWorkspace.cs @@ -143,7 +143,7 @@ public sealed partial class ClusterWorkspace { return new Result>(null, false, listed.Messages, listed.StatusCode); var related = (listed.Value ?? []) - .Where(p => Owns(p, owner.Document)) + .Where(p => ResourceOwnership.Owns(p, owner.Document)) .Select(p => ResourceRow.From(p, pods)) .ToList(); return Result>.Ok(related); @@ -367,34 +367,6 @@ public sealed partial class ClusterWorkspace { return ApplicationManifest.WorkloadNames(row.Document).Contains(name, StringComparer.Ordinal); } - private static bool Owns(JsonObject pod, JsonObject owner) { - var ownerName = JsonPath.Name(owner); - var refs = pod["metadata"]?["ownerReferences"] as JsonArray; - if (refs?.OfType().Any(r => r["name"]?.ToString() == ownerName) == true) - return true; - - var matchLabels = owner["spec"]?["selector"]?["matchLabels"] as JsonObject; - if (LabelsMatch(pod["metadata"]?["labels"] as JsonObject, matchLabels)) - return true; - - var labels = pod["metadata"]?["labels"] as JsonObject; - return labels?["app"]?.ToString() == ownerName - || labels?[ApplicationManifest.NameKey]?.ToString() == ownerName - || ApplicationManifest.SameInstance(pod, owner); - } - - private static bool LabelsMatch(JsonObject? podLabels, JsonObject? required) { - if (podLabels is null || required is null || required.Count == 0) - return false; - - foreach (var pair in required) { - if (podLabels[pair.Key]?.ToString() != pair.Value?.ToString()) - return false; - } - - return true; - } - private static bool Matches(ResourceRow row, string? filter) { if (string.IsNullOrWhiteSpace(filter)) return true; @@ -412,7 +384,11 @@ public sealed partial class ClusterWorkspace { "Applications", ResourceCatalog.Applications, ResourceCatalog.ApplicationsDescriptor), - Special(ResourceCatalog.PortForwardingId, "Port Forwarding", ResourceCatalog.Network), + Special( + ResourceCatalog.PortForwardingId, + "Port Forwarding", + ResourceCatalog.Network, + ResourceCatalog.PortForwardingDescriptor), Special(ResourceCatalog.HelmChartsId, "Charts", ResourceCatalog.Helm), Special(ResourceCatalog.HelmReleasesId, "Releases", ResourceCatalog.Helm), Special(ResourceCatalog.DaprSidecarsId, "Sidecars", ResourceCatalog.Dapr), diff --git a/src/MaksIT.ClusterConsole.Shared/Configuration.cs b/src/MaksIT.ClusterConsole.Shared/Configuration.cs index 952991d..fdee2e7 100644 --- a/src/MaksIT.ClusterConsole.Shared/Configuration.cs +++ b/src/MaksIT.ClusterConsole.Shared/Configuration.cs @@ -21,6 +21,8 @@ public sealed class Configuration { public LayoutSettings Layout { get; set; } = new(); + public List PortForwards { get; set; } = []; + public void EnsureDefaults() { OpenContexts ??= []; NamespacesByContext ??= new Dictionary(StringComparer.Ordinal); @@ -29,6 +31,7 @@ public sealed class Configuration { Layout.ColumnWidths ??= new Dictionary>(StringComparer.Ordinal); Layout.ColumnFilters ??= new Dictionary>(StringComparer.Ordinal); Layout.SearchByResource ??= new Dictionary(StringComparer.Ordinal); + PortForwards ??= []; if (string.IsNullOrWhiteSpace(OllamaEndpoint)) OllamaEndpoint = ClusterChatService.DefaultEndpoint; if (string.IsNullOrWhiteSpace(OllamaModel)) @@ -64,4 +67,41 @@ public sealed class Configuration { SelectedNamespace = ns; ActiveContext = contextName; } + + public IReadOnlyList PortForwardsFor(string context) => + (PortForwards ?? []) + .Where(p => string.Equals(p.Context, context, StringComparison.Ordinal)) + .ToList(); + + public void UpsertPortForward(PersistedPortForward forward) { + ArgumentNullException.ThrowIfNull(forward); + PortForwards ??= []; + PortForwards.RemoveAll(p => SamePortForward(p, forward.Context, forward.LocalPort)); + PortForwards.Add(forward); + } + + public void RemovePortForward(string context, int localPort) { + PortForwards?.RemoveAll(p => SamePortForward(p, context, localPort)); + } + + private static bool SamePortForward(PersistedPortForward item, string context, int localPort) => + string.Equals(item.Context, context, StringComparison.Ordinal) && item.LocalPort == localPort; +} + +public sealed class PersistedPortForward { + public string Context { get; set; } = ""; + + public string Kind { get; set; } = "Pod"; + + public string Name { get; set; } = ""; + + public string Namespace { get; set; } = "default"; + + public string PodName { get; set; } = ""; + + public int LocalPort { get; set; } + + public int RemotePort { get; set; } + + public Dictionary? MatchLabels { get; set; } } diff --git a/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs b/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs new file mode 100644 index 0000000..b24b2a4 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/PortForwardRow.cs @@ -0,0 +1,104 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using MaksIT.ClusterConsole.Client; + + +namespace MaksIT.ClusterConsole.Shared; + +public sealed record PortForwardRestoreSummary(int Restored, IReadOnlyList Failures) { + public int Total => Restored + Failures.Count; + + public string Format() { + if (Failures.Count == 0) { + if (Restored == 1) + return "Restored 1 port-forward."; + + return $"Restored {Restored} port-forwards."; + } + + var failed = string.Join("; ", Failures); + if (Restored == 0) + return $"Port-forward restore failed: {failed}"; + + return $"Restored {Restored} port-forward(s); {Failures.Count} failed: {failed}"; + } +} + +public static class PortForwardRow { + public static string Uid(int localPort) => + $"pf:{localPort.ToString(CultureInfo.InvariantCulture)}"; + + public static string StartedMessage(PortForwardHandle handle) => + $"Port-forward started: http://127.0.0.1:{handle.LocalPort} → {handle.Namespace}/{handle.PodName}:{handle.RequestedPort}."; + + public static string FailedMessage(IEnumerable messages) => + $"Port-forward failed: {string.Join("; ", messages)}"; + + public static string ReboundMessage(int previousLocalPort, PortForwardHandle handle) => + $"Port-forward rebound: localhost:{previousLocalPort} → http://127.0.0.1:{handle.LocalPort} → {handle.Namespace}/{handle.PodName}:{handle.RequestedPort}."; + + public static string LocalUrl(int localPort) => + $"http://127.0.0.1:{localPort.ToString(CultureInfo.InvariantCulture)}/"; + + public static bool TryLocalUrl(ResourceRow row, out string url) { + if (!TryLocalPort(row, out var localPort)) { + url = ""; + return false; + } + + url = LocalUrl(localPort); + return true; + } + + public static bool TryLocalPort(ResourceRow row, out int localPort) { + localPort = 0; + if (row.Document["localPort"] is JsonValue value && value.TryGetValue(out localPort) && localPort > 0) + return true; + + var uid = row.Uid; + return uid.StartsWith("pf:", StringComparison.Ordinal) + && int.TryParse(uid[3..], CultureInfo.InvariantCulture, out localPort) + && localPort > 0; + } + + public static ResourceRow From(PortForwardHandle handle, string uid, string status = "Active") => + From( + uid, + handle.PodName, + handle.Namespace, + handle.LocalPort, + handle.RequestedPort, + status); + + public static ResourceRow FromPersisted(PersistedPortForward saved, string status) => + From( + Uid(saved.LocalPort), + saved.PodName, + saved.Namespace, + saved.LocalPort, + saved.RemotePort, + status); + + private static ResourceRow From( + string uid, + string podName, + string? @namespace, + int localPort, + int remotePort, + string status) { + var local = localPort.ToString(CultureInfo.InvariantCulture); + var document = new JsonObject { + ["kind"] = "PortForward", + ["metadata"] = new JsonObject { + ["name"] = $"localhost:{local}", + ["namespace"] = @namespace, + ["uid"] = uid + }, + ["pod"] = podName, + ["localPort"] = localPort, + ["containerPort"] = remotePort, + ["status"] = status + }; + return ResourceRow.From(document, ResourceCatalog.PortForwardingDescriptor); + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs index ac3c51c..e5ec162 100644 --- a/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs +++ b/src/MaksIT.ClusterConsole.Shared/ResourceCatalog.cs @@ -66,10 +66,32 @@ public static class ResourceCatalog { new ResourceActions(CanScale: false, CanRestart: false, CanApply: false), ["Overview", "YAML", "Events", "Pods", "Logs", "Terminal"]); + public static ResourceDescriptor PortForwardingDescriptor { get; } = new( + PortForwardingId, + "Port Forwarding", + Network, + "", + "v1", + "portforwards", + "PortForward", + true, + [ + new("Name", "metadata.name"), + new("Namespace", "metadata.namespace"), + new("Pod", "pod"), + new("Local", "localPort"), + new("Remote", "containerPort"), + new("Status", "status") + ], + new ResourceActions(CanDelete: false, CanApply: false), + ["Overview"]); + public static ResourceDescriptor? Find(string id) => - id == ApplicationsId - ? ApplicationsDescriptor - : BuiltIns.FirstOrDefault(d => d.Id == id); + id switch { + ApplicationsId => ApplicationsDescriptor, + PortForwardingId => PortForwardingDescriptor, + _ => BuiltIns.FirstOrDefault(d => d.Id == id) + }; public static ResourceDescriptor? FindByGvk(string? apiVersion, string? kind) { if (string.IsNullOrWhiteSpace(kind)) @@ -137,6 +159,7 @@ public static class ResourceCatalog { var yamlTabs = new[] { "Overview", "YAML", "Events" }; var podTabs = new[] { "Overview", "YAML", "Events", "Logs", "Terminal" }; var workloadTabs = new[] { "Overview", "YAML", "Events", "Pods", "Logs", "Terminal" }; + var serviceTabs = new[] { "Overview", "YAML", "Events", "Pods" }; var crud = new ResourceActions(); var scale = new ResourceActions(CanScale: true, CanRestart: true); var logs = new ResourceActions(CanLogs: true, CanExec: true, CanPortForward: true); @@ -193,7 +216,7 @@ public static class ResourceCatalog { D("validatingwebhookconfigurations", "Validating Webhooks", Config, "admissionregistration.k8s.io", "v1", "validatingwebhookconfigurations", "ValidatingWebhookConfiguration", false, named, crud, yamlTabs), D("services", "Services", Network, "", "v1", "services", "Service", true, [..std, new("Type", "spec.type"), new("Cluster IP", "spec.clusterIP"), new("External IP", "service.externalIP"), new("Ports", "spec.ports")], - new ResourceActions(CanPortForward: true), yamlTabs), + new ResourceActions(CanPortForward: true), serviceTabs), D("endpoints", "Endpoints", Network, "", "v1", "endpoints", "Endpoints", true, std, crud, yamlTabs), D("endpointslices", "Endpoint Slices", Network, "discovery.k8s.io", "v1", "endpointslices", "EndpointSlice", true, std, crud, yamlTabs), D("ingresses", "Ingresses", Network, "networking.k8s.io", "v1", "ingresses", "Ingress", true, diff --git a/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs b/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs new file mode 100644 index 0000000..f33c3ad --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/ResourceOwnership.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; + + +namespace MaksIT.ClusterConsole.Shared; + +public static class ResourceOwnership { + public static bool Owns(JsonObject pod, JsonObject owner) { + var ownerName = JsonPath.Name(owner); + var refs = pod["metadata"]?["ownerReferences"] as JsonArray; + if (refs?.OfType().Any(r => r["name"]?.ToString() == ownerName) == true) + return true; + + var matchLabels = SelectorLabels(owner); + if (matchLabels is not null && matchLabels.Count > 0) + return LabelsMatch(pod["metadata"]?["labels"] as JsonObject, matchLabels); + + var labels = pod["metadata"]?["labels"] as JsonObject; + return labels?["app"]?.ToString() == ownerName + || labels?[ApplicationManifest.NameKey]?.ToString() == ownerName + || ApplicationManifest.SameInstance(pod, owner); + } + + public static JsonObject? SelectorLabels(JsonObject? owner) { + var selector = owner?["spec"]?["selector"]; + if (selector is not JsonObject obj) + return null; + if (obj["matchLabels"] is JsonObject match) + return match; + if (obj.ContainsKey("matchExpressions")) + return null; + + return obj; + } + + private static bool LabelsMatch(JsonObject? podLabels, JsonObject? required) { + if (podLabels is null || required is null || required.Count == 0) + return false; + + foreach (var pair in required) { + if (podLabels[pair.Key]?.ToString() != pair.Value?.ToString()) + return false; + } + + return true; + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs new file mode 100644 index 0000000..0f4c9f5 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Shared/ServicePortForward.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using MaksIT.Results; + + +namespace MaksIT.ClusterConsole.Shared; + +public sealed record PortForwardTarget(string PodName, string Namespace, int ContainerPort, int RequestedPort) { + public PortForwardTarget(string podName, string @namespace, int containerPort) + : this(podName, @namespace, containerPort, containerPort) { + } +} + +public static class ServicePortForward { + public static bool IsService(JsonObject? document) => + string.Equals(document?["kind"]?.GetValue(), "Service", StringComparison.OrdinalIgnoreCase); + + public static int? DefaultPort(JsonObject? service) { + var first = (service?["spec"]?["ports"] as JsonArray)?.OfType().FirstOrDefault(); + return first is not null && TryNumber(first["port"], out var port) ? port : null; + } + + private static readonly HashSet VolatileLabelKeys = new(StringComparer.Ordinal) { + "pod-template-hash", + "controller-revision-hash", + "statefulset.kubernetes.io/pod-name", + "apps.kubernetes.io/pod-index", + "batch.kubernetes.io/job-completion-index" + }; + + public static ResourceRow? PickPod(IReadOnlyList pods, ResourceRow? preferred) { + if (pods.Count == 0) + return null; + + if (preferred is not null + && IsRunning(preferred) + && pods.Any(p => IsSamePod(p, preferred))) + return preferred; + + return pods.FirstOrDefault(IsRunning) ?? pods[0]; + } + + public static ResourceRow? PickRunning( + IReadOnlyList pods, + string? preferredName, + IReadOnlyDictionary? labels = null) { + var pool = labels is { Count: > 0 } + ? pods.Where(p => HasLabels(p.Document, labels)).ToList() + : pods.ToList(); + if (pool.Count == 0) + return null; + + if (!string.IsNullOrWhiteSpace(preferredName)) { + var preferred = pool.FirstOrDefault(p => + string.Equals(p.Name, preferredName, StringComparison.Ordinal) && IsRunning(p)); + if (preferred is not null) + return preferred; + } + + return pool.FirstOrDefault(IsRunning); + } + + public static Dictionary? StableLabels(JsonObject? document) { + var selector = ResourceOwnership.SelectorLabels(document); + if (selector is not null && selector.Count > 0) + return ToLabels(selector, stripVolatile: false); + + var labels = document?["metadata"]?["labels"] as JsonObject; + return ToLabels(labels, stripVolatile: true); + } + + public static bool HasLabels(JsonObject? document, IReadOnlyDictionary required) { + if (required.Count == 0) + return true; + + var labels = document?["metadata"]?["labels"] as JsonObject; + if (labels is null) + return false; + + foreach (var pair in required) { + if (!string.Equals(JsonPath.Text(labels[pair.Key]), pair.Value, StringComparison.Ordinal)) + return false; + } + + return true; + } + + public static Result MapPort(JsonObject service, JsonObject? pod, int remotePort) { + var ports = service["spec"]?["ports"] as JsonArray; + var match = ports?.OfType().FirstOrDefault(p => + TryNumber(p["port"], out var port) && port == remotePort); + if (match is null) + return Result.Ok(remotePort); + + var target = match["targetPort"]; + if (target is null) + return Result.Ok(remotePort); + + if (TryNumber(target, out var number)) + return Result.Ok(number); + + var name = JsonPath.Text(target); + if (string.IsNullOrWhiteSpace(name)) + return Result.Ok(remotePort); + + var containerPort = FindNamedContainerPort(pod, name); + if (containerPort is null) + return Result.BadRequest(0, $"service targetPort '{name}' was not found on the selected pod"); + + return Result.Ok(containerPort.Value); + } + + public static Result Resolve( + JsonObject service, + IReadOnlyList pods, + ResourceRow? preferred, + int remotePort) { + var selector = ResourceOwnership.SelectorLabels(service); + if (selector is null || selector.Count == 0) + return Result.BadRequest(null, "Cannot port-forward a Service without a selector."); + + var pod = PickForwardPod(pods, preferred, service, remotePort); + if (pod is null) + return Result.NotFound(null, "No pods match this Service selector."); + + var mapped = MapPort(service, pod.Document, remotePort); + if (!mapped.IsSuccess) + return new Result(null, false, mapped.Messages, mapped.StatusCode); + + var ns = pod.Namespace ?? JsonPath.Namespace(service) ?? "default"; + return Result.Ok(new PortForwardTarget(pod.Name, ns, mapped.Value, remotePort)); + } + + private static ResourceRow? PickForwardPod( + IReadOnlyList pods, + ResourceRow? preferred, + JsonObject service, + int remotePort) { + ResourceRow? mappedFallback = null; + foreach (var candidate in OrderedPods(pods, preferred)) { + if (!MapPort(service, candidate.Document, remotePort).IsSuccess) + continue; + + if (IsSamePod(candidate, preferred) && IsRunning(candidate)) + return candidate; + + if (IsRunning(candidate)) + return candidate; + + mappedFallback ??= candidate; + } + + return mappedFallback ?? PickPod(pods, preferred); + } + + private static IEnumerable OrderedPods(IReadOnlyList pods, ResourceRow? preferred) { + if (preferred is not null && pods.Any(p => IsSamePod(p, preferred))) + yield return preferred; + + foreach (var pod in pods) { + if (IsSamePod(pod, preferred)) + continue; + + yield return pod; + } + } + + private static bool IsRunning(ResourceRow pod) => + PodStatus.Of(pod.Document).Equals("Running", StringComparison.OrdinalIgnoreCase); + + private static bool IsSamePod(ResourceRow pod, ResourceRow? other) => + other is not null + && (string.Equals(pod.Uid, other.Uid, StringComparison.Ordinal) + || (pod.Name == other.Name && pod.Namespace == other.Namespace)); + + private static Dictionary? ToLabels(JsonObject? labels, bool stripVolatile) { + if (labels is null || labels.Count == 0) + return null; + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var pair in labels) { + if (stripVolatile && VolatileLabelKeys.Contains(pair.Key)) + continue; + var value = JsonPath.Text(pair.Value); + if (!string.IsNullOrEmpty(value)) + result[pair.Key] = value; + } + + return result.Count == 0 ? null : result; + } + + private static int? FindNamedContainerPort(JsonObject? pod, string name) { + if (pod?["spec"] is not JsonObject spec) + return null; + + foreach (var field in new[] { "containers", "initContainers", "ephemeralContainers" }) { + if (spec[field] is not JsonArray containers) + continue; + + foreach (var container in containers.OfType()) { + if (container["ports"] is not JsonArray ports) + continue; + + foreach (var port in ports.OfType()) { + if (!string.Equals(JsonPath.Text(port["name"]), name, StringComparison.Ordinal)) + continue; + if (TryNumber(port["containerPort"], out var number)) + return number; + } + } + } + + return null; + } + + private static bool TryNumber(JsonNode? node, out int value) { + value = 0; + if (node is not JsonValue json) + return false; + if (json.TryGetValue(out value) && value > 0) + return true; + if (json.TryGetValue(out var longer) && longer > 0 && longer <= 65535) { + value = (int)longer; + return true; + } + + return json.TryGetValue(out var text) + && int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) + && value > 0; + } +} diff --git a/src/MaksIT.ClusterConsole.Shared/appsettings.json b/src/MaksIT.ClusterConsole.Shared/appsettings.json index d097201..018d752 100644 --- a/src/MaksIT.ClusterConsole.Shared/appsettings.json +++ b/src/MaksIT.ClusterConsole.Shared/appsettings.json @@ -22,6 +22,7 @@ "ColumnWidths": {}, "ColumnFilters": {}, "SearchByResource": {} - } + }, + "PortForwards": [] } } diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs index 0ab9a59..641b4a2 100644 --- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs @@ -172,6 +172,80 @@ public class ConfigurationFileServiceTests { } } + [Fact] + public void Save_round_trips_port_forwards() { + var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, """ + { + "Logging": { "LogLevel": { "Default": "Information" } }, + "Configuration": { "SelectedNamespace": "all" } + } + """); + + try { + var service = new ConfigurationFileService(path); + var cfg = service.Current; + cfg.UpsertPortForward(new PersistedPortForward { + Context = "homelab", + Kind = "Service", + Name = "postgres", + Namespace = "postgresql", + PodName = "postgres-0", + LocalPort = 5432, + RemotePort = 5432 + }); + cfg.UpsertPortForward(new PersistedPortForward { + Context = "homelab", + Kind = "Service", + Name = "postgres", + Namespace = "postgresql", + PodName = "postgres-1", + LocalPort = 5432, + RemotePort = 5432 + }); + cfg.UpsertPortForward(new PersistedPortForward { + Context = "dev", + Kind = "Pod", + Name = "web", + Namespace = "apps", + PodName = "web", + LocalPort = 8080, + RemotePort = 80, + MatchLabels = new Dictionary { ["app"] = "web" } + }); + service.Save(cfg); + + var reloaded = new ConfigurationFileService(path); + var homelab = reloaded.Current.PortForwardsFor("homelab"); + Assert.Single(homelab); + Assert.Equal("postgres-1", homelab[0].PodName); + Assert.Equal(5432, homelab[0].LocalPort); + Assert.Equal("Service", homelab[0].Kind); + + reloaded.Current.RemovePortForward("homelab", 5432); + reloaded.Save(reloaded.Current); + Assert.Empty(new ConfigurationFileService(path).Current.PortForwardsFor("homelab")); + var dev = new ConfigurationFileService(path).Current.PortForwardsFor("dev"); + Assert.Single(dev); + Assert.Equal("web", dev[0].MatchLabels!["app"]); + } + finally { + File.Delete(path); + } + } + + [Fact] + public void PortForwardRestoreSummary_formats_success_and_failure() { + Assert.Equal("Restored 1 port-forward.", new PortForwardRestoreSummary(1, []).Format()); + Assert.Equal("Restored 3 port-forwards.", new PortForwardRestoreSummary(3, []).Format()); + Assert.Equal( + "Port-forward restore failed: localhost:8080 (pod not found)", + new PortForwardRestoreSummary(0, ["localhost:8080 (pod not found)"]).Format()); + Assert.Equal( + "Restored 2 port-forward(s); 1 failed: localhost:80 (address in use)", + new PortForwardRestoreSummary(2, ["localhost:80 (address in use)"]).Format()); + } + [Fact] public void Default_path_is_appsettings_beside_the_executable() { var service = new ConfigurationFileService(); diff --git a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs index 5ac1e23..d11cd78 100644 --- a/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/ResourceCatalogTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using MaksIT.ClusterConsole.Client; using MaksIT.ClusterConsole.Shared; @@ -41,6 +42,68 @@ public class ResourceCatalogTests { Assert.True(sections.IndexOf(ResourceCatalog.Applications) < sections.IndexOf(ResourceCatalog.Workloads)); } + [Fact] + public void Port_forwarding_is_a_navigator_table_not_a_builtin_gvr() { + Assert.Same(ResourceCatalog.PortForwardingDescriptor, ResourceCatalog.Find(ResourceCatalog.PortForwardingId)); + Assert.DoesNotContain(ResourceCatalog.BuiltIns, d => d.Id == ResourceCatalog.PortForwardingId); + Assert.Null(ResourceCatalog.FindByGvk("v1", "PortForward")); + Assert.Contains("Pod", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header)); + Assert.Contains("Local", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header)); + Assert.Contains("Remote", ResourceCatalog.PortForwardingDescriptor.Columns.Select(c => c.Header)); + Assert.False(ResourceCatalog.PortForwardingDescriptor.Actions.CanApply); + Assert.False(ResourceCatalog.PortForwardingDescriptor.Actions.CanDelete); + + var workspace = new ClusterWorkspace(); + var item = Assert.Single(workspace.Navigator, n => n.Id == ResourceCatalog.PortForwardingId); + Assert.True(item.IsSpecial); + Assert.Same(ResourceCatalog.PortForwardingDescriptor, item.Descriptor); + Assert.Equal(ResourceCatalog.Network, item.Section); + } + + [Fact] + public void PortForwardRow_maps_handle_into_table_cells() { + using var handle = new PortForwardHandle("web-1", "apps", 8080, 18080, Stream.Null); + var row = PortForwardRow.From(handle, "pf-1"); + Assert.Equal("pf-1", row.Uid); + Assert.Equal("localhost:18080", row.Name); + Assert.Equal("apps", row.Namespace); + Assert.Equal("web-1", row.Cells["Pod"]); + Assert.Equal("18080", row.Cells["Local"]); + Assert.Equal("8080", row.Cells["Remote"]); + Assert.Equal("Active", row.Cells["Status"]); + Assert.True(PortForwardRow.TryLocalPort(row, out var localPort)); + Assert.Equal(18080, localPort); + Assert.True(PortForwardRow.TryLocalUrl(row, out var url)); + Assert.Equal("http://127.0.0.1:18080/", url); + Assert.Equal("http://127.0.0.1:18080/", PortForwardRow.LocalUrl(18080)); + Assert.Equal( + "Port-forward started: http://127.0.0.1:18080 → apps/web-1:8080.", + PortForwardRow.StartedMessage(handle)); + using var rebound = new PortForwardHandle("web-1", "apps", 8080, 18081, Stream.Null); + Assert.Equal( + "Port-forward rebound: localhost:18080 → http://127.0.0.1:18081 → apps/web-1:8080.", + PortForwardRow.ReboundMessage(18080, rebound)); + Assert.Equal("Port-forward failed: bind failed", PortForwardRow.FailedMessage(["bind failed"])); + } + + [Fact] + public void PortForwardRow_shows_requested_service_port_not_mapped_container_port() { + using var handle = new PortForwardHandle( + "longhorn-ui-1", + "longhorn-system", + 8000, + 80, + Stream.Null, + requestedPort: 80); + var row = PortForwardRow.From(handle, "pf-80"); + Assert.Equal("localhost:80", row.Name); + Assert.Equal("80", row.Cells["Local"]); + Assert.Equal("80", row.Cells["Remote"]); + Assert.Equal( + "Port-forward started: http://127.0.0.1:80 → longhorn-system/longhorn-ui-1:80.", + PortForwardRow.StartedMessage(handle)); + } + [Fact] public void Every_section_has_a_distinct_icon_path() { var paths = ResourceCatalog.Sections.Select(NavigatorIcons.Path).ToList(); diff --git a/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs b/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs new file mode 100644 index 0000000..151d381 --- /dev/null +++ b/src/MaksIT.ClusterConsole.Tests/ServicePortForwardTests.cs @@ -0,0 +1,287 @@ +using System.Text.Json.Nodes; +using MaksIT.ClusterConsole.Shared; + + +namespace MaksIT.ClusterConsole.Tests; + +public class ServicePortForwardTests { + [Fact] + public void ResourceOwnership_matches_service_selector_not_matchLabels() { + var service = Service("""{ "app": "web" }"""); + var matching = Pod("web-1", """{ "app": "web", "pod-template-hash": "abc" }"""); + var other = Pod("db-1", """{ "app": "db" }"""); + + Assert.True(ResourceOwnership.Owns(matching, service)); + Assert.False(ResourceOwnership.Owns(other, service)); + Assert.NotNull(ResourceOwnership.SelectorLabels(service)); + Assert.Equal("web", ResourceOwnership.SelectorLabels(service)!["app"]?.GetValue()); + } + + [Fact] + public void ResourceOwnership_still_matches_workload_matchLabels() { + var deployment = JsonNode.Parse(""" + { + "kind": "Deployment", + "metadata": { "name": "web" }, + "spec": { "selector": { "matchLabels": { "app": "web" } } } + } + """) as JsonObject; + var pod = Pod("web-1", """{ "app": "web" }"""); + + Assert.NotNull(deployment); + Assert.True(ResourceOwnership.Owns(pod, deployment)); + } + + [Fact] + public void ResourceOwnership_service_selector_does_not_match_same_helm_instance() { + var service = JsonNode.Parse(""" + { + "kind": "Service", + "metadata": { + "name": "longhorn-frontend", + "namespace": "longhorn-system", + "labels": { + "app": "longhorn-ui", + "app.kubernetes.io/instance": "longhorn", + "app.kubernetes.io/name": "longhorn" + } + }, + "spec": { + "selector": { "app": "longhorn-ui" }, + "ports": [{ "name": "http", "port": 80, "targetPort": "http" }] + } + } + """) as JsonObject; + var ui = Pod( + "longhorn-ui-1", + """{ "app": "longhorn-ui", "app.kubernetes.io/instance": "longhorn", "app.kubernetes.io/name": "longhorn" }"""); + var csi = Pod( + "csi-attacher-1", + """{ "app": "csi-attacher", "app.kubernetes.io/instance": "longhorn", "app.kubernetes.io/name": "longhorn" }"""); + + Assert.NotNull(service); + Assert.True(ResourceOwnership.Owns(ui, service)); + Assert.False(ResourceOwnership.Owns(csi, service)); + } + + [Fact] + public void Resolve_skips_pods_that_lack_the_named_targetPort() { + var service = JsonNode.Parse(""" + { + "kind": "Service", + "metadata": { "name": "longhorn-frontend", "namespace": "longhorn-system" }, + "spec": { + "selector": { "app": "longhorn-ui" }, + "ports": [{ "name": "http", "port": 80, "targetPort": "http" }] + } + } + """) as JsonObject; + var csi = JsonNode.Parse(""" + { + "kind": "Pod", + "metadata": { "name": "csi-attacher-1", "namespace": "longhorn-system", "labels": { "app": "csi-attacher" } }, + "spec": { "containers": [{ "name": "attacher", "ports": [{ "containerPort": 8443 }] }] }, + "status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] } + } + """) as JsonObject; + var ui = JsonNode.Parse(""" + { + "kind": "Pod", + "metadata": { "name": "longhorn-ui-1", "namespace": "longhorn-system", "labels": { "app": "longhorn-ui" } }, + "spec": { "containers": [{ "name": "longhorn-ui", "ports": [{ "name": "http", "containerPort": 8000 }] }] }, + "status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] } + } + """) as JsonObject; + + Assert.NotNull(service); + Assert.NotNull(csi); + Assert.NotNull(ui); + var pods = ResourceCatalog.Find("pods")!; + var resolved = ServicePortForward.Resolve( + service, + [ResourceRow.From(csi, pods), ResourceRow.From(ui, pods)], + null, + 80); + + Assert.True(resolved.IsSuccess); + Assert.Equal("longhorn-ui-1", resolved.Value!.PodName); + Assert.Equal(8000, resolved.Value.ContainerPort); + Assert.Equal(80, resolved.Value.RequestedPort); + } + + [Fact] + public void MapPort_translates_service_port_to_numeric_targetPort() { + var service = Service( + """{ "app": "web" }""", + """[{ "port": 80, "targetPort": 8080 }]"""); + var mapped = ServicePortForward.MapPort(service, Pod("web-1", """{ "app": "web" }"""), 80); + + Assert.True(mapped.IsSuccess); + Assert.Equal(8080, mapped.Value); + } + + [Fact] + public void MapPort_resolves_named_targetPort_on_the_pod() { + var service = Service( + """{ "app": "web" }""", + """[{ "port": 80, "targetPort": "http" }]"""); + var pod = JsonNode.Parse(""" + { + "kind": "Pod", + "metadata": { "name": "web-1", "namespace": "apps", "labels": { "app": "web" } }, + "spec": { + "containers": [ + { "name": "app", "ports": [{ "name": "http", "containerPort": 9090 }] } + ] + }, + "status": { "phase": "Running", "conditions": [{ "type": "Ready", "status": "True" }] } + } + """) as JsonObject; + + Assert.NotNull(pod); + var mapped = ServicePortForward.MapPort(service, pod, 80); + Assert.True(mapped.IsSuccess); + Assert.Equal(9090, mapped.Value); + } + + [Fact] + public void MapPort_keeps_unmatched_remote_port_as_container_port() { + var service = Service( + """{ "app": "web" }""", + """[{ "port": 80, "targetPort": 8080 }]"""); + var mapped = ServicePortForward.MapPort(service, Pod("web-1", """{ "app": "web" }"""), 8080); + + Assert.True(mapped.IsSuccess); + Assert.Equal(8080, mapped.Value); + } + + [Fact] + public void Resolve_picks_a_running_pod_and_maps_the_service_port() { + var service = Service( + """{ "app": "web" }""", + """[{ "port": 5432, "targetPort": 5432 }]"""); + var pending = ResourceRow.From(Pod("web-0", """{ "app": "web" }""", "Pending"), ResourceCatalog.Find("pods")!); + var running = ResourceRow.From( + Pod("web-1", """{ "app": "web" }""", "Running", ready: true), + ResourceCatalog.Find("pods")!); + + var resolved = ServicePortForward.Resolve(service, [pending, running], null, 5432); + + Assert.True(resolved.IsSuccess); + Assert.Equal("web-1", resolved.Value!.PodName); + Assert.Equal("apps", resolved.Value.Namespace); + Assert.Equal(5432, resolved.Value.ContainerPort); + } + + [Fact] + public void Resolve_rejects_a_service_without_selector() { + var service = JsonNode.Parse(""" + { + "kind": "Service", + "metadata": { "name": "external", "namespace": "apps" }, + "spec": { "type": "ExternalName", "externalName": "db.example.com", "ports": [{ "port": 5432 }] } + } + """) as JsonObject; + + Assert.NotNull(service); + var resolved = ServicePortForward.Resolve(service, [], null, 5432); + Assert.False(resolved.IsSuccess); + Assert.Contains("selector", string.Join(' ', resolved.Messages), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PickPod_skips_a_preferred_pod_that_is_not_running() { + var pods = ResourceCatalog.Find("pods")!; + var preferred = ResourceRow.From(Pod("web-old", """{ "app": "web" }""", "Pending"), pods); + var running = ResourceRow.From(Pod("web-new", """{ "app": "web" }""", "Running", ready: true), pods); + + var picked = ServicePortForward.PickPod([preferred, running], preferred); + + Assert.NotNull(picked); + Assert.Equal("web-new", picked.Name); + } + + [Fact] + public void PickRunning_matches_stable_labels_when_the_replica_name_changes() { + var pods = ResourceCatalog.Find("pods")!; + var gone = ResourceRow.From( + Pod("longhorn-ui-5d6f5b4c44-4p6n5", """{ "app": "longhorn-ui", "pod-template-hash": "5d6f5b4c44" }""", "Pending"), + pods); + var next = ResourceRow.From( + Pod("longhorn-ui-aaaa-bbbb", """{ "app": "longhorn-ui", "pod-template-hash": "aaaa" }""", "Running", ready: true), + pods); + + var picked = ServicePortForward.PickRunning( + [gone, next], + "longhorn-ui-5d6f5b4c44-4p6n5", + new Dictionary { ["app"] = "longhorn-ui" }); + + Assert.NotNull(picked); + Assert.Equal("longhorn-ui-aaaa-bbbb", picked.Name); + } + + [Fact] + public void StableLabels_strips_pod_template_hash() { + var pod = Pod( + "longhorn-ui-5d6f5b4c44-4p6n5", + """{ "app": "longhorn-ui", "pod-template-hash": "5d6f5b4c44" }"""); + + var labels = ServicePortForward.StableLabels(pod); + + Assert.NotNull(labels); + Assert.Equal("longhorn-ui", labels["app"]); + Assert.False(labels.ContainsKey("pod-template-hash")); + } + + [Fact] + public void StableLabels_uses_a_service_selector() { + var service = Service("""{ "app": "longhorn-ui" }"""); + + var labels = ServicePortForward.StableLabels(service); + + Assert.NotNull(labels); + Assert.Equal("longhorn-ui", labels["app"]); + } + + [Fact] + public void Services_catalog_can_port_forward_and_lists_related_pods() { + var descriptor = ResourceCatalog.Find("services")!; + Assert.True(descriptor.Actions.CanPortForward); + Assert.Contains("Pods", descriptor.DetailTabs); + Assert.Equal(5432, ServicePortForward.DefaultPort(Service( + """{ "app": "web" }""", + """[{ "port": 5432, "targetPort": 5432 }]"""))); + } + + private static JsonObject Service(string selectorJson, string? portsJson = null) { + var ports = portsJson ?? """[{ "port": 80, "targetPort": 80 }]"""; + var parsed = JsonNode.Parse($$""" + { + "kind": "Service", + "metadata": { "name": "web", "namespace": "apps" }, + "spec": { + "selector": {{selectorJson}}, + "ports": {{ports}} + } + } + """) as JsonObject; + Assert.NotNull(parsed); + return parsed; + } + + private static JsonObject Pod(string name, string labelsJson, string phase = "Running", bool ready = false) { + var readyStatus = ready ? "True" : "False"; + var parsed = JsonNode.Parse($$""" + { + "kind": "Pod", + "metadata": { "name": "{{name}}", "namespace": "apps", "labels": {{labelsJson}} }, + "status": { + "phase": "{{phase}}", + "conditions": [{ "type": "Ready", "status": "{{readyStatus}}" }] + } + } + """) as JsonObject; + Assert.NotNull(parsed); + return parsed; + } +} diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml index a8a3ae3..f3a9381 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml @@ -14,19 +14,7 @@ MinHeight="640"> - - - - - - - -