From cf49d733c1a39509e4d1a609d086cc64b3000410 Mon Sep 17 00:00:00 2001 From: Ivan Josipovic <9521987+IvanJosipovic@users.noreply.github.com> Date: Mon, 14 Oct 2024 22:08:11 -0700 Subject: [PATCH] feat(Service): Port Forward Support (#765) --- src/KubeUI/Client/Cluster.PortForward.cs | 24 ++++++- src/KubeUI/Client/Cluster.cs | 22 +++--- src/KubeUI/Client/ICluster.cs | 4 +- .../Informer/IResourceInformerRegistration.cs | 6 +- src/KubeUI/Client/PortForwarder.cs | 67 ++++++++++++++--- .../ViewModels/ResourceListViewModel.cs | 72 +++++++++++++++++-- .../Pod/PortForwarderListViewModel.cs | 2 +- .../Workloads/Pod/PortForwarderListView.cs | 13 ++-- 8 files changed, 170 insertions(+), 40 deletions(-) diff --git a/src/KubeUI/Client/Cluster.PortForward.cs b/src/KubeUI/Client/Cluster.PortForward.cs index d2f85a4c..cb5a7bdf 100644 --- a/src/KubeUI/Client/Cluster.PortForward.cs +++ b/src/KubeUI/Client/Cluster.PortForward.cs @@ -5,9 +5,29 @@ public partial class Cluster [ObservableProperty] private ObservableCollection _portForwarders = []; - public PortForwarder AddPortForward(string @namespace, string podName, int containerPort) + public PortForwarder AddPodPortForward(string @namespace, string podName, int containerPort) { - var pf = new PortForwarder(this.Client, @namespace, podName, containerPort); + var pf = new PortForwarder(this, @namespace); + + pf.SetPod(podName, containerPort); + + if (PortForwarders.Contains(pf)) + { + return PortForwarders.First(p => p.Equals(pf)); + } + + PortForwarders.Add(pf); + + pf.Start(); + + return pf; + } + + public PortForwarder AddServicePortForward(string @namespace, string serviceName, int containerPort) + { + var pf = new PortForwarder(this, @namespace); + + pf.SetService(serviceName, containerPort); if (PortForwarders.Contains(pf)) { diff --git a/src/KubeUI/Client/Cluster.cs b/src/KubeUI/Client/Cluster.cs index 6b46ae59..032c03e7 100644 --- a/src/KubeUI/Client/Cluster.cs +++ b/src/KubeUI/Client/Cluster.cs @@ -467,6 +467,7 @@ private async Task AddDefaultNavigation() if (waitForReady) { await inf.ReadyAsync(new CancellationToken()); + await Task.Delay(1000); } } else @@ -499,6 +500,7 @@ private async Task AddDefaultNavigation() if (waitForReady) { await inf.ReadyAsync(new CancellationToken()); + await Task.Delay(1000); } } } @@ -702,6 +704,15 @@ private static List ConstructFQDNList(string domain) return ((ConcurrentObservableDictionary)Objects[attribute].Items)[new NamespacedName(@namespace, name)]; } + public async Task GetObjectAsync(string @namespace, string name) where T : class, IKubernetesObject, new() + { + await Seed(true); + + var attribute = GroupApiVersionKind.From(); + + return ((ConcurrentObservableDictionary)Objects[attribute].Items)[new NamespacedName(@namespace, name)]; + } + public ConcurrentObservableDictionary GetObjectDictionary() where T : class, IKubernetesObject, new() { _ = Task.Run(() => Seed()); @@ -728,17 +739,6 @@ private static List ConstructFQDNList(string domain) var attribute = GroupApiVersionKind.From(); - if (!Objects.TryGetValue(attribute, out var container)) - { - container = new ContainerClass - { - Type = typeof(T), - Items = new ConcurrentObservableDictionary() - }; - - Objects.TryAdd(attribute, container); - } - return (ConcurrentObservableDictionary)Objects[attribute].Items; } diff --git a/src/KubeUI/Client/ICluster.cs b/src/KubeUI/Client/ICluster.cs index bb6b91a7..6b877f34 100644 --- a/src/KubeUI/Client/ICluster.cs +++ b/src/KubeUI/Client/ICluster.cs @@ -21,7 +21,7 @@ public interface ICluster ObservableCollection PodMetrics { get; set; } ObservableCollection PortForwarders { get; set; } ObservableCollection SelectedNamespaces { get; set; } - PortForwarder AddPortForward(string @namespace, string podName, int containerPort); + PortForwarder AddPodPortForward(string @namespace, string podName, int containerPort); string KubeConfigPath { get; set; } string Name { get; set; } T? GetObject(string @namespace, string name) where T : class, IKubernetesObject, new(); @@ -37,6 +37,8 @@ public interface ICluster bool CanI(Type type, Verb verb, string @namespace = "", string subresource = ""); Task> GetObjectDictionaryAsync() where T : class, IKubernetesObject, new(); bool CanIAnyNamespace(Type type, Verb verb, string subresource = ""); + Task GetObjectAsync(string @namespace, string name) where T : class, IKubernetesObject, new(); + PortForwarder AddServicePortForward(string @namespace, string serviceName, int containerPort); bool ListNamespaces { get; set; } } diff --git a/src/KubeUI/Client/Informer/IResourceInformerRegistration.cs b/src/KubeUI/Client/Informer/IResourceInformerRegistration.cs index 05bd86ec..55622abd 100644 --- a/src/KubeUI/Client/Informer/IResourceInformerRegistration.cs +++ b/src/KubeUI/Client/Informer/IResourceInformerRegistration.cs @@ -1,10 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. - -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - namespace KubeUI.Client.Informer; /// @@ -16,7 +12,7 @@ public interface IResourceInformerRegistration : IDisposable /// /// Returns a task that can be awaited to know when the initial listing of resources is complete. /// Once an await on this method it is safe to assume that all of the knowledge of this resource - /// type has been made available, and everything going forward will be updatres. + /// type has been made available, and everything going forward will be updates. /// /// The cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Task. diff --git a/src/KubeUI/Client/PortForwarder.cs b/src/KubeUI/Client/PortForwarder.cs index 849ce010..bcc799e5 100644 --- a/src/KubeUI/Client/PortForwarder.cs +++ b/src/KubeUI/Client/PortForwarder.cs @@ -2,19 +2,22 @@ using System.Net.Sockets; using System.Net; using k8s; +using k8s.Models; namespace KubeUI.Client; public partial class PortForwarder : ObservableObject, IEquatable, IDisposable { - private readonly IKubernetes _client; + private readonly ICluster _cluster; private readonly TcpListener _listener; - public string PodName { get; } + public string Name { get; private set; } public string Namespace { get; } - public int ContainerPort { get; } + public int Port { get; private set; } + + public string Type { get; private set; } [ObservableProperty] private int _localPort; @@ -22,19 +25,32 @@ public partial class PortForwarder : ObservableObject, IEquatable [ObservableProperty] private string _status = "Initializing"; + private bool _isDisposing; - public PortForwarder(IKubernetes client, string @namespace, string podName, int containerPort, int localPort = 0) + public PortForwarder(ICluster cluster, string @namespace, int localPort = 0) { - _client = client; + _cluster = cluster; Namespace = @namespace; - PodName = podName; - ContainerPort = containerPort; LocalPort = localPort; _listener = new TcpListener(IPAddress.Loopback, localPort); } + public void SetPod(string podName, int containerPort) + { + Name = podName; + Port = containerPort; + Type = "Pod"; + } + + public void SetService(string serviceName, int servicePort) + { + Name = serviceName; + Port = servicePort; + Type = "Service"; + } + public void Start() { if (LocalPort != 0 && !IsPortAvailable(LocalPort)) @@ -65,7 +81,40 @@ private void ClientConnected(IAsyncResult result) private async Task HandleConnection(Socket socket) { - using var webSocket = await _client.WebSocketNamespacedPodPortForwardAsync(PodName, Namespace, new int[] { ContainerPort }, "v4.channel.k8s.io"); + var podName = Name; + var podPort = Port; + if (Type == "Service") + { + var service = await _cluster.GetObjectAsync(Namespace, Name); + var port = service.Spec.Ports.First(x => x.Port == Port); + var endpoint = await _cluster.GetObjectAsync(Namespace, Name); + + var random = new Random(); + + var subset = endpoint.Subsets.First(x => x.Ports.Any(y => y.Name == port.Name)); + var portData = subset.Ports.First(y => y.Name == port.Name); + + var pod = subset.Addresses + .Select(x => x.TargetRef.Name) + .OrderBy(x => random.Next()) + .FirstOrDefault(); + + if (pod == null) + { + Status = "No pods found for Service"; + socket.Close(); + return; + } + else + { + Status = "Active"; + } + + podName = pod; + podPort = portData.Port; + } + + using var webSocket = await _cluster.Client.WebSocketNamespacedPodPortForwardAsync(podName, Namespace, [podPort], "v4.channel.k8s.io"); using var demux = new StreamDemuxer(webSocket, StreamType.PortForward); demux.Start(); @@ -121,7 +170,7 @@ private static bool SocketConnected(Socket s) public bool Equals(PortForwarder? other) { - return other != null && other.PodName == PodName && other.Namespace == Namespace && other.ContainerPort == ContainerPort; + return other != null && other.Name == Name && other.Namespace == Namespace && other.Port == Port && other.Type == Type; } public void Dispose() diff --git a/src/KubeUI/ViewModels/ResourceListViewModel.cs b/src/KubeUI/ViewModels/ResourceListViewModel.cs index 1de9eb77..99e3038d 100644 --- a/src/KubeUI/ViewModels/ResourceListViewModel.cs +++ b/src/KubeUI/ViewModels/ResourceListViewModel.cs @@ -949,6 +949,29 @@ private ResourceListViewDefinition GetViewDefinition() }, AgeColumn(), ]; + + definition.MenuItems = + [ + new() + { + Header = "Port Forwarding", + ItemSourcePath = "SelectedItem.Value.Spec.Ports", + IconResource = "ic_fluent_cloud_flow_filled", + ItemTemplate = new() + { + HeaderBinding = new MultiBinding() + { + Bindings = + [ + new Binding(nameof(V1ServicePort.Name)), + new Binding(nameof(V1ServicePort.Port)) + ], + StringFormat = "{0} - {1}" + }, CommandPath = nameof(ResourceListViewModel.PortForwardServiceCommand), + CommandParameterPath = ".", + } + } + ]; } else if (resourceType == typeof(V1Endpoints)) { @@ -1307,7 +1330,7 @@ private ResourceListViewDefinition GetViewDefinition() var colDef = new ResourceListViewDefinitionColumn() { Name = item.Name, - Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), + Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), Field = exp.Compile(), //Width = "*" }; @@ -1321,7 +1344,7 @@ private ResourceListViewDefinition GetViewDefinition() var colDef = new ResourceListViewDefinitionColumn() { Name = item.Name, - Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), + Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), Field = exp.Compile(), //Width = "*" }; @@ -1335,7 +1358,7 @@ private ResourceListViewDefinition GetViewDefinition() var colDef = new ResourceListViewDefinitionColumn() { Name = item.Name, - Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), + Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), Field = exp.Compile(), //Width = "*" }; @@ -1362,7 +1385,7 @@ private ResourceListViewDefinition GetViewDefinition() var colDef = new ResourceListViewDefinitionColumn() { Name = item.Name, - Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), + Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), Field = exp.Compile(), //Width = "*" }; @@ -1376,7 +1399,7 @@ private ResourceListViewDefinition GetViewDefinition() var colDef = new ResourceListViewDefinitionColumn() { Name = item.Name, - Field = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), + Field = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(), //Width = "*" }; @@ -1484,7 +1507,7 @@ private ResourceListViewDefinition GetViewDefinition() return definition; } - private static Expression> TransformToFuncOfString(Expression expression, ReadOnlyCollection parameters) + private static Expression> TransformToFuncOfString(Expression expression, ReadOnlyCollection parameters) { // Check if the expression type is an enum if (expression.Type == typeof(Enum)) @@ -1686,7 +1709,7 @@ private bool CanViewConsole(V1Container? container) [RelayCommand(CanExecute = nameof(CanPortForward))] private async Task PortForward(V1ContainerPort containerPort) { - var pf = Cluster.AddPortForward(((KeyValuePair)SelectedItem).Key.Namespace, ((KeyValuePair)SelectedItem).Key.Name, containerPort.ContainerPort); + var pf = Cluster.AddPodPortForward(((KeyValuePair)SelectedItem).Key.Namespace, ((KeyValuePair)SelectedItem).Key.Name, containerPort.ContainerPort); ContentDialogSettings settings = new() { @@ -1713,6 +1736,41 @@ private bool CanPortForward(V1ContainerPort? containerPort) Cluster.CanI(Verb.Create, ((KeyValuePair)SelectedItem).Key.Namespace, "portforward"); } + + [RelayCommand(CanExecute = nameof(CanPortForwardService))] + private async Task PortForwardService(V1ServicePort containerPort) + { + var pf = Cluster.AddServicePortForward(((KeyValuePair)SelectedItem).Key.Namespace, ((KeyValuePair)SelectedItem).Key.Name, containerPort.Port); + + ContentDialogSettings settings = new() + { + Title = Resources.ResourceListViewModel_PortForward_Title, + Content = string.Format(Resources.ResourceListViewModel_PortForward_Content, containerPort.Port, pf.LocalPort), + PrimaryButtonText = Resources.ResourceListViewModel_PortForward_Primary, + SecondaryButtonText = Resources.ResourceListViewModel_PortForward_Secondary, + DefaultButton = ContentDialogButton.Secondary + }; + + var result = await _dialogService.ShowContentDialogAsync(this, settings); + + if (result == ContentDialogResult.Primary) + { + var window = (Window)_dialogService.DialogManager.GetMainWindow()!.RefObj; + await window!.Launcher.LaunchUriAsync(new Uri($"http://localhost:{pf.LocalPort}")); + } + } + + private bool CanPortForwardService(V1ServicePort? servicePort) + { + var @namespace = ((KeyValuePair)SelectedItem).Key.Namespace; + + return servicePort?.Port > 0 && + servicePort.Protocol == "TCP" && + Cluster.CanI(Verb.Create, @namespace, "portforward") && + Cluster.CanI(Verb.List, @namespace) && + Cluster.CanI(Verb.Watch, @namespace); + } + [RelayCommand(CanExecute = nameof(CanListCRD))] private void ListCRD(V1CustomResourceDefinition item) { diff --git a/src/KubeUI/ViewModels/Workloads/Pod/PortForwarderListViewModel.cs b/src/KubeUI/ViewModels/Workloads/Pod/PortForwarderListViewModel.cs index 11fa8f2e..1f9c4a34 100644 --- a/src/KubeUI/ViewModels/Workloads/Pod/PortForwarderListViewModel.cs +++ b/src/KubeUI/ViewModels/Workloads/Pod/PortForwarderListViewModel.cs @@ -27,7 +27,7 @@ private async Task Remove(PortForwarder pf) ContentDialogSettings settings = new() { Title = Resources.PortForwarderListViewModel_Remove_Title, - Content = string.Format(Resources.PortForwarderListViewModel_Remove_Content, pf.Namespace, pf.PodName, pf.ContainerPort), + Content = string.Format(Resources.PortForwarderListViewModel_Remove_Content, pf.Namespace, pf.Name, pf.Port), PrimaryButtonText = Resources.PortForwarderListViewModel_Remove_Primary, SecondaryButtonText = Resources.PortForwarderListViewModel_Remove_Secondary, DefaultButton = ContentDialogButton.Secondary diff --git a/src/KubeUI/Views/Workloads/Pod/PortForwarderListView.cs b/src/KubeUI/Views/Workloads/Pod/PortForwarderListView.cs index 9a4c4f5b..6807756e 100644 --- a/src/KubeUI/Views/Workloads/Pod/PortForwarderListView.cs +++ b/src/KubeUI/Views/Workloads/Pod/PortForwarderListView.cs @@ -19,8 +19,13 @@ protected override object Build(PortForwarderListViewModel? vm) x.Columns([ new DataGridTextColumn() { - Binding = new Binding(nameof(PortForwarder.PodName)), - Header = "Pod Name", + Binding = new Binding(nameof(PortForwarder.Type)), + Header = "Type", + }, + new DataGridTextColumn() + { + Binding = new Binding(nameof(PortForwarder.Name)), + Header = "Name", }, new DataGridTextColumn() { @@ -29,8 +34,8 @@ protected override object Build(PortForwarderListViewModel? vm) }, new DataGridTextColumn() { - Binding = new Binding(nameof(PortForwarder.ContainerPort)), - Header = "Container Port", + Binding = new Binding(nameof(PortForwarder.Port)), + Header = "Port", }, new DataGridTextColumn() {