github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/pkg/k8s/tunnel.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package k8s provides a client for interacting with a Kubernetes cluster.
     5  package k8s
     6  
     7  // Forked from https://github.com/gruntwork-io/terratest/blob/v0.38.8/modules/k8s/tunnel.go
     8  
     9  import (
    10  	"fmt"
    11  	"io"
    12  	"net/http"
    13  	"sync"
    14  	"time"
    15  
    16  	"github.com/defenseunicorns/pkg/helpers"
    17  	"k8s.io/client-go/tools/portforward"
    18  	"k8s.io/client-go/transport/spdy"
    19  )
    20  
    21  // Global lock to synchronize port selections.
    22  var globalMutex sync.Mutex
    23  
    24  // Jackal Tunnel Configuration Constants.
    25  const (
    26  	PodResource = "pod"
    27  	SvcResource = "svc"
    28  )
    29  
    30  // Tunnel is the main struct that configures and manages port forwarding tunnels to Kubernetes resources.
    31  type Tunnel struct {
    32  	kube         *K8s
    33  	out          io.Writer
    34  	localPort    int
    35  	remotePort   int
    36  	namespace    string
    37  	resourceType string
    38  	resourceName string
    39  	urlSuffix    string
    40  	attempt      int
    41  	stopChan     chan struct{}
    42  	readyChan    chan struct{}
    43  	errChan      chan error
    44  }
    45  
    46  // NewTunnel will create a new Tunnel struct.
    47  // Note that if you use 0 for the local port, an open port on the host system
    48  // will be selected automatically, and the Tunnel struct will be updated with the selected port.
    49  func (k *K8s) NewTunnel(namespace, resourceType, resourceName, urlSuffix string, local, remote int) (*Tunnel, error) {
    50  	return &Tunnel{
    51  		out:          io.Discard,
    52  		localPort:    local,
    53  		remotePort:   remote,
    54  		namespace:    namespace,
    55  		resourceType: resourceType,
    56  		resourceName: resourceName,
    57  		urlSuffix:    urlSuffix,
    58  		stopChan:     make(chan struct{}, 1),
    59  		readyChan:    make(chan struct{}, 1),
    60  		kube:         k,
    61  	}, nil
    62  }
    63  
    64  // Wrap takes a function that returns an error and wraps it to check for tunnel errors as well.
    65  func (tunnel *Tunnel) Wrap(function func() error) error {
    66  	var err error
    67  	funcErrChan := make(chan error)
    68  
    69  	go func() {
    70  		funcErrChan <- function()
    71  	}()
    72  
    73  	select {
    74  	case err = <-funcErrChan:
    75  		return err
    76  	case err = <-tunnel.ErrChan():
    77  		return err
    78  	}
    79  }
    80  
    81  // Connect will establish a tunnel to the specified target.
    82  func (tunnel *Tunnel) Connect() (string, error) {
    83  	url, err := tunnel.establish()
    84  
    85  	// Try to establish the tunnel up to 3 times.
    86  	if err != nil {
    87  		tunnel.attempt++
    88  		// If we have exceeded the number of attempts, exit with an error.
    89  		if tunnel.attempt > 3 {
    90  			return "", fmt.Errorf("unable to establish tunnel after 3 attempts: %w", err)
    91  		}
    92  		// Otherwise, retry the connection but delay increasing intervals between attempts.
    93  		delay := tunnel.attempt * 10
    94  		tunnel.kube.Log("%s", err.Error())
    95  		tunnel.kube.Log("Delay creating tunnel, waiting %d seconds...", delay)
    96  		time.Sleep(time.Duration(delay) * time.Second)
    97  		url, err = tunnel.Connect()
    98  		if err != nil {
    99  			return "", err
   100  		}
   101  	}
   102  
   103  	return url, nil
   104  }
   105  
   106  // Endpoint returns the tunnel ip address and port (i.e. for docker registries)
   107  func (tunnel *Tunnel) Endpoint() string {
   108  	return fmt.Sprintf("%s:%d", helpers.IPV4Localhost, tunnel.localPort)
   109  }
   110  
   111  // ErrChan returns the tunnel's error channel
   112  func (tunnel *Tunnel) ErrChan() chan error {
   113  	return tunnel.errChan
   114  }
   115  
   116  // HTTPEndpoint returns the tunnel endpoint as a HTTP URL string.
   117  func (tunnel *Tunnel) HTTPEndpoint() string {
   118  	return fmt.Sprintf("http://%s", tunnel.Endpoint())
   119  }
   120  
   121  // FullURL returns the tunnel endpoint as a HTTP URL string with the urlSuffix appended.
   122  func (tunnel *Tunnel) FullURL() string {
   123  	return fmt.Sprintf("%s%s", tunnel.HTTPEndpoint(), tunnel.urlSuffix)
   124  }
   125  
   126  // Close disconnects a tunnel connection by closing the StopChan, thereby stopping the goroutine.
   127  func (tunnel *Tunnel) Close() {
   128  	close(tunnel.stopChan)
   129  }
   130  
   131  // establish opens a tunnel to a kubernetes resource, as specified by the provided tunnel struct.
   132  func (tunnel *Tunnel) establish() (string, error) {
   133  	var err error
   134  
   135  	// Track this locally as we may need to retry if the tunnel fails.
   136  	localPort := tunnel.localPort
   137  
   138  	// If the local-port is 0, get an available port before continuing. We do this here instead of relying on the
   139  	// underlying port-forwarder library, because the port-forwarder library does not expose the selected local port in a
   140  	// machine-readable manner.
   141  	// Synchronize on the global lock to avoid race conditions with concurrently selecting the same available port,
   142  	// since there is a brief moment between `GetAvailablePort` and `forwarder.ForwardPorts` where the selected port
   143  	// is available for selection again.
   144  	if localPort == 0 {
   145  		tunnel.kube.Log("Requested local port is 0. Selecting an open port on host system")
   146  		localPort, err = helpers.GetAvailablePort()
   147  		if err != nil {
   148  			return "", fmt.Errorf("unable to find an available port: %w", err)
   149  		}
   150  		tunnel.kube.Log("Selected port %d", localPort)
   151  		globalMutex.Lock()
   152  		defer globalMutex.Unlock()
   153  	}
   154  
   155  	message := fmt.Sprintf("Opening tunnel %d -> %d for %s/%s in namespace %s",
   156  		localPort,
   157  		tunnel.remotePort,
   158  		tunnel.resourceType,
   159  		tunnel.resourceName,
   160  		tunnel.namespace,
   161  	)
   162  
   163  	tunnel.kube.Log(message)
   164  
   165  	// Find the pod to port forward to
   166  	podName, err := tunnel.getAttachablePodForResource()
   167  	if err != nil {
   168  		return "", fmt.Errorf("unable to find pod attached to given resource: %w", err)
   169  	}
   170  	tunnel.kube.Log("Selected pod %s to open port forward to", podName)
   171  
   172  	// Build url to the port forward endpoint.
   173  	// Example: http://localhost:8080/api/v1/namespaces/helm/pods/tiller-deploy-9itlq/portforward.
   174  	postEndpoint := tunnel.kube.Clientset.CoreV1().RESTClient().Post()
   175  	namespace := tunnel.namespace
   176  	portForwardCreateURL := postEndpoint.
   177  		Resource("pods").
   178  		Namespace(namespace).
   179  		Name(podName).
   180  		SubResource("portforward").
   181  		URL()
   182  
   183  	tunnel.kube.Log("Using URL %s to create portforward", portForwardCreateURL)
   184  
   185  	// Construct the spdy client required by the client-go portforward library.
   186  	transport, upgrader, err := spdy.RoundTripperFor(tunnel.kube.RestConfig)
   187  	if err != nil {
   188  		return "", fmt.Errorf("unable to create the spdy client %w", err)
   189  	}
   190  	dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", portForwardCreateURL)
   191  
   192  	// Construct a new PortForwarder struct that manages the instructed port forward tunnel.
   193  	ports := []string{fmt.Sprintf("%d:%d", localPort, tunnel.remotePort)}
   194  	portforwarder, err := portforward.New(dialer, ports, tunnel.stopChan, tunnel.readyChan, tunnel.out, tunnel.out)
   195  	if err != nil {
   196  		return "", fmt.Errorf("unable to create the port forward: %w", err)
   197  	}
   198  
   199  	// Open the tunnel in a goroutine so that it is available in the background. Report errors to the main goroutine via
   200  	// a new channel.
   201  	errChan := make(chan error)
   202  	go func() {
   203  		errChan <- portforwarder.ForwardPorts()
   204  	}()
   205  
   206  	// Wait for an error or the tunnel to be ready.
   207  	select {
   208  	case err = <-errChan:
   209  		return "", fmt.Errorf("unable to start the tunnel: %w", err)
   210  	case <-portforwarder.Ready:
   211  		// Store for endpoint output
   212  		tunnel.localPort = localPort
   213  		url := tunnel.FullURL()
   214  
   215  		// Store the error channel to listen for errors
   216  		tunnel.errChan = errChan
   217  
   218  		tunnel.kube.Log("Creating port forwarding tunnel at %s", url)
   219  		return url, nil
   220  	}
   221  }
   222  
   223  // getAttachablePodForResource will find a pod that can be port forwarded to the provided resource type and return
   224  // the name.
   225  func (tunnel *Tunnel) getAttachablePodForResource() (string, error) {
   226  	switch tunnel.resourceType {
   227  	case PodResource:
   228  		return tunnel.resourceName, nil
   229  	case SvcResource:
   230  		return tunnel.getAttachablePodForService()
   231  	default:
   232  		return "", fmt.Errorf("unknown resource type: %s", tunnel.resourceType)
   233  	}
   234  }
   235  
   236  // getAttachablePodForService will find an active pod associated with the Service and return the pod name.
   237  func (tunnel *Tunnel) getAttachablePodForService() (string, error) {
   238  	service, err := tunnel.kube.GetService(tunnel.namespace, tunnel.resourceName)
   239  	if err != nil {
   240  		return "", fmt.Errorf("unable to find the service: %w", err)
   241  	}
   242  	selectorLabelsOfPods := MakeLabels(service.Spec.Selector)
   243  
   244  	servicePods := tunnel.kube.WaitForPodsAndContainers(PodLookup{
   245  		Namespace: tunnel.namespace,
   246  		Selector:  selectorLabelsOfPods,
   247  	}, nil)
   248  
   249  	if len(servicePods) < 1 {
   250  		return "", fmt.Errorf("no pods found for service %s", tunnel.resourceName)
   251  	}
   252  	return servicePods[0].Name, nil
   253  }