mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-02 05:45:42 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7a32e4aab |
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Discovery provides cluster introspection capabilities for the UI wizards.
|
// Discovery provides cluster introspection capabilities for the UI wizards.
|
||||||
@@ -41,9 +43,10 @@ type ContainerInfo struct {
|
|||||||
|
|
||||||
// PortInfo describes a port exposed by a container or service.
|
// PortInfo describes a port exposed by a container or service.
|
||||||
type PortInfo struct {
|
type PortInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Port int32
|
Port int32
|
||||||
Protocol string
|
TargetPort int32 // For services: the actual pod port to forward to
|
||||||
|
Protocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceInfo contains information about a service.
|
// ServiceInfo contains information about a service.
|
||||||
@@ -205,7 +208,60 @@ func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, names
|
|||||||
return pods, nil
|
return pods, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveTargetPort resolves a service's targetPort to an actual port number.
|
||||||
|
// If targetPort is numeric, it returns that number directly.
|
||||||
|
// If targetPort is a named port, it looks up the port number from the backing pods.
|
||||||
|
// Falls back to the service port if resolution fails.
|
||||||
|
func (d *Discovery) resolveTargetPort(ctx context.Context, client kubernetes.Interface, namespace string, svc *corev1.Service, port *corev1.ServicePort) int32 {
|
||||||
|
// If targetPort is not set, Kubernetes defaults to the service port
|
||||||
|
if port.TargetPort.Type == intstr.Int && port.TargetPort.IntVal == 0 {
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// If targetPort is numeric, use it directly
|
||||||
|
if port.TargetPort.Type == intstr.Int {
|
||||||
|
return port.TargetPort.IntVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetPort is a named port - need to look up from pods
|
||||||
|
namedPort := port.TargetPort.StrVal
|
||||||
|
if namedPort == "" {
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a backing pod to resolve the named port
|
||||||
|
if len(svc.Spec.Selector) == 0 {
|
||||||
|
// No selector, can't resolve - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: svc.Spec.Selector})
|
||||||
|
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: selector,
|
||||||
|
Limit: 1, // We only need one pod to resolve the port name
|
||||||
|
})
|
||||||
|
if err != nil || len(pods.Items) == 0 {
|
||||||
|
// Can't get pods - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the named port in the pod's containers
|
||||||
|
pod := &pods.Items[0]
|
||||||
|
for _, container := range pod.Spec.Containers {
|
||||||
|
for _, containerPort := range container.Ports {
|
||||||
|
if containerPort.Name == namedPort {
|
||||||
|
return containerPort.ContainerPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named port not found - fall back to service port
|
||||||
|
return port.Port
|
||||||
|
}
|
||||||
|
|
||||||
// ListServices returns all services in the given namespace.
|
// ListServices returns all services in the given namespace.
|
||||||
|
// For each service port, it resolves the targetPort to an actual port number
|
||||||
|
// by looking up the backing pods when the targetPort is a named port.
|
||||||
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
||||||
client, err := d.pool.GetClient(contextName)
|
client, err := d.pool.GetClient(contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,10 +277,13 @@ func (d *Discovery) ListServices(ctx context.Context, contextName, namespace str
|
|||||||
for _, svc := range svcList.Items {
|
for _, svc := range svcList.Items {
|
||||||
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
||||||
for _, port := range svc.Spec.Ports {
|
for _, port := range svc.Spec.Ports {
|
||||||
|
targetPort := d.resolveTargetPort(ctx, client, namespace, &svc, &port)
|
||||||
|
|
||||||
ports = append(ports, PortInfo{
|
ports = append(ports, PortInfo{
|
||||||
Name: port.Name,
|
Name: port.Name,
|
||||||
Port: port.Port,
|
Port: port.Port,
|
||||||
Protocol: string(port.Protocol),
|
TargetPort: targetPort,
|
||||||
|
Protocol: string(port.Protocol),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTargetPort(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
servicePort corev1.ServicePort
|
||||||
|
service *corev1.Service
|
||||||
|
pods []corev1.Pod
|
||||||
|
expectedPort int32
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numeric targetPort",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromInt(8000),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil, // No pods needed for numeric targetPort
|
||||||
|
expectedPort: 8000,
|
||||||
|
description: "should use numeric targetPort directly",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort resolved from pod",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 8000,
|
||||||
|
description: "should resolve named port from pod container",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "targetPort not set - defaults to service port",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromInt(0), // Not set
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil,
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when targetPort is not set",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort with no matching pod",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil, // No pods available
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when no pods found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service without selector",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("http"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: nil, // No selector
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: nil,
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when service has no selector",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "named targetPort not found in pod containers",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: intstr.FromString("nonexistent"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 80,
|
||||||
|
description: "should fall back to service port when named port not found in pod",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple containers with named port in second container",
|
||||||
|
servicePort: corev1.ServicePort{
|
||||||
|
Name: "metrics",
|
||||||
|
Port: 9090,
|
||||||
|
TargetPort: intstr.FromString("metrics"),
|
||||||
|
},
|
||||||
|
service: &corev1.Service{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-svc",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: corev1.ServiceSpec{
|
||||||
|
Selector: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pods: []corev1.Pod{
|
||||||
|
{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-pod",
|
||||||
|
Namespace: "default",
|
||||||
|
Labels: map[string]string{"app": "test"},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "http", ContainerPort: 8000},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sidecar",
|
||||||
|
Ports: []corev1.ContainerPort{
|
||||||
|
{Name: "metrics", ContainerPort: 9100},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedPort: 9100,
|
||||||
|
description: "should find named port in any container",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create fake client with pods
|
||||||
|
var objects []runtime.Object
|
||||||
|
for i := range tt.pods {
|
||||||
|
objects = append(objects, &tt.pods[i])
|
||||||
|
}
|
||||||
|
fakeClient := fake.NewSimpleClientset(objects...)
|
||||||
|
|
||||||
|
// Create discovery instance (we only need it to call resolveTargetPort)
|
||||||
|
d := &Discovery{}
|
||||||
|
|
||||||
|
// Call resolveTargetPort
|
||||||
|
result := d.resolveTargetPort(
|
||||||
|
context.Background(),
|
||||||
|
fakeClient,
|
||||||
|
"default",
|
||||||
|
tt.service,
|
||||||
|
&tt.servicePort,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedPort, result, tt.description)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortInfoTargetPort(t *testing.T) {
|
||||||
|
// Test that PortInfo correctly stores TargetPort
|
||||||
|
portInfo := PortInfo{
|
||||||
|
Name: "http",
|
||||||
|
Port: 80,
|
||||||
|
TargetPort: 8000,
|
||||||
|
Protocol: "TCP",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, int32(80), portInfo.Port)
|
||||||
|
assert.Equal(t, int32(8000), portInfo.TargetPort)
|
||||||
|
assert.Equal(t, "http", portInfo.Name)
|
||||||
|
assert.Equal(t, "TCP", portInfo.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUniquePorts(t *testing.T) {
|
||||||
|
// Test GetUniquePorts still works with the new PortInfo struct
|
||||||
|
pods := []PodInfo{
|
||||||
|
{
|
||||||
|
Name: "pod1",
|
||||||
|
Containers: []ContainerInfo{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []PortInfo{
|
||||||
|
{Name: "http", Port: 8080},
|
||||||
|
{Name: "metrics", Port: 9090},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pod2",
|
||||||
|
Containers: []ContainerInfo{
|
||||||
|
{
|
||||||
|
Name: "main",
|
||||||
|
Ports: []PortInfo{
|
||||||
|
{Name: "http", Port: 8080}, // Duplicate
|
||||||
|
{Name: "grpc", Port: 50051},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := GetUniquePorts(pods)
|
||||||
|
|
||||||
|
// Should have 3 unique ports
|
||||||
|
assert.Len(t, ports, 3)
|
||||||
|
|
||||||
|
// Should be sorted by port number
|
||||||
|
assert.Equal(t, int32(8080), ports[0].Port)
|
||||||
|
assert.Equal(t, int32(9090), ports[1].Port)
|
||||||
|
assert.Equal(t, int32(50051), ports[2].Port)
|
||||||
|
|
||||||
|
// Names should be preserved
|
||||||
|
assert.Equal(t, "http", ports[0].Name)
|
||||||
|
assert.Equal(t, "metrics", ports[1].Name)
|
||||||
|
assert.Equal(t, "grpc", ports[2].Name)
|
||||||
|
}
|
||||||
@@ -468,7 +468,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
||||||
// Selected a detected port
|
// Selected a detected port
|
||||||
wizard.remotePort = int(wizard.detectedPorts[wizard.cursor].Port)
|
// For services, use TargetPort (actual pod port) if available
|
||||||
|
// For pods, TargetPort is 0, so use Port (container port)
|
||||||
|
selectedPort := wizard.detectedPorts[wizard.cursor]
|
||||||
|
if selectedPort.TargetPort > 0 {
|
||||||
|
wizard.remotePort = int(selectedPort.TargetPort)
|
||||||
|
} else {
|
||||||
|
wizard.remotePort = int(selectedPort.Port)
|
||||||
|
}
|
||||||
wizard.step = StepEnterLocalPort
|
wizard.step = StepEnterLocalPort
|
||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
wizard.inputMode = InputModeText
|
wizard.inputMode = InputModeText
|
||||||
|
|||||||
@@ -349,9 +349,20 @@ func (m model) renderEnterRemotePort() string {
|
|||||||
// Render detected ports within viewport
|
// Render detected ports within viewport
|
||||||
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
||||||
port := wizard.detectedPorts[i]
|
port := wizard.detectedPorts[i]
|
||||||
portDesc := fmt.Sprintf("%d", port.Port)
|
// For services, show both service port and target port if they differ
|
||||||
if port.Name != "" {
|
var portDesc string
|
||||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||||
|
// Service with different target port: "80 → 8000 (http)"
|
||||||
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pod port or service with same port
|
||||||
|
portDesc = fmt.Sprintf("%d", port.Port)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := " "
|
prefix := " "
|
||||||
@@ -390,9 +401,17 @@ func (m model) renderEnterRemotePort() string {
|
|||||||
if len(wizard.detectedPorts) > 0 {
|
if len(wizard.detectedPorts) > 0 {
|
||||||
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
||||||
for _, port := range wizard.detectedPorts {
|
for _, port := range wizard.detectedPorts {
|
||||||
portDesc := fmt.Sprintf("%d", port.Port)
|
var portDesc string
|
||||||
if port.Name != "" {
|
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
portDesc = fmt.Sprintf("%d", port.Port)
|
||||||
|
if port.Name != "" {
|
||||||
|
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user