Ctrl K
ComunidadGuías…

internal/rollout/rollout_test.go

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

Archivo original ↗Preguntar a KiBot
package rollout

import (
	"errors"
	"slices"
	"testing"

	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	k8stesting "k8s.io/client-go/testing"

	"github.com/sophotechlabs/spinoza/internal/api"
)

var (
	deploymentGVR         = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
	replicaSetGVR         = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: replicaSets}
	statefulSetGVR        = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "statefulsets"}
	daemonSetGVR          = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "daemonsets"}
	controllerRevisionGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: controllerRevisions}
)

func clusterOf(objs ...runtime.Object) *dynamicfake.FakeDynamicClient {
	kinds := map[schema.GroupVersionResource]string{
		deploymentGVR:         "DeploymentList",
		replicaSetGVR:         "ReplicaSetList",
		statefulSetGVR:        "StatefulSetList",
		daemonSetGVR:          "DaemonSetList",
		controllerRevisionGVR: "ControllerRevisionList",
	}
	return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), kinds, objs...)
}

func podTemplate(image, hash string) map[string]any {
	labels := map[string]any{"app": "web"}
	if hash != "" {
		labels[templateHashLabel] = hash
	}
	return map[string]any{
		"metadata": map[string]any{"labels": labels},
		specField: map[string]any{
			"containers": []any{
				map[string]any{"name": "app", "image": image},
			},
		},
	}
}

func workload(kind, name, image string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       kind,
		"metadata": map[string]any{
			"name":      name,
			"namespace": "shop",
		},
		specField: map[string]any{
			"replicas":    int64(3),
			templateField: podTemplate(image, ""),
		},
	}}
}

func replicaSet(name, revision, image, owner string) *unstructured.Unstructured {
	annotations := map[string]any{"kubernetes.io/change-cause": "kubectl set image"}
	if revision != "" {
		annotations[revisionAnnotation] = revision
	}
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ReplicaSet",
		"metadata": map[string]any{
			"name":              name,
			"namespace":         "shop",
			"annotations":       annotations,
			"creationTimestamp": "2026-07-31T12:00:00Z",
			"ownerReferences": []any{
				map[string]any{"apiVersion": "apps/v1", "kind": deploymentKind, "name": owner},
			},
		},
		specField: map[string]any{
			"replicas":    int64(3),
			templateField: podTemplate(image, name),
		},
		"status": map[string]any{"readyReplicas": int64(2)},
	}}
}

func controllerRevision(name string, number int64, image, kind, owner string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ControllerRevision",
		"metadata": map[string]any{
			"name":              name,
			"namespace":         "shop",
			"creationTimestamp": "2026-07-31T12:00:00Z",
			"ownerReferences": []any{
				map[string]any{"apiVersion": "apps/v1", "kind": kind, "name": owner},
			},
		},
		"revision": number,
		"data": map[string]any{
			specField: map[string]any{templateField: podTemplate(image, name)},
		},
	}}
}

func deploymentRef() api.ObjectRef {
	return api.ObjectRef{Group: appsGroup, Version: "v1", Resource: "deployments", Namespace: "shop", Name: "web"}
}

func statefulSetRef() api.ObjectRef {
	return api.ObjectRef{Group: appsGroup, Version: "v1", Resource: "statefulsets", Namespace: "shop", Name: "db"}
}

func daemonSetRef() api.ObjectRef {
	return api.ObjectRef{Group: appsGroup, Version: "v1", Resource: "daemonsets", Namespace: "shop", Name: "agent"}
}

func deploymentWorld() []runtime.Object {
	return []runtime.Object{
		workload(deploymentKind, "web", "nginx:5.0"),
		replicaSet("web-1", "1", "nginx:1.0", "web"),
		replicaSet("web-2", "2", "nginx:2.0", "web"),
		replicaSet("web-3", "3", "nginx:3.0", "web"),
		replicaSet("web-4", "4", "nginx:4.0", "web"),
		replicaSet("web-5", "5", "nginx:5.0", "web"),
		replicaSet("web-elsewhere", "9", "nginx:9.0", "shop-api"),
		replicaSet("web-unnumbered", "", "nginx:0.9", "web"),
	}
}

func statefulSetWorld() []runtime.Object {
	return []runtime.Object{
		workload(statefulSetKind, "db", "postgres:16"),
		controllerRevision("db-1", 1, "postgres:15", statefulSetKind, "db"),
		controllerRevision("db-2", 2, "postgres:16", statefulSetKind, "db"),
		controllerRevision("cache-1", 1, "redis:7", statefulSetKind, "cache"),
	}
}

func daemonSetWorld() []runtime.Object {
	return []runtime.Object{
		workload(daemonSetKind, "agent", "agent:2.0"),
		controllerRevision("agent-1", 1, "agent:1.0", daemonSetKind, "agent"),
		controllerRevision("agent-2", 2, "agent:2.0", daemonSetKind, "agent"),
	}
}

func numbersOf(found api.Revisions) []int64 {
	out := make([]int64, 0, len(found.Revisions))
	for _, one := range found.Revisions {
		out = append(out, one.Number)
	}
	return out
}

func namesOf(found api.Revisions) []string {
	out := make([]string, 0, len(found.Revisions))
	for _, one := range found.Revisions {
		out = append(out, one.Name)
	}
	return out
}

func currentOf(t *testing.T, found api.Revisions) string {
	t.Helper()
	current := ""
	for _, one := range found.Revisions {
		if !one.Current {
			continue
		}
		if current != "" {
			t.Fatalf("%s and %s are both marked current", current, one.Name)
		}
		current = one.Name
	}
	return current
}

func TestTheRevisionListIsNewestFirstAndHoldsOnlyWhatTheWorkloadOwns(t *testing.T) {
	cases := []struct {
		name      string
		objects   []runtime.Object
		ref       api.ObjectRef
		supported bool
		reason    string
		numbers   []int64
		current   string
	}{
		{
			name:      "a deployment orders its own replica sets",
			objects:   deploymentWorld(),
			ref:       deploymentRef(),
			supported: true,
			numbers:   []int64{5, 4, 3, 2, 1},
			current:   "web-5",
		},
		{
			name:      "a stateful set orders its controller revisions",
			objects:   statefulSetWorld(),
			ref:       statefulSetRef(),
			supported: true,
			numbers:   []int64{2, 1},
			current:   "db-2",
		},
		{
			name:      "a daemon set orders its controller revisions",
			objects:   daemonSetWorld(),
			ref:       daemonSetRef(),
			supported: true,
			numbers:   []int64{2, 1},
			current:   "agent-2",
		},
		{
			name:      "a kind with no rollout history says so instead of failing",
			objects:   nil,
			ref:       api.ObjectRef{Version: "v1", Resource: "configmaps", Namespace: "shop", Name: "settings"},
			supported: false,
			reason:    "configmaps keep no rollout revisions",
			numbers:   []int64{},
		},
		{
			name:      "a kind outside the apps group is named with its group",
			objects:   nil,
			ref:       api.ObjectRef{Group: "batch", Version: "v1", Resource: "cronjobs", Namespace: "shop", Name: "nightly"},
			supported: false,
			reason:    "batch/cronjobs keep no rollout revisions",
			numbers:   []int64{},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			found, err := List(t.Context(), FromDynamic(clusterOf(tc.objects...)), tc.ref)
			if err != nil {
				t.Fatalf("list revisions: %v", err)
			}

			if found.Supported != tc.supported {
				t.Fatalf("supported = %v, want %v", found.Supported, tc.supported)
			}
			if found.Reason != tc.reason {
				t.Fatalf("reason = %q, want %q", found.Reason, tc.reason)
			}
			if !slices.Equal(numbersOf(found), tc.numbers) {
				t.Fatalf("revisions = %v, want %v", numbersOf(found), tc.numbers)
			}
			if current := currentOf(t, found); current != tc.current {
				t.Fatalf("current = %q, want %q", current, tc.current)
			}
		})
	}
}

func TestAReplicaSetOwnedByAnotherDeploymentIsLeftOut(t *testing.T) {
	found, err := List(t.Context(), FromDynamic(clusterOf(deploymentWorld()...)), deploymentRef())
	if err != nil {
		t.Fatalf("list revisions: %v", err)
	}

	names := namesOf(found)
	if slices.Contains(names, "web-elsewhere") {
		t.Fatalf("revisions = %v, want the replica set another deployment owns to be left out", names)
	}
	if slices.Contains(names, "web-unnumbered") {
		t.Fatalf("revisions = %v, want the replica set with no revision annotation to be left out", names)
	}
}

func TestARevisionCarriesItsImagesCauseAndReplicaCounts(t *testing.T) {
	found, err := List(t.Context(), FromDynamic(clusterOf(deploymentWorld()...)), deploymentRef())
	if err != nil {
		t.Fatalf("list revisions: %v", err)
	}

	newest := found.Revisions[0]
	if !slices.Equal(newest.Images, []string{"nginx:5.0"}) {
		t.Fatalf("images = %v, want the images of that revision", newest.Images)
	}
	if newest.Cause != "kubectl set image" {
		t.Fatalf("cause = %q, want the change-cause annotation", newest.Cause)
	}
	if newest.CreatedAt != "2026-07-31T12:00:00Z" {
		t.Fatalf("createdAt = %q, want the creation timestamp", newest.CreatedAt)
	}
	if newest.Replicas != 3 || newest.Ready != 2 {
		t.Fatalf("replicas = %d ready = %d, want 3 and 2", newest.Replicas, newest.Ready)
	}
}

func TestTheTemplateOfARevisionLeavesOutThePodTemplateHash(t *testing.T) {
	template, err := Template(t.Context(), FromDynamic(clusterOf(deploymentWorld()...)), deploymentRef(), 2)
	if err != nil {
		t.Fatalf("read the template: %v", err)
	}

	labels, found, nestedErr := unstructured.NestedStringMap(template, "metadata", "labels")
	if !found || nestedErr != nil {
		t.Fatalf("labels: found = %v err = %v", found, nestedErr)
	}
	if _, carried := labels[templateHashLabel]; carried {
		t.Fatalf("labels = %v, want the pod template hash left out", labels)
	}
	images, _, _ := unstructured.NestedSlice(template, specField, "containers")
	if len(images) != 1 {
		t.Fatalf("containers = %v, want the containers of revision 2", images)
	}
}

func TestATemplateOfAKindWithNoHistoryReadsBackTheReason(t *testing.T) {
	ref := api.ObjectRef{Version: "v1", Resource: "configmaps", Namespace: "shop", Name: "settings"}

	_, err := Template(t.Context(), FromDynamic(clusterOf()), ref, 1)

	if err == nil || err.Error() != "configmaps keep no rollout revisions" {
		t.Fatalf("error = %v, want the sentence the list gives", err)
	}
}

func TestARevisionThatIsNotThereIsNamedInTheError(t *testing.T) {
	_, err := Template(t.Context(), FromDynamic(clusterOf(deploymentWorld()...)), deploymentRef(), 12)

	if err == nil {
		t.Fatal("expected a missing revision to be refused")
	}
	if err.Error() != "web has no revision 12" {
		t.Fatalf("error = %q, want it to name the revision", err.Error())
	}
}

func TestAWorkloadThatIsNotThereIsReportedRatherThanEmpty(t *testing.T) {
	cases := []struct {
		name string
		ref  api.ObjectRef
	}{
		{name: "a deployment that was deleted", ref: deploymentRef()},
		{name: "a stateful set that was deleted", ref: statefulSetRef()},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			_, err := List(t.Context(), FromDynamic(clusterOf()), tc.ref)

			if err == nil {
				t.Fatal("expected a missing workload to fail")
			}
		})
	}
}

func TestAListThatTheApiserverRefusesIsReported(t *testing.T) {
	cases := []struct {
		name     string
		resource string
		ref      api.ObjectRef
		objects  []runtime.Object
	}{
		{
			name:     "replica sets a deployment cannot list",
			resource: replicaSets,
			ref:      deploymentRef(),
			objects:  deploymentWorld(),
		},
		{
			name:     "controller revisions a stateful set cannot list",
			resource: controllerRevisions,
			ref:      statefulSetRef(),
			objects:  statefulSetWorld(),
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			client := clusterOf(tc.objects...)
			client.PrependReactor("list", tc.resource, func(k8stesting.Action) (bool, runtime.Object, error) {
				return true, nil, errors.New("no")
			})

			_, err := List(t.Context(), FromDynamic(client), tc.ref)

			if err == nil {
				t.Fatal("expected a refused list to be reported")
			}
		})
	}
}

func plainReplicaSet(name, revision string, template map[string]any) *unstructured.Unstructured {
	set := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ReplicaSet",
		"metadata": map[string]any{
			"name":        name,
			"namespace":   "shop",
			"annotations": map[string]any{revisionAnnotation: revision},
			"ownerReferences": []any{
				map[string]any{"apiVersion": "apps/v1", "kind": deploymentKind, "name": "web"},
			},
		},
		specField: map[string]any{},
	}}
	if template != nil {
		_ = unstructured.SetNestedMap(set.Object, template, specField, templateField)
	}
	return set
}

func plainWorkload(kind, name string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       kind,
		"metadata":   map[string]any{"name": name, "namespace": "shop"},
		specField:    map[string]any{"replicas": int64(1)},
	}}
}

func plainControllerRevision(name, kind, owner string) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ControllerRevision",
		"metadata": map[string]any{
			"name":      name,
			"namespace": "shop",
			"ownerReferences": []any{
				map[string]any{"apiVersion": "apps/v1", "kind": kind, "name": owner},
			},
		},
		"data": map[string]any{specField: map[string]any{templateField: podTemplate("postgres:14", name)}},
	}}
}

func TestARevisionListLeavesOutWhatItCannotRead(t *testing.T) {
	cases := []struct {
		name    string
		objects []runtime.Object
		ref     api.ObjectRef
		names   []string
		current string
	}{
		{
			name: "a revision annotation that is not a number",
			objects: []runtime.Object{
				workload(deploymentKind, "web", "nginx:5.0"),
				replicaSet("web-1", "1", "nginx:1.0", "web"),
				replicaSet("web-later", "later", "nginx:2.0", "web"),
			},
			ref:     deploymentRef(),
			names:   []string{"web-1"},
			current: "web-1",
		},
		{
			name: "a workload with no pod template of its own",
			objects: []runtime.Object{
				plainWorkload(deploymentKind, "web"),
				replicaSet("web-1", "1", "nginx:1.0", "web"),
			},
			ref:     deploymentRef(),
			names:   []string{"web-1"},
			current: "web-1",
		},
		{
			name: "a controller revision with no revision number",
			objects: []runtime.Object{
				workload(statefulSetKind, "db", "postgres:16"),
				controllerRevision("db-1", 1, "postgres:15", statefulSetKind, "db"),
				plainControllerRevision("db-unnumbered", statefulSetKind, "db"),
			},
			ref:     statefulSetRef(),
			names:   []string{"db-1"},
			current: "db-1",
		},
		{
			name: "two replica sets that claim the same revision are ordered by name",
			objects: []runtime.Object{
				workload(deploymentKind, "web", "nginx:5.0"),
				replicaSet("web-b", "1", "nginx:1.0", "web"),
				replicaSet("web-a", "1", "nginx:1.1", "web"),
			},
			ref:     deploymentRef(),
			names:   []string{"web-a", "web-b"},
			current: "web-a",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			found, err := List(t.Context(), FromDynamic(clusterOf(tc.objects...)), tc.ref)
			if err != nil {
				t.Fatalf("list revisions: %v", err)
			}

			if !slices.Equal(namesOf(found), tc.names) {
				t.Fatalf("revisions = %v, want %v", namesOf(found), tc.names)
			}
			if current := currentOf(t, found); current != tc.current {
				t.Fatalf("current = %q, want %q", current, tc.current)
			}
		})
	}
}

func TestARevisionWithNothingToSayLeavesItsFieldsEmpty(t *testing.T) {
	objects := []runtime.Object{
		plainWorkload(deploymentKind, "web"),
		plainReplicaSet("web-1", "1", map[string]any{
			"metadata": map[string]any{"labels": map[string]any{templateHashLabel: "web-1"}},
		}),
	}

	found, err := List(t.Context(), FromDynamic(clusterOf(objects...)), deploymentRef())
	if err != nil {
		t.Fatalf("list revisions: %v", err)
	}

	one := found.Revisions[0]
	if one.Images != nil {
		t.Fatalf("images = %v, want none", one.Images)
	}
	if one.CreatedAt != "" {
		t.Fatalf("createdAt = %q, want it empty", one.CreatedAt)
	}
	if one.Cause != "" {
		t.Fatalf("cause = %q, want it empty", one.Cause)
	}
	if one.Replicas != 0 || one.Ready != 0 {
		t.Fatalf("replicas = %d ready = %d, want no counts", one.Replicas, one.Ready)
	}
}

func TestARevisionOnlyListsContainersThatNameAnImage(t *testing.T) {
	objects := []runtime.Object{
		plainWorkload(deploymentKind, "web"),
		plainReplicaSet("web-1", "1", map[string]any{
			specField: map[string]any{
				"containers": []any{
					map[string]any{"name": "app", "image": "nginx:1.0"},
					map[string]any{"name": "waiting"},
					"not a container at all",
				},
			},
		}),
	}

	found, err := List(t.Context(), FromDynamic(clusterOf(objects...)), deploymentRef())
	if err != nil {
		t.Fatalf("list revisions: %v", err)
	}

	if !slices.Equal(found.Revisions[0].Images, []string{"nginx:1.0"}) {
		t.Fatalf("images = %v, want only the container that names one", found.Revisions[0].Images)
	}
}

func TestARevisionThatHoldsNoPodTemplateCannotBeReplayed(t *testing.T) {
	objects := []runtime.Object{
		plainWorkload(deploymentKind, "web"),
		plainReplicaSet("web-1", "1", nil),
	}

	_, err := Template(t.Context(), FromDynamic(clusterOf(objects...)), deploymentRef(), 1)

	if err == nil {
		t.Fatal("expected a revision with no pod template to be refused")
	}
	if err.Error() != "revision 1 of web holds no pod template" {
		t.Fatalf("error = %q, want it to name the revision", err.Error())
	}
}

func TestATemplateOfAWorkloadThatIsNotThereIsReported(t *testing.T) {
	_, err := Template(t.Context(), FromDynamic(clusterOf()), deploymentRef(), 1)

	if err == nil {
		t.Fatal("expected a missing deployment to fail")
	}
}

func TestASourceWithoutANamespaceReadsAcrossTheCluster(t *testing.T) {
	source := FromDynamic(clusterOf(deploymentWorld()...))
	ref := api.ObjectRef{Group: appsGroup, Version: "v1", Resource: replicaSets}

	found, err := source.List(t.Context(), ref)
	if err != nil {
		t.Fatalf("list replica sets: %v", err)
	}

	if len(found) != 7 {
		t.Fatalf("read %d replica sets, want every one in the cluster", len(found))
	}
}

func TestAfterARollbackOnlyOneRevisionIsCurrent(t *testing.T) {
	same := []entry{
		{Number: 3, Name: "web-c", Current: true},
		{Number: 2, Name: "web-b"},
		{Number: 1, Name: "web-a", Current: true},
	}

	got := ordered(same)

	current := []int64{}
	for _, one := range got {
		if one.Current {
			current = append(current, one.Number)
		}
	}
	if len(current) != 1 {
		t.Fatalf("revisions %v say they are current, want exactly one", current)
	}
	if current[0] != 3 {
		t.Fatalf("revision %d is current, want the newest that matches", current[0])
	}
}

func TestTheNewestRevisionIsTheOneRunning(t *testing.T) {
	got := ordered([]entry{
		{Number: 1, Name: "web-a"},
		{Number: 3, Name: "web-c"},
		{Number: 2, Name: "web-b"},
	})

	if len(got) != 3 {
		t.Fatalf("ordered %d revisions", len(got))
	}
	if !got[0].Current || got[0].Number != 3 {
		t.Fatalf("current = %+v, want revision 3", got[0].Revision)
	}
	for _, one := range got[1:] {
		if one.Current {
			t.Fatalf("revision %d also says it is current", one.Number)
		}
	}
}

func TestNothingIsCurrentWhenThereAreNoRevisions(t *testing.T) {
	if got := ordered(nil); len(got) != 0 {
		t.Fatalf("ordered %d revisions from none", len(got))
	}
}