mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
3a7cc6f502
* Minor improvements. * DRY the codebase. * Add version checker / updater.
287 lines
8.7 KiB
Go
287 lines
8.7 KiB
Go
package k8s
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nvm/kportal/internal/config"
|
|
|
|
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: config.DefaultTCPKeepalive,
|
|
dialTimeout: config.DefaultDialTimeout,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if len(service.Spec.Selector) == 0 {
|
|
return fmt.Errorf("service %s has no selector (headless service without selector cannot be port-forwarded)", serviceName)
|
|
}
|
|
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)
|
|
}
|
|
|
|
if len(service.Spec.Selector) == 0 {
|
|
return "", fmt.Errorf("service %s has no selector (headless service without selector cannot be port-forwarded)", resourceName)
|
|
}
|
|
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
|
|
}
|