github.com/tilt-dev/tilt@v0.36.0/integration/k8s_fixture_test.go (about)

     1  //go:build integration
     2  // +build integration
     3  
     4  package integration
     5  
     6  import (
     7  	"bytes"
     8  	"context"
     9  	"encoding/base64"
    10  	"fmt"
    11  	"log"
    12  	"os"
    13  	"os/exec"
    14  	"path/filepath"
    15  	"strings"
    16  	"testing"
    17  	"time"
    18  
    19  	"github.com/pkg/errors"
    20  	v1 "k8s.io/api/core/v1"
    21  
    22  	"github.com/tilt-dev/tilt/internal/testutils/tempdir"
    23  )
    24  
    25  var k8sInstalled bool
    26  
    27  type k8sFixture struct {
    28  	*fixture
    29  	tempDir *tempdir.TempDirFixture
    30  
    31  	token          string
    32  	cert           string
    33  	kubeconfigPath string
    34  }
    35  
    36  func newK8sFixture(t *testing.T, dir string) *k8sFixture {
    37  	td := tempdir.NewTempDirFixture(t)
    38  	f := newFixture(t, dir)
    39  
    40  	kf := &k8sFixture{fixture: f, tempDir: td}
    41  
    42  	if !k8sInstalled {
    43  		kf.checkKubectlConnection()
    44  
    45  		// Delete the namespace when the test starts,
    46  		// to make sure nothing is left over from previous tests.
    47  		kf.deleteNamespace()
    48  
    49  		k8sInstalled = true
    50  	} else {
    51  		kf.ClearNamespace()
    52  	}
    53  
    54  	t.Cleanup(kf.TearDown)
    55  
    56  	return kf
    57  }
    58  
    59  func (f *k8sFixture) checkKubectlConnection() {
    60  	cmd := exec.CommandContext(f.ctx, "kubectl", "version")
    61  	f.runOrFail(cmd, "Checking kubectl connection")
    62  }
    63  
    64  func (f *k8sFixture) deleteNamespace() {
    65  	cmd := exec.CommandContext(f.ctx, "kubectl", "delete", "namespace", "tilt-integration", "--ignore-not-found")
    66  	f.runOrFail(cmd, "Deleting namespace tilt-integration")
    67  
    68  	// block until the namespace doesn't exist, since kubectl often returns and the namespace is still "terminating"
    69  	// which causes the creation of objects in that namespace to fail
    70  	var b []byte
    71  	args := []string{"kubectl", "get", "namespace", "tilt-integration", "--ignore-not-found"}
    72  	timeout := time.Now().Add(10 * time.Second)
    73  	for time.Now().Before(timeout) {
    74  		cmd := exec.CommandContext(f.ctx, args[0], args[1:]...)
    75  		b, err := cmd.Output()
    76  		if err != nil {
    77  			f.t.Fatalf("Error: checking that deletion of the tilt-integration namespace has completed: %v", err)
    78  		}
    79  		if len(b) == 0 {
    80  			return
    81  		}
    82  	}
    83  	f.t.Fatalf("timed out waiting for tilt-integration deletion to complete. last output of %q: %q", args, string(b))
    84  }
    85  
    86  // Waits until all pods matching the selector are ready (i.e. phase = "Running")
    87  // At least one pod must match.
    88  // Returns the names of the ready pods.
    89  func (f *k8sFixture) WaitForAllPodsReady(ctx context.Context, selector string) []string {
    90  	return f.WaitForAllPodsInPhase(ctx, selector, v1.PodRunning)
    91  }
    92  
    93  func (f *k8sFixture) WaitForAllPodsInPhase(ctx context.Context, selector string, phase v1.PodPhase) []string {
    94  	f.t.Helper()
    95  	for {
    96  		allPodsReady, output, podNames := f.AllPodsInPhase(ctx, selector, phase)
    97  		if allPodsReady {
    98  			return podNames
    99  		}
   100  
   101  		select {
   102  		case <-f.activeTiltDone():
   103  			f.t.Fatalf("Tilt died while waiting for pods to be ready: %v", f.activeTiltErr())
   104  		case <-ctx.Done():
   105  			f.t.Fatalf("Timed out waiting for pods to be ready. Selector: %s. Output:\n:%s\n", selector, output)
   106  		case <-time.After(200 * time.Millisecond):
   107  		}
   108  	}
   109  }
   110  
   111  // Checks that all pods are in the given phase
   112  // Returns the output (for diagnostics) and the name of the pods in the given phase.
   113  func (f *k8sFixture) AllPodsInPhase(ctx context.Context, selector string, phase v1.PodPhase) (bool, string, []string) {
   114  	cmd := exec.Command("kubectl", "get", "pods",
   115  		namespaceFlag, "--selector="+selector, "-o=template",
   116  		"--template", "{{range .items}}{{.metadata.name}} {{.status.phase}}{{println}}{{end}}")
   117  	out, err := cmd.CombinedOutput()
   118  	if err != nil {
   119  		f.t.Fatal(errors.Wrap(err, "get pods"))
   120  	}
   121  
   122  	outStr := string(out)
   123  	lines := strings.Split(outStr, "\n")
   124  	podNames := []string{}
   125  	hasOneMatchingPod := false
   126  	for _, line := range lines {
   127  		line = strings.TrimSpace(line)
   128  		if line == "" {
   129  			continue
   130  		}
   131  
   132  		elements := strings.Split(line, " ")
   133  		if len(elements) < 2 {
   134  			f.t.Fatalf("Unexpected output of kubect get pods: %s", outStr)
   135  		}
   136  
   137  		name, actualPhase := elements[0], elements[1]
   138  		var matchedPhase bool
   139  		if actualPhase == string(phase) {
   140  			matchedPhase = true
   141  			hasOneMatchingPod = true
   142  
   143  		}
   144  
   145  		if !matchedPhase {
   146  			return false, outStr, nil
   147  		}
   148  
   149  		podNames = append(podNames, name)
   150  	}
   151  	return hasOneMatchingPod, outStr, podNames
   152  }
   153  
   154  func (f *k8sFixture) ForwardPort(name string, portMap string) {
   155  	cmd := exec.CommandContext(f.ctx, "kubectl", "port-forward", namespaceFlag, name, portMap)
   156  	cmd.Stdout = os.Stdout
   157  	cmd.Stderr = os.Stdout
   158  	err := cmd.Start()
   159  	if err != nil {
   160  		f.t.Fatal(err)
   161  	}
   162  
   163  	go func() {
   164  		err := cmd.Wait()
   165  		if err != nil && !f.tearingDown {
   166  			fmt.Printf("port forward failed: %v\n", err)
   167  		}
   168  	}()
   169  }
   170  
   171  func (f *k8sFixture) ClearResource(name string) {
   172  	outWriter := bytes.NewBuffer(nil)
   173  	cmd := exec.CommandContext(f.ctx, "kubectl", "delete", name, namespaceFlag, "--all")
   174  	cmd.Stdout = outWriter
   175  	cmd.Stderr = outWriter
   176  	err := cmd.Run()
   177  	if err != nil {
   178  		f.t.Fatalf("Error deleting deployments: %v. Logs:\n%s", err, outWriter.String())
   179  	}
   180  }
   181  
   182  func (f *k8sFixture) ClearNamespace() {
   183  	f.ClearResource("jobs")
   184  	f.ClearResource("deployments")
   185  	f.ClearResource("services")
   186  }
   187  
   188  func (f *k8sFixture) setupNewKubeConfig() {
   189  	cmd := exec.CommandContext(f.ctx, "kubectl", "config", "view", "--minify", "--raw")
   190  	current, err := cmd.Output()
   191  	if err != nil {
   192  		f.t.Fatalf("Error reading KUBECONFIG: %v", err)
   193  	}
   194  
   195  	// Create a file with the same basename as the current kubeconfig,
   196  	// because we sometimes use that for env detection.
   197  	kubeconfigBaseName := filepath.Base(os.Getenv("KUBECONFIG"))
   198  	if kubeconfigBaseName == "" || kubeconfigBaseName == "." {
   199  		kubeconfigBaseName = "config"
   200  	}
   201  	f.kubeconfigPath = f.tempDir.JoinPath(kubeconfigBaseName)
   202  	f.tempDir.WriteFile(f.kubeconfigPath, string(current))
   203  	f.fixture.tilt.Environ["KUBECONFIG"] = f.kubeconfigPath
   204  	log.Printf("New kubeconfig: %s", f.kubeconfigPath)
   205  }
   206  
   207  func (f *k8sFixture) runCommand(name string, arg ...string) (*bytes.Buffer, error) {
   208  	outWriter := bytes.NewBuffer(nil)
   209  	cmd := exec.CommandContext(f.ctx, name, arg...)
   210  	cmd.Stdout = outWriter
   211  	cmd.Stderr = outWriter
   212  	cmd.Dir = packageDir
   213  	if f.kubeconfigPath != "" {
   214  		cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", f.kubeconfigPath))
   215  	}
   216  	err := cmd.Run()
   217  	return outWriter, err
   218  }
   219  
   220  func (f *k8sFixture) runCommandSilently(name string, arg ...string) {
   221  	_, err := f.runCommand(name, arg...)
   222  	if err != nil {
   223  		f.t.Fatalf("Error running command silently %s %v: %v", name, arg, err)
   224  	}
   225  }
   226  
   227  func (f *k8sFixture) runCommandGetOutput(cmdStr string) string {
   228  	outWriter := bytes.NewBuffer(nil)
   229  	cmd := exec.CommandContext(f.ctx, "bash", "-c", cmdStr)
   230  	cmd.Stderr = outWriter
   231  	cmd.Dir = packageDir
   232  	if f.kubeconfigPath != "" {
   233  		cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", f.kubeconfigPath))
   234  	}
   235  	output, err := cmd.Output()
   236  	if err != nil {
   237  		f.t.Fatalf("Error running command with output %s: %v", cmdStr, err)
   238  	}
   239  
   240  	return strings.TrimSpace(string(output))
   241  }
   242  
   243  func (f *k8sFixture) getSecrets() {
   244  	cmdStr := `kubectl get secrets tilt-integration-user -n tilt-integration -o json | jq -r '.data.token'`
   245  	tokenBase64 := f.runCommandGetOutput(cmdStr)
   246  	tokenBytes, err := base64.StdEncoding.DecodeString(tokenBase64)
   247  	if err != nil {
   248  		f.t.Fatalf("Unable to decode token: %v", err)
   249  	}
   250  
   251  	cmdStr = `kubectl get secrets tilt-integration-user -n tilt-integration -o json | jq -r '.data["ca.crt"]'`
   252  	cert := f.runCommandGetOutput(cmdStr)
   253  
   254  	f.token = string(tokenBytes)
   255  	f.cert = cert
   256  }
   257  
   258  func (f *k8sFixture) SetRestrictedCredentials() {
   259  	// docker-for-desktop has a default binding that gives service accounts access to everything.
   260  	// See: https://github.com/docker/for-mac/issues/3694
   261  	f.runCommandSilently("kubectl", "delete", "clusterrolebinding", "docker-for-desktop-binding", "--ignore-not-found")
   262  
   263  	// The service account needs the namespace to exist.
   264  	f.runCommandSilently("kubectl", "apply", "-f", "namespace.yaml")
   265  	f.runCommandSilently("kubectl", "apply", "-f", "service-account.yaml")
   266  	f.runCommandSilently("kubectl", "apply", "-f", "access.yaml")
   267  	f.getSecrets()
   268  
   269  	// The new kubeconfig will have the same context and cluster name, so
   270  	// grab those first before we create it.
   271  	currentContext := f.runCommandGetOutput("kubectl config current-context")
   272  
   273  	f.setupNewKubeConfig()
   274  
   275  	// Fill in all the auth info so we can connect to the same context/cluster
   276  	// with restricted credentials.
   277  	f.runCommandSilently("kubectl", "config", "set-credentials", "tilt-integration-user", fmt.Sprintf("--token=%s", f.token))
   278  	f.runCommandSilently("kubectl", "config", "set", "users.tilt-integration-user.client-key-data", f.cert)
   279  	f.runCommandSilently("kubectl", "config", "set-context", currentContext, "--user=tilt-integration-user", "--namespace=tilt-integration")
   280  }
   281  
   282  func (f *k8sFixture) TearDown() {
   283  	f.StartTearDown()
   284  	f.ClearNamespace()
   285  }
   286  
   287  // waits for pods to be in a specified state, or times out and fails
   288  type podWaiter struct {
   289  	disallowedPodIDs map[string]bool
   290  	f                *k8sFixture
   291  	selector         string
   292  	expectedPhase    v1.PodPhase
   293  	expectedPodCount int // or -1 for no expectation
   294  	timeout          time.Duration
   295  }
   296  
   297  func (f *k8sFixture) newPodWaiter(selector string) podWaiter {
   298  	return podWaiter{
   299  		f:                f,
   300  		selector:         selector,
   301  		expectedPodCount: -1,
   302  		disallowedPodIDs: make(map[string]bool),
   303  		timeout:          time.Minute,
   304  	}
   305  }
   306  
   307  func (pw podWaiter) podPhases() (map[string]string, string) {
   308  	cmd := exec.Command("kubectl", "get", "pods",
   309  		namespaceFlag, "--selector="+pw.selector, "-o=template",
   310  		"--template", "{{range .items}}{{.metadata.name}} {{.status.phase}}{{println}}{{end}}")
   311  	out, err := cmd.CombinedOutput()
   312  	if err != nil {
   313  		pw.f.t.Fatal(errors.Wrap(err, "get pods"))
   314  	}
   315  
   316  	ret := make(map[string]string)
   317  
   318  	outStr := string(out)
   319  	lines := strings.Split(outStr, "\n")
   320  	for _, line := range lines {
   321  		line = strings.TrimSpace(line)
   322  		if line == "" {
   323  			continue
   324  		}
   325  
   326  		elements := strings.Split(line, " ")
   327  		if len(elements) < 2 {
   328  			pw.f.t.Fatalf("Unexpected output of kubect get pods: %s", outStr)
   329  		}
   330  
   331  		name, phase := elements[0], elements[1]
   332  
   333  		ret[name] = phase
   334  	}
   335  
   336  	return ret, outStr
   337  }
   338  
   339  // if a test is transitioning from one pod id to another, it should disallow the old pod id here
   340  // simply waiting for pod state does not suffice - it leaves us with a race condition:
   341  // an old pod matching the label is up
   342  // we run some command causing a new pod matching the label to come up
   343  // while the deployment controller is swapping from the old pod to the new pod, there will be at least a moment
   344  // in which both pods exist. we want to ensure we've made it past the point where the old pod is gone
   345  func (pw podWaiter) withDisallowedPodIDs(podIDs []string) podWaiter {
   346  	pw.disallowedPodIDs = make(map[string]bool)
   347  	for _, podID := range podIDs {
   348  		pw.disallowedPodIDs[podID] = true
   349  	}
   350  	return pw
   351  }
   352  
   353  func (pw podWaiter) withExpectedPodCount(count int) podWaiter {
   354  	pw.expectedPodCount = count
   355  	return pw
   356  }
   357  
   358  func (pw podWaiter) withExpectedPhase(phase v1.PodPhase) podWaiter {
   359  	pw.expectedPhase = phase
   360  	return pw
   361  }
   362  
   363  func (pw podWaiter) wait() []string {
   364  	ctx, cancel := context.WithTimeout(pw.f.ctx, pw.timeout)
   365  	defer cancel()
   366  	for {
   367  		okToReturn := true
   368  		podPhases, output := pw.podPhases()
   369  		var podIDs []string
   370  		for podID, phase := range podPhases {
   371  			if (pw.expectedPhase != "" && string(pw.expectedPhase) != phase) || pw.disallowedPodIDs[podID] {
   372  				okToReturn = false
   373  				break
   374  			}
   375  			podIDs = append(podIDs, podID)
   376  		}
   377  		if okToReturn &&
   378  			(pw.expectedPodCount == -1 || pw.expectedPodCount == len(podPhases)) {
   379  			return podIDs
   380  		}
   381  
   382  		select {
   383  		case <-pw.f.activeTiltDone():
   384  			pw.f.t.Fatalf("Tilt died while waiting for pods to be ready: %v", pw.f.activeTiltErr())
   385  		case <-ctx.Done():
   386  			pw.f.t.Fatalf("Timed out waiting for pods to be ready. Selector: %s. Output:\n:%s\n", pw.selector, output)
   387  		case <-time.After(200 * time.Millisecond):
   388  		}
   389  	}
   390  }