diff --git a/internal/k8s/discovery.go b/internal/k8s/discovery.go index 6ac053c..deac9c3 100644 --- a/internal/k8s/discovery.go +++ b/internal/k8s/discovery.go @@ -9,6 +9,8 @@ import ( corev1 "k8s.io/api/core/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. @@ -41,9 +43,10 @@ type ContainerInfo struct { // PortInfo describes a port exposed by a container or service. type PortInfo struct { - Name string - Port int32 - Protocol string + Name string + Port int32 + TargetPort int32 // For services: the actual pod port to forward to + Protocol string } // ServiceInfo contains information about a service. @@ -205,7 +208,60 @@ func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, names 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. +// 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) { client, err := d.pool.GetClient(contextName) if err != nil { @@ -221,10 +277,13 @@ func (d *Discovery) ListServices(ctx context.Context, contextName, namespace str for _, svc := range svcList.Items { ports := make([]PortInfo, 0, len(svc.Spec.Ports)) for _, port := range svc.Spec.Ports { + targetPort := d.resolveTargetPort(ctx, client, namespace, &svc, &port) + ports = append(ports, PortInfo{ - Name: port.Name, - Port: port.Port, - Protocol: string(port.Protocol), + Name: port.Name, + Port: port.Port, + TargetPort: targetPort, + Protocol: string(port.Protocol), }) } diff --git a/internal/k8s/discovery_test.go b/internal/k8s/discovery_test.go new file mode 100644 index 0000000..62da92e --- /dev/null +++ b/internal/k8s/discovery_test.go @@ -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) +} diff --git a/internal/ui/wizard_handlers.go b/internal/ui/wizard_handlers.go index b268148..14311d5 100644 --- a/internal/ui/wizard_handlers.go +++ b/internal/ui/wizard_handlers.go @@ -468,7 +468,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) { wizard.clearTextInput() } else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) { // 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.clearTextInput() wizard.inputMode = InputModeText diff --git a/internal/ui/wizard_views.go b/internal/ui/wizard_views.go index 9e88bc7..3dced89 100644 --- a/internal/ui/wizard_views.go +++ b/internal/ui/wizard_views.go @@ -349,9 +349,20 @@ func (m model) renderEnterRemotePort() string { // Render detected ports within viewport for i := start; i < end && i < len(wizard.detectedPorts); i++ { port := wizard.detectedPorts[i] - portDesc := fmt.Sprintf("%d", port.Port) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) + // For services, show both service port and target port if they differ + var portDesc string + 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 := " " @@ -390,9 +401,17 @@ func (m model) renderEnterRemotePort() string { if len(wizard.detectedPorts) > 0 { b.WriteString(mutedStyle.Render("Detected ports:\n")) for _, port := range wizard.detectedPorts { - portDesc := fmt.Sprintf("%d", port.Port) - if port.Name != "" { - portDesc += fmt.Sprintf(" (%s)", port.Name) + var portDesc string + if port.TargetPort > 0 && port.TargetPort != port.Port { + 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))) }