Ctrl K
ComunidadGuías…

pkg/k8score/workload_images_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"testing"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/validation/field"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	clienttesting "k8s.io/client-go/testing"
)

func imageWorkload(apiVersion, kind, namespace, name string, containers, initContainers []any) *unstructured.Unstructured {
	spec := map[string]any{"containers": containers}
	if initContainers != nil {
		spec["initContainers"] = initContainers
	}
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": apiVersion,
		"kind":       kind,
		"metadata": map[string]any{
			"namespace": namespace,
			"name":      name,
			"labels":    map[string]any{"preserve": "yes"},
		},
		"spec": map[string]any{
			"replicas": int64(3),
			"template": map[string]any{"spec": spec},
		},
	}}
}

func imageContainer(name, image string) map[string]any {
	return map[string]any{"name": name, "image": image}
}

func fakeImageClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient {
	return dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), objects...)
}

func TestGetWorkloadImagesInventoriesRegularAndInitContainers(t *testing.T) {
	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1"), imageContainer("sidecar", "repo/sidecar:v1")},
		[]any{imageContainer("migrate", "repo/migrate:v1")},
	)
	client := fakeImageClient(deployment)

	inventory, err := NewWorkloadManager(client, nil).GetWorkloadImages(context.Background(), "deployments", "prod", "web")
	if err != nil {
		t.Fatalf("GetWorkloadImages: %v", err)
	}
	if len(inventory.Containers) != 3 {
		t.Fatalf("containers = %v, want 3 entries", inventory.Containers)
	}
	if inventory.Containers[0] != (WorkloadContainerImage{Type: containerTypeRegular, Name: "app", Image: "repo/app:v1"}) {
		t.Errorf("first container = %+v", inventory.Containers[0])
	}
	if inventory.Containers[2].Type != containerTypeInit || inventory.Containers[2].Name != "migrate" {
		t.Errorf("init container = %+v", inventory.Containers[2])
	}
	if inventory.Target.Group != "apps" || inventory.Target.Resource != "deployments" || inventory.Target.Name != "web" {
		t.Errorf("target = %+v", inventory.Target)
	}
	if inventory.Behavior.Type != "rolling" {
		t.Errorf("behavior = %+v, want rolling", inventory.Behavior)
	}
}

func TestSetWorkloadImagesBuildsOneAtomicNarrowPatch(t *testing.T) {
	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1"), imageContainer("sidecar", "repo/sidecar:v1")},
		[]any{imageContainer("migrate", "repo/migrate:v1")},
	)
	client := fakeImageClient(deployment)
	updated := deployment.DeepCopy()
	updated.SetManagedFields([]metav1.ManagedFieldsEntry{{Manager: "test"}})
	updated.SetAnnotations(map[string]string{lastAppliedAnnotation: `{"kind":"Deployment"}`, "preserve": "yes"})
	_ = unstructured.SetNestedSlice(updated.Object,
		[]any{imageContainer("app", "repo/app:v2"), imageContainer("sidecar", "repo/sidecar:v1")},
		"spec", "template", "spec", "containers")
	_ = unstructured.SetNestedSlice(updated.Object,
		[]any{imageContainer("migrate", "repo/migrate:v2")},
		"spec", "template", "spec", "initContainers")
	var captured clienttesting.PatchAction
	client.PrependReactor("patch", "deployments", func(action clienttesting.Action) (bool, runtime.Object, error) {
		captured = action.(clienttesting.PatchAction)
		return true, updated, nil
	})

	result, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "Deployment", "prod", "web", []WorkloadImageUpdate{
		{Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2"},
		{Type: containerTypeInit, Name: "migrate", PreviousImage: "repo/migrate:v1", Image: "repo/migrate:v2"},
	})
	if err != nil {
		t.Fatalf("SetWorkloadImages: %v", err)
	}
	if captured == nil {
		t.Fatal("no patch was issued")
	}
	if captured.GetPatchType() != "application/json-patch+json" {
		t.Errorf("patch type = %s", captured.GetPatchType())
	}
	var operations []map[string]any
	if err := json.Unmarshal(captured.GetPatch(), &operations); err != nil {
		t.Fatalf("patch JSON: %v", err)
	}
	if len(operations) != 6 {
		t.Fatalf("operations = %v, want 6", operations)
	}
	if operations[0]["op"] != "test" || operations[0]["path"] != "/spec/template/spec/containers/0/name" {
		t.Errorf("first operation = %v", operations[0])
	}
	if operations[2]["op"] != "replace" || operations[2]["path"] != "/spec/template/spec/containers/0/image" {
		t.Errorf("regular replacement = %v", operations[2])
	}
	if operations[5]["path"] != "/spec/template/spec/initContainers/0/image" {
		t.Errorf("init replacement = %v", operations[5])
	}
	if result.Containers[0].Image != "repo/app:v2" || result.Containers[2].Image != "repo/migrate:v2" {
		t.Errorf("result inventory = %+v", result.Containers)
	}
	if result.Object.GetLabels()["preserve"] != "yes" {
		t.Errorf("unrelated label was lost: %v", result.Object.GetLabels())
	}
	if len(result.Object.GetManagedFields()) != 0 {
		t.Errorf("managed fields leaked into result: %v", result.Object.GetManagedFields())
	}
	if _, found := result.Object.GetAnnotations()[lastAppliedAnnotation]; found {
		t.Errorf("last-applied annotation leaked into result: %v", result.Object.GetAnnotations())
	}
	if result.Object.GetAnnotations()["preserve"] != "yes" {
		t.Errorf("unrelated annotation was lost: %v", result.Object.GetAnnotations())
	}
}

func TestSetWorkloadImagesRejectsStalePreviousImageBeforePatching(t *testing.T) {
	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v2")}, nil)
	client := fakeImageClient(deployment)

	_, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v3",
	}})
	if !apierrors.IsConflict(err) {
		t.Fatalf("error = %v, want Conflict", err)
	}
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			t.Fatalf("stale request issued a patch: %v", client.Actions())
		}
	}
}

func TestSetWorkloadImagesRetriesWhenContainerOrderChanges(t *testing.T) {
	initial := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1"), imageContainer("sidecar", "repo/sidecar:v1")}, nil)
	reordered := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("sidecar", "repo/sidecar:v1"), imageContainer("app", "repo/app:v1")}, nil)
	updated := reordered.DeepCopy()
	_ = unstructured.SetNestedSlice(updated.Object,
		[]any{imageContainer("sidecar", "repo/sidecar:v1"), imageContainer("app", "repo/app:v2")},
		"spec", "template", "spec", "containers")
	client := fakeImageClient(initial)
	getCount := 0
	client.PrependReactor("get", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		getCount++
		if getCount == 1 {
			return true, initial.DeepCopy(), nil
		}
		return true, reordered.DeepCopy(), nil
	})
	patchCount := 0
	client.PrependReactor("patch", "deployments", func(action clienttesting.Action) (bool, runtime.Object, error) {
		patchCount++
		if patchCount == 1 {
			return true, nil, apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
				field.Invalid(field.NewPath("spec", "template", "spec", "containers"), nil, "JSON Patch test failed"),
			})
		}
		var operations []map[string]any
		_ = json.Unmarshal(action.(clienttesting.PatchAction).GetPatch(), &operations)
		if operations[0]["path"] != "/spec/template/spec/containers/1/name" {
			t.Errorf("retry did not rebuild index: %v", operations)
		}
		return true, updated, nil
	})

	result, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2",
	}})
	if err != nil {
		t.Fatalf("SetWorkloadImages: %v", err)
	}
	if patchCount != 2 {
		t.Fatalf("patch count = %d, want 2", patchCount)
	}
	if result.Containers[1].Image != "repo/app:v2" {
		t.Errorf("result = %+v", result.Containers)
	}
}

func TestSetWorkloadImagesReturnsConflictWhenImageChangesDuringPatch(t *testing.T) {
	initial := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1")}, nil)
	changed := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v2")}, nil)
	client := fakeImageClient(initial)
	getCount := 0
	client.PrependReactor("get", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		getCount++
		if getCount == 1 {
			return true, initial.DeepCopy(), nil
		}
		return true, changed.DeepCopy(), nil
	})
	client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		return true, nil, apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
			field.Invalid(field.NewPath("spec", "template", "spec", "containers", "0", "image"), nil, "JSON Patch test failed"),
		})
	})

	_, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v3",
	}})
	if !apierrors.IsConflict(err) {
		t.Fatalf("error = %v, want Conflict", err)
	}
	patches := 0
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			patches++
		}
	}
	if patches != 1 {
		t.Fatalf("patch count = %d, want no retry after the image changed", patches)
	}
}

func TestSetWorkloadImagesDetectsTerminationAfterRejectedPatch(t *testing.T) {
	tests := []struct {
		name           string
		getResponses   []*unstructured.Unstructured
		wantPatchCount int
	}{
		{
			name: "before retry",
			getResponses: []*unstructured.Unstructured{
				imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("app", "repo/app:v1")}, nil),
				imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("app", "repo/app:v1")}, nil),
			},
			wantPatchCount: 1,
		},
		{
			name: "after retry",
			getResponses: []*unstructured.Unstructured{
				imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("app", "repo/app:v1"), imageContainer("sidecar", "repo/sidecar:v1")}, nil),
				imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("sidecar", "repo/sidecar:v1"), imageContainer("app", "repo/app:v1")}, nil),
				imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("sidecar", "repo/sidecar:v1"), imageContainer("app", "repo/app:v1")}, nil),
			},
			wantPatchCount: 2,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			now := metav1.Now()
			tt.getResponses[len(tt.getResponses)-1].SetDeletionTimestamp(&now)
			client := fakeImageClient(tt.getResponses[0])
			getCount := 0
			client.PrependReactor("get", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
				response := tt.getResponses[getCount]
				getCount++
				return true, response.DeepCopy(), nil
			})
			patchCount := 0
			client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
				patchCount++
				return true, nil, apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
					field.Invalid(field.NewPath("spec", "template", "spec", "containers"), nil, "JSON Patch test failed"),
				})
			})

			_, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
				Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2",
			}})
			if !errors.Is(err, ErrImageWorkloadTerminating) {
				t.Fatalf("error = %v, want ErrImageWorkloadTerminating", err)
			}
			if patchCount != tt.wantPatchCount {
				t.Fatalf("patch count = %d, want %d", patchCount, tt.wantPatchCount)
			}
		})
	}
}

func TestSetWorkloadImagesPreservesUnrelatedInvalidErrors(t *testing.T) {
	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1")}, nil)
	client := fakeImageClient(deployment)
	invalid := apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
		field.Forbidden(field.NewPath("spec", "template"), "admission rejected the image"),
	})
	client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		return true, nil, invalid
	})

	_, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2",
	}})
	if !apierrors.IsInvalid(err) {
		t.Fatalf("error = %v, want original Invalid", err)
	}
	patches := 0
	for _, action := range client.Actions() {
		if _, ok := action.(clienttesting.PatchAction); ok {
			patches++
		}
	}
	if patches != 1 {
		t.Fatalf("patch count = %d, want no retry", patches)
	}
}

func TestSetWorkloadImagesKeepsAdmissionErrorAccurateAfterReorderRetry(t *testing.T) {
	initial := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "repo/app:v1"), imageContainer("sidecar", "repo/sidecar:v1")}, nil)
	reordered := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("sidecar", "repo/sidecar:v1"), imageContainer("app", "repo/app:v1")}, nil)
	client := fakeImageClient(initial)
	getCount := 0
	client.PrependReactor("get", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		getCount++
		if getCount == 1 {
			return true, initial.DeepCopy(), nil
		}
		return true, reordered.DeepCopy(), nil
	})
	patchCount := 0
	client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		patchCount++
		if patchCount == 1 {
			return true, nil, apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
				field.Invalid(field.NewPath("spec", "template", "spec", "containers"), nil, "JSON Patch test failed"),
			})
		}
		return true, nil, apierrors.NewInvalid(schema.GroupKind{Group: "apps", Kind: "Deployment"}, "web", field.ErrorList{
			field.Forbidden(field.NewPath("spec", "template"), "admission rejected the image"),
		})
	})

	_, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "deployments", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2",
	}})
	if !apierrors.IsInvalid(err) || !strings.Contains(err.Error(), "admission rejected the image") {
		t.Fatalf("error = %v, want admission Invalid", err)
	}
	if strings.Contains(err.Error(), "container order") {
		t.Fatalf("error blamed the resolved reorder: %v", err)
	}
	if patchCount != 2 {
		t.Fatalf("patch count = %d, want one bounded retry", patchCount)
	}
}

func TestSetWorkloadImagesFollowsRolloutWorkloadRef(t *testing.T) {
	rollout := imageWorkload("argoproj.io/v1alpha1", "Rollout", "prod", "web", nil, nil)
	unstructured.RemoveNestedField(rollout.Object, "spec", "template")
	_ = unstructured.SetNestedMap(rollout.Object, map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "name": "web-target"}, "spec", "workloadRef")
	_ = unstructured.SetNestedMap(rollout.Object, map[string]any{"canary": map[string]any{}}, "spec", "strategy")
	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web-target",
		[]any{imageContainer("app", "repo/app:v1")}, nil)
	updated := deployment.DeepCopy()
	_ = unstructured.SetNestedSlice(updated.Object, []any{imageContainer("app", "repo/app:v2")}, "spec", "template", "spec", "containers")
	client := fakeImageClient(rollout, deployment)
	client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		return true, updated, nil
	})

	result, err := NewWorkloadManager(client, nil).SetWorkloadImages(context.Background(), "rollouts", "prod", "web", []WorkloadImageUpdate{{
		Type: containerTypeRegular, Name: "app", PreviousImage: "repo/app:v1", Image: "repo/app:v2",
	}})
	if err != nil {
		t.Fatalf("SetWorkloadImages: %v", err)
	}
	if result.Target.Resource != "deployments" || result.Target.Name != "web-target" {
		t.Errorf("target = %+v", result.Target)
	}
	if result.Behavior.Type != "canary" {
		t.Errorf("behavior = %+v, want canary", result.Behavior)
	}
}

func TestClassifyWorkloadUpdateBehavior(t *testing.T) {
	stateful := imageWorkload("apps/v1", "StatefulSet", "prod", "db", []any{imageContainer("db", "db:v1")}, nil)
	_ = unstructured.SetNestedField(stateful.Object, "RollingUpdate", "spec", "updateStrategy", "type")
	_ = unstructured.SetNestedField(stateful.Object, int64(2), "spec", "updateStrategy", "rollingUpdate", "partition")
	behavior := classifyWorkloadUpdateBehavior("statefulsets", stateful)
	if behavior.Type != "partitioned" || behavior.Partition == nil || *behavior.Partition != 2 {
		t.Errorf("partitioned behavior = %+v", behavior)
	}

	daemonSet := imageWorkload("apps/v1", "DaemonSet", "prod", "agent", []any{imageContainer("agent", "agent:v1")}, nil)
	_ = unstructured.SetNestedField(daemonSet.Object, "OnDelete", "spec", "updateStrategy", "type")
	if got := classifyWorkloadUpdateBehavior("daemonsets", daemonSet).Type; got != "onDelete" {
		t.Errorf("DaemonSet behavior = %q", got)
	}

	deployment := imageWorkload("apps/v1", "Deployment", "prod", "web", []any{imageContainer("app", "app:v1")}, nil)
	_ = unstructured.SetNestedField(deployment.Object, true, "spec", "paused")
	if got := classifyWorkloadUpdateBehavior("deployments", deployment).Type; got != "paused" {
		t.Errorf("Deployment behavior = %q", got)
	}

	rollout := imageWorkload("argoproj.io/v1alpha1", "Rollout", "prod", "web", []any{imageContainer("app", "app:v1")}, nil)
	_ = unstructured.SetNestedMap(rollout.Object, map[string]any{"canary": map[string]any{}}, "spec", "strategy")
	_ = unstructured.SetNestedField(rollout.Object, true, "spec", "paused")
	if got := classifyWorkloadUpdateBehavior("rollouts", rollout).Type; got != "paused" {
		t.Errorf("paused Rollout behavior = %q", got)
	}

	unstructured.RemoveNestedField(rollout.Object, "spec", "paused")
	canary := classifyWorkloadUpdateBehavior("rollouts", rollout)
	if canary.Type != "canary" || canary.Gated == nil || *canary.Gated {
		t.Errorf("ungated canary behavior = %+v", canary)
	}
	_ = unstructured.SetNestedSlice(rollout.Object, []any{map[string]any{"setWeight": int64(20)}}, "spec", "strategy", "canary", "steps")
	canary = classifyWorkloadUpdateBehavior("rollouts", rollout)
	if canary.Gated == nil || !*canary.Gated {
		t.Errorf("gated canary behavior = %+v", canary)
	}

	_ = unstructured.SetNestedMap(rollout.Object, map[string]any{"blueGreen": map[string]any{}}, "spec", "strategy")
	blueGreen := classifyWorkloadUpdateBehavior("rollouts", rollout)
	if blueGreen.Type != "blueGreen" || blueGreen.AutoPromote == nil || !*blueGreen.AutoPromote {
		t.Errorf("default blue-green behavior = %+v", blueGreen)
	}
	_ = unstructured.SetNestedField(rollout.Object, false, "spec", "strategy", "blueGreen", "autoPromotionEnabled")
	blueGreen = classifyWorkloadUpdateBehavior("rollouts", rollout)
	if blueGreen.AutoPromote == nil || *blueGreen.AutoPromote {
		t.Errorf("manual blue-green behavior = %+v", blueGreen)
	}

	unstructured.RemoveNestedField(rollout.Object, "spec", "strategy")
	if got := classifyWorkloadUpdateBehavior("rollouts", rollout).Type; got != "rolling" {
		t.Errorf("Rollout without a strategy behavior = %q", got)
	}
}

func TestGetWorkloadImagesRejectsTerminatingRootAndReferencedTarget(t *testing.T) {
	now := metav1.Now()
	terminatingDeployment := imageWorkload("apps/v1", "Deployment", "prod", "web",
		[]any{imageContainer("app", "app:v1")}, nil)
	terminatingDeployment.SetDeletionTimestamp(&now)
	_, err := NewWorkloadManager(fakeImageClient(terminatingDeployment), nil).GetWorkloadImages(context.Background(), "deployments", "prod", "web")
	if !errors.Is(err, ErrImageWorkloadTerminating) {
		t.Fatalf("terminating root error = %v", err)
	}

	rollout := imageWorkload("argoproj.io/v1alpha1", "Rollout", "prod", "rollout", nil, nil)
	unstructured.RemoveNestedField(rollout.Object, "spec", "template")
	_ = unstructured.SetNestedMap(rollout.Object, map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "name": "web"}, "spec", "workloadRef")
	_, err = NewWorkloadManager(fakeImageClient(rollout, terminatingDeployment), nil).GetWorkloadImages(context.Background(), "rollouts", "prod", "rollout")
	if !errors.Is(err, ErrImageWorkloadTerminating) {
		t.Fatalf("terminating referenced target error = %v", err)
	}
}

func TestWorkloadImageTargetForRollout(t *testing.T) {
	inline := imageWorkload("argoproj.io/v1alpha1", "Rollout", "prod", "web", []any{imageContainer("app", "app:v1")}, nil)
	group, resource, needsGet, supported := WorkloadImageTargetForRollout(inline)
	if group != "argoproj.io" || resource != "rollouts" || needsGet || !supported {
		t.Errorf("inline target = (%q, %q, %v, %v)", group, resource, needsGet, supported)
	}

	ref := inline.DeepCopy()
	unstructured.RemoveNestedField(ref.Object, "spec", "template")
	_ = unstructured.SetNestedMap(ref.Object, map[string]any{"apiVersion": "apps/v1", "kind": "Deployment", "name": "web-target"}, "spec", "workloadRef")
	group, resource, needsGet, supported = WorkloadImageTargetForRollout(ref)
	if group != "apps" || resource != "deployments" || !needsGet || !supported {
		t.Errorf("workloadRef target = (%q, %q, %v, %v)", group, resource, needsGet, supported)
	}

	_ = unstructured.SetNestedMap(ref.Object, map[string]any{"apiVersion": "apps/v1", "kind": "StatefulSet", "name": "web-target"}, "spec", "workloadRef")
	_, _, _, supported = WorkloadImageTargetForRollout(ref)
	if supported {
		t.Error("unsupported workloadRef reported as supported")
	}
}

func TestValidateImageUpdates(t *testing.T) {
	tests := []struct {
		name    string
		updates []WorkloadImageUpdate
	}{
		{"empty", nil},
		{"unknown type", []WorkloadImageUpdate{{Type: "ephemeralContainer", Name: "debug", PreviousImage: "a", Image: "b"}}},
		{"missing image", []WorkloadImageUpdate{{Type: containerTypeRegular, Name: "app", PreviousImage: "a"}}},
		{"unchanged", []WorkloadImageUpdate{{Type: containerTypeRegular, Name: "app", PreviousImage: "a", Image: "a"}}},
		{"duplicate", []WorkloadImageUpdate{
			{Type: containerTypeRegular, Name: "app", PreviousImage: "a", Image: "b"},
			{Type: containerTypeRegular, Name: "app", PreviousImage: "a", Image: "c"},
		}},
		{"too many", func() []WorkloadImageUpdate {
			updates := make([]WorkloadImageUpdate, 65)
			for i := range updates {
				updates[i] = WorkloadImageUpdate{Type: containerTypeRegular, Name: fmt.Sprintf("app-%d", i), PreviousImage: "a", Image: "b"}
			}
			return updates
		}()},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if err := validateImageUpdates(tc.updates); !errors.Is(err, ErrInvalidImageUpdate) {
				t.Fatalf("error = %v, want ErrInvalidImageUpdate", err)
			}
		})
	}
}