Skip to content

Commit

Permalink
feat(Service): Port Forward Support (#765)
Browse files Browse the repository at this point in the history
  • Loading branch information
IvanJosipovic authored Oct 15, 2024
1 parent 62747a8 commit cf49d73
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 40 deletions.
24 changes: 22 additions & 2 deletions src/KubeUI/Client/Cluster.PortForward.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,29 @@ public partial class Cluster
[ObservableProperty]
private ObservableCollection<PortForwarder> _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))
{
Expand Down
22 changes: 11 additions & 11 deletions src/KubeUI/Client/Cluster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ private async Task AddDefaultNavigation()
if (waitForReady)
{
await inf.ReadyAsync(new CancellationToken());
await Task.Delay(1000);
}
}
else
Expand Down Expand Up @@ -499,6 +500,7 @@ private async Task AddDefaultNavigation()
if (waitForReady)
{
await inf.ReadyAsync(new CancellationToken());
await Task.Delay(1000);
}
}
}
Expand Down Expand Up @@ -702,6 +704,15 @@ private static List<string> ConstructFQDNList(string domain)
return ((ConcurrentObservableDictionary<NamespacedName, T>)Objects[attribute].Items)[new NamespacedName(@namespace, name)];
}

public async Task<T?> GetObjectAsync<T>(string @namespace, string name) where T : class, IKubernetesObject<V1ObjectMeta>, new()
{
await Seed<T>(true);

var attribute = GroupApiVersionKind.From<T>();

return ((ConcurrentObservableDictionary<NamespacedName, T>)Objects[attribute].Items)[new NamespacedName(@namespace, name)];
}

public ConcurrentObservableDictionary<NamespacedName, T> GetObjectDictionary<T>() where T : class, IKubernetesObject<V1ObjectMeta>, new()
{
_ = Task.Run(() => Seed<T>());
Expand All @@ -728,17 +739,6 @@ private static List<string> ConstructFQDNList(string domain)

var attribute = GroupApiVersionKind.From<T>();

if (!Objects.TryGetValue(attribute, out var container))
{
container = new ContainerClass
{
Type = typeof(T),
Items = new ConcurrentObservableDictionary<NamespacedName, T>()
};

Objects.TryAdd(attribute, container);
}

return (ConcurrentObservableDictionary<NamespacedName, T>)Objects[attribute].Items;
}

Expand Down
4 changes: 3 additions & 1 deletion src/KubeUI/Client/ICluster.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public interface ICluster
ObservableCollection<PodMetrics> PodMetrics { get; set; }
ObservableCollection<PortForwarder> PortForwarders { get; set; }
ObservableCollection<V1Namespace> 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<T>(string @namespace, string name) where T : class, IKubernetesObject<V1ObjectMeta>, new();
Expand All @@ -37,6 +37,8 @@ public interface ICluster
bool CanI(Type type, Verb verb, string @namespace = "", string subresource = "");
Task<ConcurrentObservableDictionary<NamespacedName, T>> GetObjectDictionaryAsync<T>() where T : class, IKubernetesObject<V1ObjectMeta>, new();
bool CanIAnyNamespace(Type type, Verb verb, string subresource = "");
Task<T?> GetObjectAsync<T>(string @namespace, string name) where T : class, IKubernetesObject<V1ObjectMeta>, new();
PortForwarder AddServicePortForward(string @namespace, string serviceName, int containerPort);

bool ListNamespaces { get; set; }
}
6 changes: 1 addition & 5 deletions src/KubeUI/Client/Informer/IResourceInformerRegistration.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
Expand All @@ -16,7 +12,7 @@ public interface IResourceInformerRegistration : IDisposable
/// <summary>
/// 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.
/// </summary>
/// <param name="cancellationToken">The cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
/// <returns>Task.</returns>
Expand Down
67 changes: 58 additions & 9 deletions src/KubeUI/Client/PortForwarder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,55 @@
using System.Net.Sockets;
using System.Net;
using k8s;
using k8s.Models;

namespace KubeUI.Client;

public partial class PortForwarder : ObservableObject, IEquatable<PortForwarder>, 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;

[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))
Expand Down Expand Up @@ -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<V1Service>(Namespace, Name);
var port = service.Spec.Ports.First(x => x.Port == Port);
var endpoint = await _cluster.GetObjectAsync<V1Endpoints>(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();

Expand Down Expand Up @@ -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()
Expand Down
72 changes: 65 additions & 7 deletions src/KubeUI/ViewModels/ResourceListViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,29 @@ private ResourceListViewDefinition<T> 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<V1Pod>.PortForwardServiceCommand),
CommandParameterPath = ".",
}
}
];
}
else if (resourceType == typeof(V1Endpoints))
{
Expand Down Expand Up @@ -1307,7 +1330,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
var colDef = new ResourceListViewDefinitionColumn<T, double>()
{
Name = item.Name,
Display = TransformToFuncOfString<T>(exp.Body, exp.Parameters).Compile(),
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
Field = exp.Compile(),
//Width = "*"
};
Expand All @@ -1321,7 +1344,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
var colDef = new ResourceListViewDefinitionColumn<T, long>()
{
Name = item.Name,
Display = TransformToFuncOfString<T>(exp.Body, exp.Parameters).Compile(),
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
Field = exp.Compile(),
//Width = "*"
};
Expand All @@ -1335,7 +1358,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
var colDef = new ResourceListViewDefinitionColumn<T, int>()
{
Name = item.Name,
Display = TransformToFuncOfString<T>(exp.Body, exp.Parameters).Compile(),
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
Field = exp.Compile(),
//Width = "*"
};
Expand All @@ -1362,7 +1385,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
var colDef = new ResourceListViewDefinitionColumn<T, bool>()
{
Name = item.Name,
Display = TransformToFuncOfString<T>(exp.Body, exp.Parameters).Compile(),
Display = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
Field = exp.Compile(),
//Width = "*"
};
Expand All @@ -1376,7 +1399,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
var colDef = new ResourceListViewDefinitionColumn<T, string>()
{
Name = item.Name,
Field = TransformToFuncOfString<T>(exp.Body, exp.Parameters).Compile(),
Field = TransformToFuncOfString(exp.Body, exp.Parameters).Compile(),
//Width = "*"
};

Expand Down Expand Up @@ -1484,7 +1507,7 @@ private ResourceListViewDefinition<T> GetViewDefinition()
return definition;
}

private static Expression<Func<T, string>> TransformToFuncOfString<T>(Expression expression, ReadOnlyCollection<ParameterExpression> parameters)
private static Expression<Func<T, string>> TransformToFuncOfString(Expression expression, ReadOnlyCollection<ParameterExpression> parameters)
{
// Check if the expression type is an enum
if (expression.Type == typeof(Enum))
Expand Down Expand Up @@ -1686,7 +1709,7 @@ private bool CanViewConsole(V1Container? container)
[RelayCommand(CanExecute = nameof(CanPortForward))]
private async Task PortForward(V1ContainerPort containerPort)
{
var pf = Cluster.AddPortForward(((KeyValuePair<NamespacedName, V1Pod>)SelectedItem).Key.Namespace, ((KeyValuePair<NamespacedName, V1Pod>)SelectedItem).Key.Name, containerPort.ContainerPort);
var pf = Cluster.AddPodPortForward(((KeyValuePair<NamespacedName, V1Pod>)SelectedItem).Key.Namespace, ((KeyValuePair<NamespacedName, V1Pod>)SelectedItem).Key.Name, containerPort.ContainerPort);

ContentDialogSettings settings = new()
{
Expand All @@ -1713,6 +1736,41 @@ private bool CanPortForward(V1ContainerPort? containerPort)
Cluster.CanI<V1Pod>(Verb.Create, ((KeyValuePair<NamespacedName, V1Pod>)SelectedItem).Key.Namespace, "portforward");
}


[RelayCommand(CanExecute = nameof(CanPortForwardService))]
private async Task PortForwardService(V1ServicePort containerPort)
{
var pf = Cluster.AddServicePortForward(((KeyValuePair<NamespacedName, V1Service>)SelectedItem).Key.Namespace, ((KeyValuePair<NamespacedName, V1Service>)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<NamespacedName, V1Service>)SelectedItem).Key.Namespace;

return servicePort?.Port > 0 &&
servicePort.Protocol == "TCP" &&
Cluster.CanI<V1Pod>(Verb.Create, @namespace, "portforward") &&
Cluster.CanI<V1Endpoints>(Verb.List, @namespace) &&
Cluster.CanI<V1Endpoints>(Verb.Watch, @namespace);
}

[RelayCommand(CanExecute = nameof(CanListCRD))]
private void ListCRD(V1CustomResourceDefinition item)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit cf49d73

Please sign in to comment.