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 }