github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/k8s/portforward.go (about)

     1  package k8s
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net"
     7  	"net/http"
     8  	"net/url"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"k8s.io/apimachinery/pkg/util/httpstream"
    13  	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // registers gcp auth provider
    14  	"k8s.io/client-go/transport/spdy"
    15  
    16  	"github.com/tilt-dev/tilt/internal/k8s/portforward"
    17  
    18  	"github.com/pkg/errors"
    19  )
    20  
    21  type PortForwardClient interface {
    22  	// Creates a new port-forwarder that's bound to the given context's lifecycle.
    23  	// When the context is canceled, the port-forwarder will close.
    24  	CreatePortForwarder(ctx context.Context, namespace Namespace, podID PodID, localPort int, remotePort int, host string) (PortForwarder, error)
    25  }
    26  
    27  type PortForwarder interface {
    28  	// The local port we're listening on.
    29  	LocalPort() int
    30  
    31  	// Addresses that we're listening on.
    32  	Addresses() []string
    33  
    34  	// ReadyCh will be closed by ForwardPorts once the forwarding is successfully set up.
    35  	//
    36  	// ForwardPorts might return an error during initialization before forwarding is successfully set up, in which
    37  	// case this channel will NOT be closed.
    38  	ReadyCh() <-chan struct{}
    39  
    40  	// Listens on the configured port and forward all traffic to the container.
    41  	// Returns when the port-forwarder sees an unrecoverable error or
    42  	// when the context passed at creation is canceled.
    43  	ForwardPorts() error
    44  
    45  	// TODO(nick): If the port forwarder has any problems connecting to the pod,
    46  	// it just logs those as debug logs. I'm not sure that logs are the right API
    47  	// for this -- there are lots of cases (e.g., where you're deliberately
    48  	// restarting the pod) where it's ok if it drops the connection.
    49  	//
    50  	// I suspect what we actually need is a healthcheck/status field for the
    51  	// portforwarder that's exposed as part of the engine.
    52  }
    53  
    54  type portForwarder struct {
    55  	*portforward.PortForwarder
    56  	localPort int
    57  }
    58  
    59  var _ PortForwarder = portForwarder{}
    60  
    61  func (pf portForwarder) LocalPort() int {
    62  	return pf.localPort
    63  }
    64  
    65  func (pf portForwarder) ReadyCh() <-chan struct{} {
    66  	return pf.Ready
    67  }
    68  
    69  func (k *K8sClient) CreatePortForwarder(ctx context.Context, namespace Namespace, podID PodID, optionalLocalPort, remotePort int, host string) (PortForwarder, error) {
    70  	localPort := optionalLocalPort
    71  	if localPort == 0 {
    72  		// preferably, we'd set the localport to 0, and let the underlying function pick a port for us,
    73  		// to avoid the race condition potential of something else grabbing this port between
    74  		// the call to `getAvailablePort` and whenever `portForwarder` actually binds the port.
    75  		// the k8s client supports a local port of 0, and stores the actual local port assigned in a field,
    76  		// but unfortunately does not export that field, so there is no way for the caller to know which
    77  		// local port to talk to.
    78  		var err error
    79  		localPort, err = getAvailablePort()
    80  		if err != nil {
    81  			return nil, errors.Wrap(err, "failed to find an available local port")
    82  		}
    83  	}
    84  
    85  	return k.portForwardClient.CreatePortForwarder(ctx, namespace, podID, localPort, remotePort, host)
    86  }
    87  
    88  type newPodDialerFn func(namespace Namespace, podID PodID) (httpstream.Dialer, error)
    89  
    90  type portForwardClient struct {
    91  	newPodDialer newPodDialerFn
    92  }
    93  
    94  func ProvidePortForwardClient(
    95  	maybeRESTConfig RESTConfigOrError,
    96  	maybeClientset ClientsetOrError) PortForwardClient {
    97  	if maybeRESTConfig.Error != nil {
    98  		return explodingPortForwardClient{error: maybeRESTConfig.Error}
    99  	}
   100  	if maybeClientset.Error != nil {
   101  		return explodingPortForwardClient{error: maybeClientset.Error}
   102  	}
   103  
   104  	config := maybeRESTConfig.Config
   105  	core := maybeClientset.Clientset.CoreV1()
   106  	newPodDialer := newPodDialerFn(func(namespace Namespace, podID PodID) (httpstream.Dialer, error) {
   107  		transport, upgrader, err := spdy.RoundTripperFor(config)
   108  		if err != nil {
   109  			return nil, errors.Wrap(err, "error getting roundtripper")
   110  		}
   111  
   112  		req := core.RESTClient().Post().
   113  			Resource("pods").
   114  			Namespace(namespace.String()).
   115  			Name(podID.String()).
   116  			SubResource("portforward")
   117  
   118  		dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL())
   119  		return dialer, nil
   120  	})
   121  
   122  	return portForwardClient{
   123  		newPodDialer: newPodDialer,
   124  	}
   125  }
   126  
   127  func (c portForwardClient) CreatePortForwarder(ctx context.Context, namespace Namespace, podID PodID, localPort int, remotePort int, host string) (PortForwarder, error) {
   128  	dialer, err := c.newPodDialer(namespace, podID)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  	readyChan := make(chan struct{}, 1)
   133  
   134  	ports := []string{fmt.Sprintf("%d:%d", localPort, remotePort)}
   135  
   136  	var pf *portforward.PortForwarder
   137  
   138  	// The tiltfile evaluator always defaults the empty string.
   139  	//
   140  	// If it's defaulting to localhost, use the default kubernetse logic
   141  	// for binding the portforward.
   142  	if host == "" || host == "localhost" {
   143  		pf, err = portforward.New(
   144  			ctx,
   145  			dialer,
   146  			ports,
   147  			readyChan)
   148  	} else {
   149  		var addresses []string
   150  		addresses, err = getListenableAddresses(host)
   151  		if err != nil {
   152  			return nil, err
   153  		}
   154  		pf, err = portforward.NewOnAddresses(
   155  			ctx,
   156  			dialer,
   157  			addresses,
   158  			ports,
   159  			readyChan)
   160  	}
   161  	if err != nil {
   162  		return nil, errors.Wrap(err, "error forwarding port")
   163  	}
   164  
   165  	return portForwarder{
   166  		PortForwarder: pf,
   167  		localPort:     localPort,
   168  	}, nil
   169  }
   170  
   171  func getListenableAddresses(host string) ([]string, error) {
   172  	// handle IPv6 literals like `[::1]`
   173  	url, err := url.Parse(fmt.Sprintf("http://%s/", host))
   174  	if err != nil {
   175  		return nil, errors.Wrap(err, fmt.Sprintf("invalid host %s", host))
   176  	}
   177  	addresses, err := net.LookupHost(url.Hostname())
   178  	if err != nil {
   179  		return nil, errors.Wrap(err, fmt.Sprintf("failed to look up address for %s", host))
   180  	}
   181  	listenable := make([]string, 0)
   182  	for _, addr := range addresses {
   183  		var l net.Listener
   184  		if ipv6 := strings.Contains(addr, ":"); ipv6 {
   185  			// skip ipv6 addresses that include a zone index
   186  			// see: https://github.com/tilt-dev/tilt/issues/5981
   187  			if hasZoneIndex := strings.Contains(addr, "%"); hasZoneIndex {
   188  				continue
   189  			}
   190  
   191  			l, err = net.Listen("tcp6", fmt.Sprintf("[%s]:0", addr))
   192  		} else {
   193  			l, err = net.Listen("tcp4", fmt.Sprintf("%s:0", addr))
   194  		}
   195  		if err == nil {
   196  			l.Close()
   197  			listenable = append(listenable, addr)
   198  		}
   199  	}
   200  	if len(listenable) == 0 {
   201  		return nil, errors.Errorf("host %s: cannot listen on any resolved addresses: %v", host, addresses)
   202  	}
   203  	return listenable, nil
   204  }
   205  
   206  func getAvailablePort() (int, error) {
   207  	l, err := net.Listen("tcp", ":0")
   208  	if err != nil {
   209  		return 0, err
   210  	}
   211  	defer func() {
   212  		e := l.Close()
   213  		if err == nil {
   214  			err = e
   215  		}
   216  	}()
   217  
   218  	_, p, err := net.SplitHostPort(l.Addr().String())
   219  	if err != nil {
   220  		return 0, err
   221  	}
   222  	port, err := strconv.Atoi(p)
   223  	if err != nil {
   224  		return 0, err
   225  	}
   226  	return port, err
   227  }
   228  
   229  type explodingPortForwardClient struct {
   230  	error error
   231  }
   232  
   233  func (c explodingPortForwardClient) CreatePortForwarder(ctx context.Context, namespace Namespace, podID PodID, localPort int, remotePort int, host string) (PortForwarder, error) {
   234  	return nil, c.error
   235  }