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  }