github.com/grahambrereton-form3/tilt@v0.10.18/integration/k8s_fixture_test.go (about)

     1  //+build integration
     2  
     3  package integration
     4  
     5  import (
     6  	"bytes"
     7  	"context"
     8  	"encoding/base64"
     9  	"fmt"
    10  	"io/ioutil"
    11  	"log"
    12  	"net/http"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"strings"
    17  	"testing"
    18  	"time"
    19  
    20  	"github.com/pkg/errors"
    21  	v1 "k8s.io/api/core/v1"
    22  
    23  	"github.com/windmilleng/tilt/internal/testutils/tempdir"
    24  )
    25  
    26  var k8sInstalled bool
    27  
    28  type k8sFixture struct {
    29  	*fixture
    30  	tempDir *tempdir.TempDirFixture
    31  
    32  	token          string
    33  	cert           string
    34  	kubeconfigPath string
    35  }
    36  
    37  func newK8sFixture(t *testing.T, dir string) *k8sFixture {
    38  	f := newFixture(t, dir)
    39  	td := tempdir.NewTempDirFixture(t)
    40  
    41  	kf := &k8sFixture{fixture: f, tempDir: td}
    42  
    43  	if !k8sInstalled {
    44  		kf.checkKubectlConnection()
    45  
    46  		// Delete the namespace when the test starts,
    47  		// to make sure nothing is left over from previous tests.
    48  		kf.deleteNamespace()
    49  
    50  		k8sInstalled = true
    51  	} else {
    52  		kf.ClearNamespace()
    53  	}
    54  
    55  	return kf
    56  }
    57  
    58  func (f *k8sFixture) checkKubectlConnection() {
    59  	cmd := exec.CommandContext(f.ctx, "kubectl", "version")
    60  	f.runOrFail(cmd, "Checking kubectl connection")
    61  }
    62  
    63  func (f *k8sFixture) deleteNamespace() {
    64  	cmd := exec.CommandContext(f.ctx, "kubectl", "delete", "namespace", "tilt-integration", "--ignore-not-found")
    65  	f.runOrFail(cmd, "Deleting namespace tilt-integration")
    66  
    67  	// block until the namespace doesn't exist, since kubectl often returns and the namespace is still "terminating"
    68  	// which causes the creation of objects in that namespace to fail
    69  	var b []byte
    70  	args := []string{"kubectl", "get", "namespace", "tilt-integration", "--ignore-not-found"}
    71  	timeout := time.Now().Add(10 * time.Second)
    72  	for time.Now().Before(timeout) {
    73  		cmd := exec.CommandContext(f.ctx, args[0], args[1:]...)
    74  		b, err := cmd.Output()
    75  		if err != nil {
    76  			f.t.Fatalf("Error: checking that deletion of the tilt-integration namespace has completed: %v", err)
    77  		}
    78  		if len(b) == 0 {
    79  			return
    80  		}
    81  	}
    82  	f.t.Fatalf("timed out waiting for tilt-integration deletion to complete. last output of %q: %q", args, string(b))
    83  }
    84  
    85  func (f *k8sFixture) Curl(url string) (string, error) {
    86  	resp, err := http.Get(url)
    87  	if err != nil {
    88  		return "", errors.Wrap(err, "Curl")
    89  	}
    90  	defer resp.Body.Close()
    91  
    92  	if resp.StatusCode != 200 {
    93  		f.t.Errorf("Error fetching %s: %s", url, resp.Status)
    94  	}
    95  
    96  	body, err := ioutil.ReadAll(resp.Body)
    97  	if err != nil {
    98  		return "", errors.Wrap(err, "Curl")
    99  	}
   100  	return string(body), nil
   101  }
   102  
   103  func (f *k8sFixture) CurlUntil(ctx context.Context, url string, expectedContents string) {
   104  	f.WaitUntil(ctx, fmt.Sprintf("curl(%s)", url), func() (string, error) {
   105  		return f.Curl(url)
   106  	}, expectedContents)
   107  }
   108  
   109  // Waits until all pods matching the selector are ready (i.e. phase = "Running")
   110  // At least one pod must match.
   111  // Returns the names of the ready pods.
   112  func (f *k8sFixture) WaitForAllPodsReady(ctx context.Context, selector string) []string {
   113  	return f.WaitForAllPodsInPhase(ctx, selector, v1.PodRunning)
   114  }
   115  
   116  func (f *k8sFixture) WaitForAllPodsInPhase(ctx context.Context, selector string, phase v1.PodPhase) []string {
   117  	for {
   118  		allPodsReady, output, podNames := f.AllPodsInPhase(ctx, selector, phase)
   119  		if allPodsReady {
   120  			return podNames
   121  		}
   122  
   123  		select {
   124  		case <-f.activeTiltDone():
   125  			f.t.Fatalf("Tilt died while waiting for pods to be ready: %v", f.activeTiltErr())
   126  		case <-ctx.Done():
   127  			f.t.Fatalf("Timed out waiting for pods to be ready. Selector: %s. Output:\n:%s\n", selector, output)
   128  		case <-time.After(200 * time.Millisecond):
   129  		}
   130  	}
   131  }
   132  
   133  // Checks that all pods are in the given phase
   134  // Returns the output (for diagnostics) and the name of the pods in the given phase.
   135  func (f *k8sFixture) AllPodsInPhase(ctx context.Context, selector string, phase v1.PodPhase) (bool, string, []string) {
   136  	cmd := exec.Command("kubectl", "get", "pods",
   137  		namespaceFlag, "--selector="+selector, "-o=template",
   138  		"--template", "{{range .items}}{{.metadata.name}} {{.status.phase}}{{println}}{{end}}")
   139  	out, err := cmd.CombinedOutput()
   140  	if err != nil {
   141  		f.t.Fatal(errors.Wrap(err, "get pods"))
   142  	}
   143  
   144  	outStr := string(out)
   145  	lines := strings.Split(outStr, "\n")
   146  	podNames := []string{}
   147  	hasOneMatchingPod := false
   148  	for _, line := range lines {
   149  		line = strings.TrimSpace(line)
   150  		if line == "" {
   151  			continue
   152  		}
   153  
   154  		elements := strings.Split(line, " ")
   155  		if len(elements) < 2 {
   156  			f.t.Fatalf("Unexpected output of kubect get pods: %s", outStr)
   157  		}
   158  
   159  		name, actualPhase := elements[0], elements[1]
   160  		var matchedPhase bool
   161  		if actualPhase == string(phase) {
   162  			matchedPhase = true
   163  			hasOneMatchingPod = true
   164  
   165  		}
   166  
   167  		if !matchedPhase {
   168  			return false, outStr, nil
   169  		}
   170  
   171  		podNames = append(podNames, name)
   172  	}
   173  	return hasOneMatchingPod, outStr, podNames
   174  }
   175  
   176  func (f *k8sFixture) ForwardPort(name string, portMap string) {
   177  	cmd := exec.CommandContext(f.ctx, "kubectl", "port-forward", namespaceFlag, name, portMap)
   178  	cmd.Stdout = os.Stdout
   179  	cmd.Stderr = os.Stdout
   180  	err := cmd.Start()
   181  	if err != nil {
   182  		f.t.Fatal(err)
   183  	}
   184  
   185  	f.cmds = append(f.cmds, cmd)
   186  	go func() {
   187  		err := cmd.Wait()
   188  		if err != nil && !f.tearingDown {
   189  			fmt.Printf("port forward failed: %v\n", err)
   190  		}
   191  	}()
   192  }
   193  
   194  func (f *k8sFixture) ClearResource(name string) {
   195  	outWriter := bytes.NewBuffer(nil)
   196  	cmd := exec.CommandContext(f.ctx, "kubectl", "delete", name, namespaceFlag, "--all")
   197  	cmd.Stdout = outWriter
   198  	cmd.Stderr = outWriter
   199  	err := cmd.Run()
   200  	if err != nil {
   201  		f.t.Fatalf("Error deleting deployments: %v. Logs:\n%s", err, outWriter.String())
   202  	}
   203  }
   204  
   205  func (f *k8sFixture) ClearNamespace() {
   206  	f.ClearResource("jobs")
   207  	f.ClearResource("deployments")
   208  	f.ClearResource("services")
   209  }
   210  
   211  func (f *k8sFixture) setupNewKubeConfig() {
   212  	cmd := exec.CommandContext(f.ctx, "kubectl", "config", "view", "--minify")
   213  	current, err := cmd.Output()
   214  	if err != nil {
   215  		f.t.Fatalf("Error reading KUBECONFIG: %v", err)
   216  	}
   217  
   218  	// Create a file with the same basename as the current kubeconfig,
   219  	// because we sometimes use that for env detection.
   220  	kubeconfigBaseName := filepath.Base(os.Getenv("KUBECONFIG"))
   221  	if kubeconfigBaseName == "" || kubeconfigBaseName == "." {
   222  		kubeconfigBaseName = "config"
   223  	}
   224  	f.kubeconfigPath = f.tempDir.JoinPath(kubeconfigBaseName)
   225  	f.tempDir.WriteFile(f.kubeconfigPath, string(current))
   226  	f.fixture.tilt.Environ["KUBECONFIG"] = f.kubeconfigPath
   227  	log.Printf("New kubeconfig: %s", f.kubeconfigPath)
   228  }
   229  
   230  func (f *k8sFixture) runCommand(name string, arg ...string) (*bytes.Buffer, error) {
   231  	outWriter := bytes.NewBuffer(nil)
   232  	cmd := exec.CommandContext(f.ctx, name, arg...)
   233  	cmd.Stdout = outWriter
   234  	cmd.Stderr = outWriter
   235  	cmd.Dir = packageDir
   236  	if f.kubeconfigPath != "" {
   237  		cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", f.kubeconfigPath))
   238  	}
   239  	err := cmd.Run()
   240  	return outWriter, err
   241  }
   242  
   243  func (f *k8sFixture) runCommandSilently(name string, arg ...string) {
   244  	_, err := f.runCommand(name, arg...)
   245  	if err != nil {
   246  		f.t.Fatalf("Error running command silently %s %v: %v", name, arg, err)
   247  	}
   248  }
   249  
   250  func (f *k8sFixture) runCommandGetOutput(cmdStr string) string {
   251  	outWriter := bytes.NewBuffer(nil)
   252  	cmd := exec.CommandContext(f.ctx, "bash", "-c", cmdStr)
   253  	cmd.Stderr = outWriter
   254  	cmd.Dir = packageDir
   255  	if f.kubeconfigPath != "" {
   256  		cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", f.kubeconfigPath))
   257  	}
   258  	output, err := cmd.Output()
   259  	if err != nil {
   260  		f.t.Fatalf("Error running command with output %s: %v", cmdStr, err)
   261  	}
   262  
   263  	return strings.TrimSpace(string(output))
   264  }
   265  
   266  func (f *k8sFixture) getSecrets() {
   267  	cmdStr := `kubectl get secrets -n tilt-integration -o json | jq -r '.items[] | select(.metadata.name | startswith("tilt-integration-user-token-")) | .data.token'`
   268  	tokenBase64 := f.runCommandGetOutput(cmdStr)
   269  	tokenBytes, err := base64.StdEncoding.DecodeString(tokenBase64)
   270  	if err != nil {
   271  		f.t.Fatalf("Unable to decode token: %v", err)
   272  	}
   273  
   274  	cmdStr = `kubectl get secrets -n tilt-integration -o json | jq -r '.items[] | select(.metadata.name | startswith("tilt-integration-user-token-")) | .data["ca.crt"]'`
   275  	cert := f.runCommandGetOutput(cmdStr)
   276  
   277  	f.token = string(tokenBytes)
   278  	f.cert = cert
   279  }
   280  
   281  func (f *k8sFixture) SetRestrictedCredentials() {
   282  	// docker-for-desktop has a default binding that gives service accounts access to everything.
   283  	// See: https://github.com/docker/for-mac/issues/3694
   284  	f.runCommandSilently("kubectl", "delete", "clusterrolebinding", "docker-for-desktop-binding", "--ignore-not-found")
   285  
   286  	// The service account needs the namespace to exist.
   287  	f.runCommandSilently("kubectl", "apply", "-f", "namespace.yaml")
   288  	f.runCommandSilently("kubectl", "apply", "-f", "service-account.yaml")
   289  	f.runCommandSilently("kubectl", "apply", "-f", "access.yaml")
   290  	f.getSecrets()
   291  
   292  	f.setupNewKubeConfig()
   293  
   294  	f.runCommandSilently("kubectl", "config", "set-credentials", "tilt-integration-user", fmt.Sprintf("--token=%s", f.token))
   295  	f.runCommandSilently("kubectl", "config", "set", "users.tilt-integration-user.client-key-data", f.cert)
   296  
   297  	currentContext := f.runCommandGetOutput("kubectl config current-context")
   298  
   299  	f.runCommandSilently("kubectl", "config", "set-context", currentContext, "--user=tilt-integration-user", "--namespace=tilt-integration")
   300  
   301  	cmdStr := fmt.Sprintf(`kubectl config view -o json | jq -r '.contexts[] | select(.name == "%s") | .context.cluster'`, currentContext)
   302  	currentCluster := f.runCommandGetOutput(cmdStr)
   303  
   304  	f.runCommandSilently("kubectl", "config", "set", fmt.Sprintf("clusters.%s.certificate-authority-data", currentCluster), f.cert)
   305  	f.runCommandSilently("kubectl", "config", "unset", fmt.Sprintf("clusters.%s.certificate-authority", currentCluster))
   306  }
   307  
   308  func (f *k8sFixture) TearDown() {
   309  	f.StartTearDown()
   310  	f.ClearNamespace()
   311  	f.fixture.TearDown()
   312  	f.tempDir.TearDown()
   313  }