Files
kportal/internal/k8s/portforward.go
T
lukaszraczylo 2fdc5912e7 healtcheck improvements (#4)
* Advanced healtchecks.
* Add watchdog for stale connections handling.
2025-11-24 13:00:19 +00:00

279 lines
8.4 KiB
Go

package k8s
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/portforward"
"k8s.io/client-go/transport/spdy"
)
// PortForwarder handles Kubernetes port-forwarding operations.
type PortForwarder struct {
clientPool *ClientPool
resolver *ResourceResolver
tcpKeepalive time.Duration // TCP keepalive interval
dialTimeout time.Duration // Connection dial timeout
}
// NewPortForwarder creates a new PortForwarder instance with default settings.
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
return &PortForwarder{
clientPool: clientPool,
resolver: resolver,
tcpKeepalive: 30 * time.Second, // Default: 30 second keepalive
dialTimeout: 30 * time.Second, // Default: 30 second dial timeout
}
}
// SetTCPKeepalive configures the TCP keepalive interval for new connections.
func (pf *PortForwarder) SetTCPKeepalive(keepalive time.Duration) {
pf.tcpKeepalive = keepalive
}
// SetDialTimeout configures the connection dial timeout.
func (pf *PortForwarder) SetDialTimeout(timeout time.Duration) {
pf.dialTimeout = timeout
}
// ForwardRequest contains the parameters for a port-forward request.
type ForwardRequest struct {
ContextName string // Kubernetes context name
Namespace string // Namespace
Resource string // Resource (pod/name or service/name)
Selector string // Label selector (for pod resolution)
LocalPort int // Local port
RemotePort int // Remote port
StopChan chan struct{}
ReadyChan chan struct{}
Out io.Writer // Output writer for logs
ErrOut io.Writer // Error output writer
}
// Forward establishes a port-forward connection to a Kubernetes resource.
// It supports both pod and service forwarding.
// The connection runs until StopChan is closed or an error occurs.
func (pf *PortForwarder) Forward(ctx context.Context, req *ForwardRequest) error {
// Resolve the resource to an actual pod name
resolvedResource, err := pf.resolver.Resolve(ctx, req.ContextName, req.Namespace, req.Resource, req.Selector)
if err != nil {
return fmt.Errorf("failed to resolve resource: %w", err)
}
// Parse the resolved resource
parts := strings.SplitN(resolvedResource, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
}
resourceType := parts[0]
resourceName := parts[1]
// Handle different resource types
switch resourceType {
case "pod":
return pf.forwardToPod(ctx, req, resourceName)
case "service":
return pf.forwardToService(ctx, req, resourceName)
default:
return fmt.Errorf("unsupported resource type: %s", resourceType)
}
}
// forwardToPod establishes a port-forward to a specific pod.
func (pf *PortForwarder) forwardToPod(ctx context.Context, req *ForwardRequest, podName string) error {
// Get Kubernetes client and config
client, err := pf.clientPool.GetClient(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
config, err := pf.clientPool.GetRestConfig(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get rest config: %w", err)
}
// Verify pod exists and is running
pod, err := client.CoreV1().Pods(req.Namespace).Get(ctx, podName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get pod: %w", err)
}
if pod.Status.Phase != corev1.PodRunning {
return fmt.Errorf("pod is not running (current phase: %s)", pod.Status.Phase)
}
// Build the port-forward URL
reqURL := client.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(req.Namespace).
Name(podName).
SubResource("portforward").
URL()
// Create the port-forward
return pf.executePortForward(config, reqURL, req)
}
// forwardToService establishes a port-forward to a service.
// This resolves the service to its backing pods and forwards to one of them.
func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardRequest, serviceName string) error {
// Get Kubernetes client
client, err := pf.clientPool.GetClient(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// Get the service
service, err := client.CoreV1().Services(req.Namespace).Get(ctx, serviceName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
// Get pods backing the service using label selector
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
LabelSelector: selector,
})
if err != nil {
return fmt.Errorf("failed to list pods for service: %w", err)
}
// Find first running pod
var targetPod *corev1.Pod
for i := range pods.Items {
pod := &pods.Items[i]
if pod.Status.Phase == corev1.PodRunning {
targetPod = pod
break
}
}
if targetPod == nil {
return fmt.Errorf("no running pods found for service %s", serviceName)
}
// Forward to the pod
config, err := pf.clientPool.GetRestConfig(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get rest config: %w", err)
}
reqURL := client.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(req.Namespace).
Name(targetPod.Name).
SubResource("portforward").
URL()
return pf.executePortForward(config, reqURL, req)
}
// executePortForward performs the actual port-forward operation.
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
// Configure TCP settings on the underlying connection
// This is set in the rest.Config which will be used by the SPDY transport
if config.Dial == nil {
// Create a custom dialer with configurable timeout and keepalive
// - Timeout: How long to wait for connection to establish
// - KeepAlive: TCP keepalive helps OS detect dead connections at network layer
dialer := &net.Dialer{
Timeout: pf.dialTimeout, // Configurable dial timeout
KeepAlive: pf.tcpKeepalive, // Configurable keepalive interval
}
config.Dial = dialer.DialContext
}
// Create SPDY roundtripper
transport, upgrader, err := spdy.RoundTripperFor(config)
if err != nil {
return fmt.Errorf("failed to create round tripper: %w", err)
}
// Create dialer
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url)
// Set up port forwarding
ports := []string{fmt.Sprintf("%d:%d", req.LocalPort, req.RemotePort)}
// Create output writers
out := req.Out
errOut := req.ErrOut
if out == nil {
out = io.Discard
}
if errOut == nil {
errOut = io.Discard
}
// Create port forwarder
fw, err := portforward.New(dialer, ports, req.StopChan, req.ReadyChan, out, errOut)
if err != nil {
return fmt.Errorf("failed to create port forwarder: %w", err)
}
// Start forwarding (blocks until stopped or error)
if err := fw.ForwardPorts(); err != nil {
return fmt.Errorf("port forward failed: %w", err)
}
return nil
}
// GetPodForResource returns the pod name that would be used for forwarding.
// This is useful for logging and debugging.
func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
resolvedResource, err := pf.resolver.Resolve(ctx, contextName, namespace, resource, selector)
if err != nil {
return "", err
}
parts := strings.SplitN(resolvedResource, "/", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
}
resourceType := parts[0]
resourceName := parts[1]
if resourceType == "service" {
// For services, need to resolve to backing pod
client, err := pf.clientPool.GetClient(contextName)
if err != nil {
return "", fmt.Errorf("failed to get client: %w", err)
}
service, err := client.CoreV1().Services(namespace).Get(ctx, resourceName, metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("failed to get service: %w", err)
}
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: selector,
})
if err != nil {
return "", fmt.Errorf("failed to list pods: %w", err)
}
for i := range pods.Items {
if pods.Items[i].Status.Phase == corev1.PodRunning {
return pods.Items[i].Name, nil
}
}
return "", fmt.Errorf("no running pods found for service")
}
return resourceName, nil
}