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 }