github.com/mponton/terratest@v0.44.0/modules/k8s/tunnel.go (about) 1 package k8s 2 3 // The following code is a fork of the Helm client. The main differences are: 4 // - Support testing context for better logging 5 // - Support resources other than pods 6 // See: https://github.com/helm/helm/blob/master/pkg/kube/tunnel.go 7 8 import ( 9 "fmt" 10 "io" 11 "io/ioutil" 12 "net" 13 "net/http" 14 "strconv" 15 "strings" 16 "sync" 17 18 "github.com/stretchr/testify/require" 19 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 "k8s.io/client-go/tools/portforward" 21 "k8s.io/client-go/transport/spdy" 22 23 "github.com/mponton/terratest/modules/logger" 24 "github.com/mponton/terratest/modules/testing" 25 ) 26 27 // Global lock to synchronize port selections 28 var globalMutex sync.Mutex 29 30 // KubeResourceType is an enum representing known resource types that can support port forwarding 31 type KubeResourceType int 32 33 const ( 34 // ResourceTypePod is a k8s pod kind identifier 35 ResourceTypePod KubeResourceType = iota 36 // ResourceTypeDeployment is a k8s deployment kind identifier 37 ResourceTypeDeployment 38 // ResourceTypeService is a k8s service kind identifier 39 ResourceTypeService 40 ) 41 42 func (resourceType KubeResourceType) String() string { 43 switch resourceType { 44 case ResourceTypeDeployment: 45 return "deploy" 46 case ResourceTypePod: 47 return "pod" 48 case ResourceTypeService: 49 return "svc" 50 default: 51 // This should not happen 52 return "UNKNOWN_RESOURCE_TYPE" 53 } 54 } 55 56 // makeLabels is a helper to format a map of label key and value pairs into a single string for use as a selector. 57 func makeLabels(labels map[string]string) string { 58 out := []string{} 59 for key, value := range labels { 60 out = append(out, fmt.Sprintf("%s=%s", key, value)) 61 } 62 return strings.Join(out, ",") 63 } 64 65 // Tunnel is the main struct that configures and manages port forwading tunnels to Kubernetes resources. 66 type Tunnel struct { 67 out io.Writer 68 localPort int 69 remotePort int 70 kubectlOptions *KubectlOptions 71 resourceType KubeResourceType 72 resourceName string 73 logger logger.TestLogger 74 stopChan chan struct{} 75 readyChan chan struct{} 76 } 77 78 // NewTunnel creates a new tunnel with NewTunnelWithLogger, setting logger.Terratest as the logger. 79 func NewTunnel(kubectlOptions *KubectlOptions, resourceType KubeResourceType, resourceName string, local int, remote int) *Tunnel { 80 return NewTunnelWithLogger(kubectlOptions, resourceType, resourceName, local, remote, logger.Terratest) 81 } 82 83 // NewTunnelWithLogger will create a new Tunnel struct with the provided logger. 84 // Note that if you use 0 for the local port, an open port on the host system 85 // will be selected automatically, and the Tunnel struct will be updated with the selected port. 86 func NewTunnelWithLogger( 87 kubectlOptions *KubectlOptions, 88 resourceType KubeResourceType, 89 resourceName string, 90 local int, 91 remote int, 92 logger logger.TestLogger, 93 ) *Tunnel { 94 return &Tunnel{ 95 out: ioutil.Discard, 96 localPort: local, 97 remotePort: remote, 98 kubectlOptions: kubectlOptions, 99 resourceType: resourceType, 100 resourceName: resourceName, 101 logger: logger, 102 stopChan: make(chan struct{}, 1), 103 readyChan: make(chan struct{}, 1), 104 } 105 } 106 107 // Endpoint returns the tunnel endpoint 108 func (tunnel *Tunnel) Endpoint() string { 109 return fmt.Sprintf("localhost:%d", tunnel.localPort) 110 } 111 112 // Close disconnects a tunnel connection by closing the StopChan, thereby stopping the goroutine. 113 func (tunnel *Tunnel) Close() { 114 close(tunnel.stopChan) 115 } 116 117 // getAttachablePodForResource will find a pod that can be port forwarded to given the provided resource type and return 118 // the name. 119 func (tunnel *Tunnel) getAttachablePodForResourceE(t testing.TestingT) (string, error) { 120 switch tunnel.resourceType { 121 case ResourceTypePod: 122 return tunnel.resourceName, nil 123 case ResourceTypeService: 124 return tunnel.getAttachablePodForServiceE(t) 125 case ResourceTypeDeployment: 126 return tunnel.getAttachablePodForDeploymentE(t) 127 default: 128 return "", UnknownKubeResourceType{tunnel.resourceType} 129 } 130 } 131 132 // getAttachablePodForDeploymentE will find an active pod associated with the Deployment and return the pod name. 133 func (tunnel *Tunnel) getAttachablePodForDeploymentE(t testing.TestingT) (string, error) { 134 deploy, err := GetDeploymentE(t, tunnel.kubectlOptions, tunnel.resourceName) 135 if err != nil { 136 return "", err 137 } 138 selectorLabelsOfPods := makeLabels(deploy.Spec.Selector.MatchLabels) 139 deploymentPods, err := ListPodsE(t, tunnel.kubectlOptions, metav1.ListOptions{LabelSelector: selectorLabelsOfPods}) 140 if err != nil { 141 return "", err 142 } 143 for _, pod := range deploymentPods { 144 if IsPodAvailable(&pod) { 145 return pod.Name, nil 146 } 147 } 148 return "", DeploymentNotAvailable{deploy} 149 } 150 151 // getAttachablePodForServiceE will find an active pod associated with the Service and return the pod name. 152 func (tunnel *Tunnel) getAttachablePodForServiceE(t testing.TestingT) (string, error) { 153 service, err := GetServiceE(t, tunnel.kubectlOptions, tunnel.resourceName) 154 if err != nil { 155 return "", err 156 } 157 selectorLabelsOfPods := makeLabels(service.Spec.Selector) 158 servicePods, err := ListPodsE(t, tunnel.kubectlOptions, metav1.ListOptions{LabelSelector: selectorLabelsOfPods}) 159 if err != nil { 160 return "", err 161 } 162 for _, pod := range servicePods { 163 if IsPodAvailable(&pod) { 164 return pod.Name, nil 165 } 166 } 167 return "", ServiceNotAvailable{service} 168 } 169 170 // ForwardPort opens a tunnel to a kubernetes resource, as specified by the provided tunnel struct. This will fail the 171 // test if there is an error attempting to open the port. 172 func (tunnel *Tunnel) ForwardPort(t testing.TestingT) { 173 require.NoError(t, tunnel.ForwardPortE(t)) 174 } 175 176 // ForwardPortE opens a tunnel to a kubernetes resource, as specified by the provided tunnel struct. 177 func (tunnel *Tunnel) ForwardPortE(t testing.TestingT) error { 178 tunnel.logger.Logf( 179 t, 180 "Creating a port forwarding tunnel for resource %s/%s routing local port %d to remote port %d", 181 tunnel.resourceType.String(), 182 tunnel.resourceName, 183 tunnel.localPort, 184 tunnel.remotePort, 185 ) 186 187 // Prepare a kubernetes client for the client-go library 188 clientset, err := GetKubernetesClientFromOptionsE(t, tunnel.kubectlOptions) 189 if err != nil { 190 tunnel.logger.Logf(t, "Error creating a new Kubernetes client: %s", err) 191 return err 192 } 193 kubeConfigPath, err := tunnel.kubectlOptions.GetConfigPath(t) 194 if err != nil { 195 tunnel.logger.Logf(t, "Error getting kube config path: %s", err) 196 return err 197 } 198 config, err := LoadApiClientConfigE(kubeConfigPath, tunnel.kubectlOptions.ContextName) 199 if err != nil { 200 tunnel.logger.Logf(t, "Error loading Kubernetes config: %s", err) 201 return err 202 } 203 204 // Find the pod to port forward to 205 podName, err := tunnel.getAttachablePodForResourceE(t) 206 if err != nil { 207 tunnel.logger.Logf(t, "Error finding available pod: %s", err) 208 return err 209 } 210 tunnel.logger.Logf(t, "Selected pod %s to open port forward to", podName) 211 212 // Build a url to the portforward endpoint 213 // example: http://localhost:8080/api/v1/namespaces/helm/pods/tiller-deploy-9itlq/portforward 214 postEndpoint := clientset.CoreV1().RESTClient().Post() 215 namespace := tunnel.kubectlOptions.Namespace 216 portForwardCreateURL := postEndpoint. 217 Resource("pods"). 218 Namespace(namespace). 219 Name(podName). 220 SubResource("portforward"). 221 URL() 222 223 tunnel.logger.Logf(t, "Using URL %s to create portforward", portForwardCreateURL) 224 225 // Construct the spdy client required by the client-go portforward library 226 transport, upgrader, err := spdy.RoundTripperFor(config) 227 if err != nil { 228 tunnel.logger.Logf(t, "Error creating http client: %s", err) 229 return err 230 } 231 dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", portForwardCreateURL) 232 233 // If the localport is 0, get an available port before continuing. We do this here instead of relying on the 234 // underlying portforwarder library, because the portforwarder library does not expose the selected local port in a 235 // machine readable manner. 236 // Synchronize on the global lock to avoid race conditions with concurrently selecting the same available port, 237 // since there is a brief moment between `GetAvailablePort` and `portforwader.ForwardPorts` where the selected port 238 // is available for selection again. 239 if tunnel.localPort == 0 { 240 tunnel.logger.Logf(t, "Requested local port is 0. Selecting an open port on host system") 241 tunnel.localPort, err = GetAvailablePortE(t) 242 if err != nil { 243 tunnel.logger.Logf(t, "Error getting available port: %s", err) 244 return err 245 } 246 tunnel.logger.Logf(t, "Selected port %d", tunnel.localPort) 247 globalMutex.Lock() 248 defer globalMutex.Unlock() 249 } 250 251 // Construct a new PortForwarder struct that manages the instructed port forward tunnel 252 ports := []string{fmt.Sprintf("%d:%d", tunnel.localPort, tunnel.remotePort)} 253 portforwarder, err := portforward.New(dialer, ports, tunnel.stopChan, tunnel.readyChan, tunnel.out, tunnel.out) 254 if err != nil { 255 tunnel.logger.Logf(t, "Error creating port forwarding tunnel: %s", err) 256 return err 257 } 258 259 // Open the tunnel in a goroutine so that it is available in the background. Report errors to the main goroutine via 260 // a new channel. 261 errChan := make(chan error) 262 go func() { 263 errChan <- portforwarder.ForwardPorts() 264 }() 265 266 // Wait for an error or the tunnel to be ready 267 select { 268 case err = <-errChan: 269 tunnel.logger.Logf(t, "Error starting port forwarding tunnel: %s", err) 270 return err 271 case <-portforwarder.Ready: 272 tunnel.logger.Logf(t, "Successfully created port forwarding tunnel") 273 return nil 274 } 275 } 276 277 // GetAvailablePort retrieves an available port on the host machine. This delegates the port selection to the golang net 278 // library by starting a server and then checking the port that the server is using. This will fail the test if it could 279 // not find an available port. 280 func GetAvailablePort(t testing.TestingT) int { 281 port, err := GetAvailablePortE(t) 282 require.NoError(t, err) 283 return port 284 } 285 286 // GetAvailablePortE retrieves an available port on the host machine. This delegates the port selection to the golang net 287 // library by starting a server and then checking the port that the server is using. 288 func GetAvailablePortE(t testing.TestingT) (int, error) { 289 l, err := net.Listen("tcp", ":0") 290 if err != nil { 291 return 0, err 292 } 293 defer l.Close() 294 295 _, p, err := net.SplitHostPort(l.Addr().String()) 296 if err != nil { 297 return 0, err 298 } 299 port, err := strconv.Atoi(p) 300 if err != nil { 301 return 0, err 302 } 303 return port, err 304 }