// Package portforward provides low-level K8s port-forwarding primitives. // These are the pure K8s API building blocks: finding pods, finding ports, // and running tunnels. Lifecycle management and singleton state live in each // consumer (e.g., Radar's internal/portforward for metrics proxying). package portforward import ( "context" "fmt" "io" "net" "net/http" "net/url" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" streamhttp "k8s.io/streaming/pkg/httpstream" ) // NewDialer builds a port-forward dialer that uses the WebSocket API // (SPDY-over-WebSocket), falling back to the legacy raw-SPDY API when the // apiserver doesn't support the WebSocket port-forward subprotocol. func NewDialer(config *rest.Config, u *url.URL) (httpstream.Dialer, error) { wsDialer, err := portforward.NewSPDYOverWebsocketDialer(u, config) if err != nil { return nil, fmt.Errorf("failed to create websocket dialer: %w", err) } transport, upgrader, err := spdy.RoundTripperFor(config) if err != nil { return nil, fmt.Errorf("failed to create round tripper: %w", err) } spdyDialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", u) // The WebSocket dialer is built on k8s.io/streaming/pkg/httpstream, so a // rejected upgrade surfaces as that package's error types. shouldFallback := func(err error) bool { return streamhttp.IsUpgradeFailure(err) || streamhttp.IsHTTPSProxyError(err) } return portforward.NewFallbackDialer(wsDialer, spdyDialer, shouldFallback), nil } // RunPortForward runs a port-forward from localPort to targetPort on the given pod. // It blocks until the port-forward terminates (stopCh closed or context cancelled). // readyCh is closed once the tunnel is established and ready to accept connections. func RunPortForward(ctx context.Context, client kubernetes.Interface, config *rest.Config, namespace, podName string, localPort, targetPort int, stopCh chan struct{}, readyCh chan struct{}, ) error { req := client.CoreV1().RESTClient().Post(). Resource("pods"). Name(podName). Namespace(namespace). SubResource("portforward"). VersionedParams(&corev1.PodPortForwardOptions{ Ports: []int32{int32(targetPort)}, }, scheme.ParameterCodec) dialer, err := NewDialer(config, req.URL()) if err != nil { return err } ports := []string{fmt.Sprintf("%d:%d", localPort, targetPort)} pf, err := portforward.New(dialer, ports, stopCh, readyCh, io.Discard, io.Discard) if err != nil { return fmt.Errorf("failed to create port forwarder: %w", err) } return pf.ForwardPorts() } // FindPodForService finds a running pod backing the given service by selector matching. func FindPodForService(ctx context.Context, client kubernetes.Interface, namespace, serviceName string) (string, error) { svc, err := client.CoreV1().Services(namespace).Get(ctx, serviceName, metav1.GetOptions{}) if err != nil { return "", fmt.Errorf("failed to get service: %w", err) } if svc.Spec.ClusterIP == "None" || svc.Spec.ClusterIP == "" { if len(svc.Spec.Selector) == 0 { return "", fmt.Errorf("headless service %s has no selector", serviceName) } } else if len(svc.Spec.Selector) == 0 { return "", fmt.Errorf("service %s has no selector", serviceName) } var selector string for k, v := range svc.Spec.Selector { if selector != "" { selector += "," } selector += k + "=" + v } pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: selector, }) if err != nil { return "", fmt.Errorf("failed to list pods: %w", err) } if len(pods.Items) == 0 { return "", fmt.Errorf("no pods found matching selector for service %s", serviceName) } if serving := ServingPods(pods.Items); len(serving) > 0 { return serving[0].Name, nil } return "", fmt.Errorf("no running pod found for service %s (pods being deleted are skipped)", serviceName) } // ServingPods returns the pods a forward can be pointed at, best first: ready // pods, then running pods that are not ready. A pod being deleted can still // read Running, and a forward to it can hang until it times out, so none is // returned. A pod that is not ready is kept because readiness can fail for a // reason unrelated to the forwarded port. func ServingPods(pods []corev1.Pod) []*corev1.Pod { var ready, notReady []*corev1.Pod for i := range pods { pod := &pods[i] if pod.Status.Phase != corev1.PodRunning || pod.DeletionTimestamp != nil { continue } if podReady(pod) { ready = append(ready, pod) } else { notReady = append(notReady, pod) } } return append(ready, notReady...) } func podReady(pod *corev1.Pod) bool { for _, c := range pod.Status.Conditions { if c.Type == corev1.PodReady { return c.Status == corev1.ConditionTrue } } return false } // FindFreePort finds an available local TCP port. func FindFreePort() (int, error) { addr, err := net.ResolveTCPAddr("tcp", "localhost:0") if err != nil { return 0, err } l, err := net.ListenTCP("tcp", addr) if err != nil { return 0, err } defer l.Close() return l.Addr().(*net.TCPAddr).Port, nil }