github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/testutils/podbuilder/podbuilder.go (about) 1 package podbuilder 2 3 import ( 4 "fmt" 5 "testing" 6 "time" 7 8 "github.com/tilt-dev/tilt/pkg/apis" 9 10 appsv1 "k8s.io/api/apps/v1" 11 v1 "k8s.io/api/core/v1" 12 "k8s.io/apimachinery/pkg/api/validation" 13 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 "k8s.io/apimachinery/pkg/types" 15 16 "github.com/tilt-dev/tilt/internal/container" 17 "github.com/tilt-dev/tilt/internal/k8s" 18 "github.com/tilt-dev/tilt/pkg/model" 19 ) 20 21 const fakeContainerID = container.ID("myTestContainer") 22 23 func FakeContainerID() container.ID { 24 return FakeContainerIDAtIndex(0) 25 } 26 27 func FakeContainerIDAtIndex(index int) container.ID { 28 indexSuffix := "" 29 if index != 0 { 30 indexSuffix = fmt.Sprintf("-%d", index) 31 } 32 return container.ID(fmt.Sprintf("%s%s", fakeContainerID, indexSuffix)) 33 } 34 35 // Builds Pod objects for testing 36 // 37 // The pod model should be internally well-formed (e.g., the containers 38 // in the PodSpec object should match the containers in the PodStatus object). 39 // 40 // The pod model should also be consistent with the Manifest (e.g., if the Manifest 41 // specifies a Deployment with labels in a PodTemplateSpec, then any Pods should also 42 // have those labels). 43 // 44 // The PodBuilder is responsible for making sure we create well-formed Pods for 45 // testing. Tests should never modify the pod directly, but instead use the PodBuilder 46 // methods to ensure that the pod is consistent. 47 type PodBuilder struct { 48 t testing.TB 49 manifest model.Manifest 50 51 podUID types.UID 52 podName string 53 phase string 54 creationTime time.Time 55 deletionTime time.Time 56 restartCount int 57 extraPodLabels map[string]string 58 deploymentUID types.UID 59 resourceVersion string 60 unknownOwner bool 61 62 // contextNamespace allows setting a fallback default namespace instead of `default` to simulate 63 // a namespace override in the active config context. 64 contextNamespace k8s.Namespace 65 66 // keyed by container index -- i.e. the first container will have image: imageRefs[0] and ID: cIDs[0], etc. 67 // If there's no entry at index i, we'll use a dummy value. 68 imageRefs map[int]string 69 cIDs map[int]string 70 cReady map[int]bool 71 72 setPodTemplateSpecHash bool 73 podTemplateSpecHash k8s.PodTemplateSpecHash 74 } 75 76 func New(t testing.TB, manifest model.Manifest) PodBuilder { 77 return PodBuilder{ 78 t: t, 79 manifest: manifest, 80 creationTime: time.Now(), 81 imageRefs: make(map[int]string), 82 cIDs: make(map[int]string), 83 cReady: make(map[int]bool), 84 extraPodLabels: make(map[string]string), 85 setPodTemplateSpecHash: true, 86 } 87 } 88 89 // Remove the owner reference. Useful for testing pod watching when 90 // the owner chain is broken (as in some CRDs). 91 func (b PodBuilder) WithUnknownOwner() PodBuilder { 92 b.unknownOwner = true 93 return b 94 } 95 96 func (b PodBuilder) WithPodLabel(key, val string) PodBuilder { 97 b.extraPodLabels[key] = val 98 return b 99 } 100 101 func (b PodBuilder) ManifestName() model.ManifestName { 102 return b.manifest.Name 103 } 104 105 func (b PodBuilder) WithTemplateSpecHash(s k8s.PodTemplateSpecHash) PodBuilder { 106 b.podTemplateSpecHash = s 107 return b 108 } 109 110 func (b PodBuilder) WithNoTemplateSpecHash() PodBuilder { 111 b.setPodTemplateSpecHash = false 112 return b 113 } 114 115 func (b PodBuilder) RestartCount() int { 116 return b.restartCount 117 } 118 119 func (b PodBuilder) WithRestartCount(restartCount int) PodBuilder { 120 b.restartCount = restartCount 121 return b 122 } 123 124 func (b PodBuilder) WithResourceVersion(rv string) PodBuilder { 125 b.resourceVersion = rv 126 return b 127 } 128 129 func (b PodBuilder) WithPodUID(uid types.UID) PodBuilder { 130 b.podUID = uid 131 return b 132 } 133 134 func (b PodBuilder) WithPodName(name string) PodBuilder { 135 msgs := validation.NameIsDNSSubdomain(name, false) 136 if len(msgs) != 0 { 137 b.t.Fatalf("pod id %q is invalid: %s", name, msgs) 138 } 139 b.podName = name 140 return b 141 } 142 143 func (b PodBuilder) WithPhase(phase string) PodBuilder { 144 b.phase = phase 145 return b 146 } 147 148 func (b PodBuilder) WithImage(image string) PodBuilder { 149 return b.WithImageAtIndex(image, 0) 150 } 151 152 func (b PodBuilder) WithImageAtIndex(image string, index int) PodBuilder { 153 b.imageRefs[index] = image 154 return b 155 } 156 157 func (b PodBuilder) WithContainerID(cID container.ID) PodBuilder { 158 return b.WithContainerIDAtIndex(cID, 0) 159 } 160 161 func (b PodBuilder) WithContainerIDAtIndex(cID container.ID, index int) PodBuilder { 162 if cID == "" { 163 b.cIDs[index] = "" 164 } else { 165 b.cIDs[index] = fmt.Sprintf("%s%s", k8s.ContainerIDPrefix, cID) 166 } 167 return b 168 } 169 170 func (b PodBuilder) WithContainerReady(ready bool) PodBuilder { 171 return b.WithContainerReadyAtIndex(ready, 0) 172 } 173 174 func (b PodBuilder) WithContainerReadyAtIndex(ready bool, index int) PodBuilder { 175 b.cReady[index] = ready 176 return b 177 } 178 179 func (b PodBuilder) WithCreationTime(creationTime time.Time) PodBuilder { 180 b.creationTime = creationTime 181 return b 182 } 183 184 func (b PodBuilder) WithDeletionTime(deletionTime time.Time) PodBuilder { 185 b.deletionTime = deletionTime 186 return b 187 } 188 189 func (b PodBuilder) PodName() k8s.PodID { 190 if b.podName != "" { 191 return k8s.PodID(b.podName) 192 } 193 return k8s.PodID(fmt.Sprintf("%s-fakePodID", b.manifest.Name)) 194 } 195 196 func (b PodBuilder) PodUID() types.UID { 197 if b.podUID != "" { 198 return b.podUID 199 } 200 return types.UID(fmt.Sprintf("%s-fakeUID", b.PodName())) 201 } 202 203 func (b PodBuilder) WithDeploymentUID(deploymentUID types.UID) PodBuilder { 204 b.deploymentUID = deploymentUID 205 return b 206 } 207 208 // WithContextNamespace sets the fallback namespace used if the entities in the manifest YAML do 209 // not specify any namespace. 210 // 211 // This simulates having a namespace set on the active kubeconfig context, which Tilt also infers 212 // and uses, but is not explicitly accessible to PodBuilder. 213 // 214 // If this is not set AND the entities do not reference a namespace, they will be assigned a namespace 215 // value of k8s.DefaultNamespace (`default`). 216 func (b PodBuilder) WithContextNamespace(ns k8s.Namespace) PodBuilder { 217 b.contextNamespace = ns 218 return b 219 } 220 221 func (b PodBuilder) buildReplicaSetName() string { 222 return fmt.Sprintf("%s-replicaset", b.manifest.Name) 223 } 224 225 func (b PodBuilder) buildReplicaSetUID() types.UID { 226 if b.deploymentUID != "" { 227 // if there's a custom Deployment UID, use that as the base for the ReplicaSet since 228 // Deployments create ReplicaSets, and otherwise we can mix up this ReplicaSet with 229 // the "default" Deployment since they'd have the same UID 230 return types.UID(fmt.Sprintf("%s-rs-fakeUID", b.deploymentUID)) 231 } 232 return types.UID(fmt.Sprintf("%s-fakeUID", b.buildReplicaSetName())) 233 } 234 235 func (b PodBuilder) buildDeploymentName() string { 236 return fmt.Sprintf("%s-deployment", b.manifest.Name) 237 } 238 239 func (b PodBuilder) DeploymentUID() types.UID { 240 if b.deploymentUID != "" { 241 return b.deploymentUID 242 } 243 return types.UID(fmt.Sprintf("%s-fakeUID", b.buildDeploymentName())) 244 } 245 246 func (b PodBuilder) buildDeployment(ns k8s.Namespace, spec v1.PodSpec, labels map[string]string) *appsv1.Deployment { 247 return &appsv1.Deployment{ 248 TypeMeta: metav1.TypeMeta{ 249 APIVersion: "apps/v1", 250 Kind: "Deployment", 251 }, 252 ObjectMeta: metav1.ObjectMeta{ 253 Name: b.buildDeploymentName(), 254 Namespace: ns.String(), 255 Labels: k8s.NewTiltLabelMap(), 256 UID: b.DeploymentUID(), 257 }, 258 Spec: appsv1.DeploymentSpec{ 259 Template: v1.PodTemplateSpec{ 260 ObjectMeta: metav1.ObjectMeta{ 261 Labels: labels, 262 }, 263 Spec: spec, 264 }, 265 }, 266 } 267 } 268 269 func (b PodBuilder) buildReplicaSet(deployment *appsv1.Deployment) *appsv1.ReplicaSet { 270 return &appsv1.ReplicaSet{ 271 TypeMeta: metav1.TypeMeta{ 272 APIVersion: "apps/v1", 273 Kind: "ReplicaSet", 274 }, 275 ObjectMeta: metav1.ObjectMeta{ 276 Name: b.buildReplicaSetName(), 277 Namespace: deployment.Namespace, 278 UID: b.buildReplicaSetUID(), 279 Labels: k8s.NewTiltLabelMap(), 280 OwnerReferences: []metav1.OwnerReference{ 281 k8s.RuntimeObjToOwnerRef(deployment), 282 }, 283 }, 284 } 285 } 286 287 func (b PodBuilder) buildCreationTime() metav1.Time { 288 return apis.NewTime(b.creationTime) 289 } 290 291 func (b PodBuilder) buildDeletionTime() *metav1.Time { 292 if !b.deletionTime.IsZero() { 293 v := apis.NewTime(b.deletionTime) 294 return &v 295 } 296 return nil 297 } 298 299 func (b PodBuilder) buildLabels(tSpec *v1.PodTemplateSpec) map[string]string { 300 labels := k8s.NewTiltLabelMap() 301 for k, v := range tSpec.Labels { 302 labels[k] = v 303 } 304 for k, v := range b.extraPodLabels { 305 labels[k] = v 306 } 307 308 if b.setPodTemplateSpecHash { 309 podTemplateSpecHash := b.podTemplateSpecHash 310 if podTemplateSpecHash == "" { 311 var err error 312 podTemplateSpecHash, err = k8s.HashPodTemplateSpec(tSpec) 313 if err != nil { 314 panic(fmt.Sprintf("error computing pod template spec hash: %v", err)) 315 } 316 } 317 labels[k8s.TiltPodTemplateHashLabel] = string(podTemplateSpecHash) 318 } 319 320 return labels 321 } 322 323 func (b PodBuilder) buildImage(imageSpec string, index int) string { 324 image, ok := b.imageRefs[index] 325 if ok { 326 return image 327 } 328 329 imageSpecRef := container.MustParseNamed(imageSpec) 330 331 // Use the pod ID as the image tag. This is kind of weird, but gets at the semantics 332 // we want (e.g., a new pod ID indicates that this is a new build). 333 // Tests that don't want this behavior should replace the image with setImage(pod, imageName) 334 return fmt.Sprintf("%s:%s", imageSpecRef.Name(), b.PodName()) 335 } 336 337 func (b PodBuilder) buildContainerID(index int) string { 338 cID, ok := b.cIDs[index] 339 if ok { 340 return cID 341 } 342 343 return fmt.Sprintf("%s%s", k8s.ContainerIDPrefix, FakeContainerIDAtIndex(index)) 344 } 345 346 func (b PodBuilder) buildPhase() v1.PodPhase { 347 if b.phase == "" { 348 return v1.PodPhase("Running") 349 } 350 return v1.PodPhase(b.phase) 351 } 352 353 func (b PodBuilder) buildContainerStatuses(spec v1.PodSpec) []v1.ContainerStatus { 354 result := make([]v1.ContainerStatus, len(spec.Containers)) 355 for i, cSpec := range spec.Containers { 356 restartCount := 0 357 if i == 0 { 358 restartCount = b.restartCount 359 } 360 ready, ok := b.cReady[i] 361 // if not specified, default to true 362 ready = !ok || ready 363 364 state := v1.ContainerState{ 365 Running: &v1.ContainerStateRunning{ 366 StartedAt: b.buildCreationTime(), 367 }, 368 } 369 370 result[i] = v1.ContainerStatus{ 371 Name: cSpec.Name, 372 Image: b.buildImage(cSpec.Image, i), 373 Ready: ready, 374 State: state, 375 ContainerID: b.buildContainerID(i), 376 RestartCount: int32(restartCount), 377 } 378 } 379 return result 380 } 381 382 func (b PodBuilder) validateImageRefs(numContainers int) { 383 for index, img := range b.imageRefs { 384 if index >= numContainers { 385 b.t.Fatalf("Image %q specified at index %d. Pod only has %d containers", img, index, numContainers) 386 } 387 } 388 } 389 390 func (b PodBuilder) validateContainerIDs(numContainers int) { 391 for index, cID := range b.cIDs { 392 if index >= numContainers { 393 b.t.Fatalf("Container ID %q specified at index %d. Pod only has %d containers", cID, index, numContainers) 394 } 395 } 396 } 397 398 func (b PodBuilder) determineNamespace(entities []k8s.K8sEntity) k8s.Namespace { 399 // N.B. we want to be careful to not coerce values to `default`, which might not be the implicit default based 400 // on configured context, so we really want to leave this as an empty string in that case 401 nsVal := entities[0].NamespaceOrDefault("") 402 for i := 1; i < len(entities)-1; i++ { 403 if string(entities[i].Namespace()) != nsVal { 404 b.t.Fatalf("PodBuilder only works with Manifests that reference exactly 1 namespace (found %s and %s)", 405 nsVal, entities[i].Namespace()) 406 } 407 } 408 if nsVal != "" { 409 return k8s.Namespace(nsVal) 410 } 411 if b.contextNamespace != "" { 412 return b.contextNamespace 413 } 414 // this is actually redundant as long as k8s.Namespace::String() is used which 415 // coerces empty string to this, but that behavior is actually a bit sketchy, 416 // so this is done explicitly 417 return k8s.DefaultNamespace 418 } 419 420 type PodObjectTree []k8s.K8sEntity 421 422 func (p PodObjectTree) Pod() k8s.K8sEntity { 423 return p[0] 424 } 425 426 func (p PodObjectTree) ReplicaSet() k8s.K8sEntity { 427 return p[1] 428 } 429 430 func (p PodObjectTree) Deployment() k8s.K8sEntity { 431 return p[2] 432 } 433 434 // Simulates a Pod -> ReplicaSet -> Deployment ref tree 435 func (b PodBuilder) ObjectTreeEntities() PodObjectTree { 436 pod := b.Build() 437 dep := b.buildDeployment(k8s.Namespace(pod.Namespace), pod.Spec, pod.Labels) 438 rs := b.buildReplicaSet(dep) 439 return PodObjectTree{ 440 k8s.NewK8sEntity(pod), 441 k8s.NewK8sEntity(rs), 442 k8s.NewK8sEntity(dep), 443 } 444 } 445 446 func (b PodBuilder) Build() *v1.Pod { 447 entities, err := parseYAMLFromManifest(b.manifest) 448 if err != nil { 449 b.t.Fatal(fmt.Errorf("PodBuilder YAML parser: %v", err)) 450 } 451 452 tSpecs, err := k8s.ExtractPodTemplateSpec(entities) 453 if err != nil { 454 b.t.Fatal(fmt.Errorf("PodBuilder extract pod templates: %v", err)) 455 } 456 457 if len(tSpecs) != 1 { 458 b.t.Fatalf("PodBuilder only works with Manifests with exactly 1 PodTemplateSpec: %v", tSpecs) 459 } 460 461 ns := b.determineNamespace(entities) 462 463 tSpec := tSpecs[0] 464 spec := tSpec.Spec 465 numContainers := len(spec.Containers) 466 b.validateImageRefs(numContainers) 467 b.validateContainerIDs(numContainers) 468 469 // Generate buildLabels from the incoming pod spec, before we've modified it, 470 // so that it matches the spec we generate from the manifest itself. 471 // Can override this behavior by setting b.PodTemplateSpecHash (or 472 // by setting b.setPodTemplateSpecHash = false ) 473 labels := b.buildLabels(tSpec) 474 475 for i, container := range spec.Containers { 476 container.Image = b.buildImage(container.Image, i) 477 spec.Containers[i] = container 478 } 479 480 deployment := b.buildDeployment(ns, spec, labels) 481 ownerRefs := []metav1.OwnerReference{ 482 k8s.RuntimeObjToOwnerRef(b.buildReplicaSet(deployment)), 483 } 484 if b.unknownOwner { 485 ownerRefs = nil 486 } 487 488 return &v1.Pod{ 489 TypeMeta: metav1.TypeMeta{ 490 APIVersion: "v1", 491 Kind: "Pod", 492 }, 493 ObjectMeta: metav1.ObjectMeta{ 494 Name: string(b.PodName()), 495 Namespace: ns.String(), 496 CreationTimestamp: b.buildCreationTime(), 497 DeletionTimestamp: b.buildDeletionTime(), 498 Labels: labels, 499 UID: b.PodUID(), 500 OwnerReferences: ownerRefs, 501 ResourceVersion: b.resourceVersion, 502 }, 503 Spec: spec, 504 Status: v1.PodStatus{ 505 Phase: b.buildPhase(), 506 ContainerStatuses: b.buildContainerStatuses(spec), 507 }, 508 } 509 } 510 511 func parseYAMLFromManifest(m model.Manifest) ([]k8s.K8sEntity, error) { 512 return k8s.ParseYAMLFromString(m.K8sTarget().YAML) 513 }