Ctrl K
ComunidadGuías…

pkg/k8score/workload_rollback_test.go

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

Archivo original ↗Preguntar a KiBot
package k8score

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

	appsv1 "k8s.io/api/apps/v1"
	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/types"
	"k8s.io/apimachinery/pkg/util/strategicpatch"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	clienttesting "k8s.io/client-go/testing"

	"github.com/skyhook-io/radar/pkg/rollouts"
)

var (
	deploymentsGVR         = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
	replicaSetsGVR         = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}
	statefulSetsGVR        = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "statefulsets"}
	daemonSetsGVR          = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "daemonsets"}
	controllerRevisionsGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "controllerrevisions"}
)

func appsDiscovery() *ResourceDiscovery {
	rd := &ResourceDiscovery{resourceMap: map[string]APIResource{}, gvrMap: map[string]schema.GroupVersionResource{}}
	for kind, gvr := range map[string]schema.GroupVersionResource{
		"Deployment": deploymentsGVR, "ReplicaSet": replicaSetsGVR, "StatefulSet": statefulSetsGVR,
		"DaemonSet": daemonSetsGVR, "ControllerRevision": controllerRevisionsGVR,
	} {
		res := APIResource{Kind: kind, Name: gvr.Resource, Group: gvr.Group, Version: gvr.Version, Namespaced: true}
		rd.resourceMap[strings.ToLower(kind)] = res
		rd.resourceMap[gvr.Resource] = res
		rd.gvrMap[strings.ToLower(kind)] = gvr
		rd.gvrMap[gvr.Resource] = gvr
	}
	return rd
}

func newAppsClient(objs ...runtime.Object) *dynamicfake.FakeDynamicClient {
	return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{
		deploymentsGVR: "DeploymentList", replicaSetsGVR: "ReplicaSetList", statefulSetsGVR: "StatefulSetList",
		daemonSetsGVR: "DaemonSetList", controllerRevisionsGVR: "ControllerRevisionList",
	}, objs...)
}

// Revision 1 is a bare container; revision 2 adds what a later deploy
// typically adds: a volume and its mount, an env var, a sidecar, and the
// restartedAt annotation kubectl rollout restart writes.
func podTemplateV1() map[string]any {
	return map[string]any{
		"metadata": map[string]any{"labels": map[string]any{"app": "web"}},
		"spec": map[string]any{
			"containers": []any{map[string]any{"name": "app", "image": "web:v1"}},
		},
	}
}

func podTemplateV2() map[string]any {
	return map[string]any{
		"metadata": map[string]any{
			"labels":      map[string]any{"app": "web"},
			"annotations": map[string]any{"kubectl.kubernetes.io/restartedAt": "2026-10-27T10:00:00Z"},
		},
		"spec": map[string]any{
			"containers": []any{
				map[string]any{
					"name":         "app",
					"image":        "web:v2",
					"env":          []any{map[string]any{"name": "FEATURE", "value": "on"}},
					"volumeMounts": []any{map[string]any{"name": "cache", "mountPath": "/cache"}},
				},
				map[string]any{"name": "proxy", "image": "envoy:v1"},
			},
			"volumes": []any{map[string]any{"name": "cache", "emptyDir": map[string]any{}}},
		},
	}
}

func withHash(template map[string]any, hash string) map[string]any {
	t := runtime.DeepCopyJSON(template)
	labels := t["metadata"].(map[string]any)["labels"].(map[string]any)
	labels["pod-template-hash"] = hash
	return t
}

func deploymentObject(template map[string]any, annotations map[string]any, mutate func(map[string]any)) *unstructured.Unstructured {
	d := &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "Deployment",
		"metadata": map[string]any{
			"namespace": "prod", "name": "web", "uid": "dep-uid", "resourceVersion": "42",
			"annotations": annotations,
		},
		"spec": map[string]any{
			"replicas": int64(2),
			"selector": map[string]any{"matchLabels": map[string]any{"app": "web"}},
			"template": runtime.DeepCopyJSON(template),
		},
	}}
	if mutate != nil {
		mutate(d.Object)
	}
	return d
}

func deploymentReplicaSet(name, revision, hash string, template map[string]any, annotations map[string]any) *unstructured.Unstructured {
	ann := map[string]any{"deployment.kubernetes.io/revision": revision}
	for k, v := range annotations {
		ann[k] = v
	}
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ReplicaSet",
		"metadata": map[string]any{
			"namespace": "prod", "name": name, "annotations": ann,
			"labels": map[string]any{"app": "web", "pod-template-hash": hash},
			"ownerReferences": []any{map[string]any{
				"apiVersion": "apps/v1", "kind": "Deployment", "name": "web", "uid": "dep-uid", "controller": true,
			}},
		},
		"spec": map[string]any{"replicas": int64(0), "template": withHash(template, hash)},
	}}
}

func getDeployment(t *testing.T, client *dynamicfake.FakeDynamicClient) *unstructured.Unstructured {
	t.Helper()
	d, err := client.Resource(deploymentsGVR).Namespace("prod").Get(context.Background(), "web", metav1.GetOptions{})
	if err != nil {
		t.Fatalf("get deployment: %v", err)
	}
	return d
}

func patchActions(client *dynamicfake.FakeDynamicClient) []clienttesting.PatchAction {
	var out []clienttesting.PatchAction
	for _, a := range client.Actions() {
		if pa, ok := a.(clienttesting.PatchAction); ok {
			out = append(out, pa)
		}
	}
	return out
}

// Rolling back across a revision that added a volume+mount, env var, sidecar and
// restartedAt must leave exactly the target template: a merge patch would keep
// every one of them, and copying the ReplicaSet template would leak its hash.
func TestRollbackDeploymentRestoresTargetTemplateExactly(t *testing.T) {
	for _, tc := range []struct {
		name             string
		from, to         func() map[string]any
		toRev, fromRev   string
		toHash, fromHash string
	}{
		{name: "back to the revision without the additions", from: podTemplateV2, to: podTemplateV1, fromRev: "2", toRev: "1", fromHash: "h2", toHash: "h1"},
		{name: "forward to the revision with the additions", from: podTemplateV1, to: podTemplateV2, fromRev: "3", toRev: "2", fromHash: "h1", toHash: "h2"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			client := newAppsClient(
				deploymentObject(tc.from(), map[string]any{"deployment.kubernetes.io/revision": tc.fromRev}, nil),
				deploymentReplicaSet("web-"+tc.toHash, tc.toRev, tc.toHash, tc.to(), nil),
				deploymentReplicaSet("web-"+tc.fromHash, tc.fromRev, tc.fromHash, tc.from(), nil),
			)
			m := NewWorkloadManager(client, appsDiscovery())
			toRev := map[string]int64{"1": 1, "2": 2}[tc.toRev]
			if err := m.RollbackWorkload(context.Background(), "Deployment", "prod", "web", toRev); err != nil {
				t.Fatalf("RollbackWorkload: %v", err)
			}
			got, _, _ := unstructured.NestedMap(getDeployment(t, client).Object, "spec", "template")
			if !reflect.DeepEqual(got, tc.to()) {
				gotJSON, _ := json.Marshal(got)
				wantJSON, _ := json.Marshal(tc.to())
				t.Fatalf("template after rollback\n got  %s\n want %s", gotJSON, wantJSON)
			}
			pa := patchActions(client)
			if len(pa) != 1 || pa[0].GetPatchType() != "application/json-patch+json" {
				t.Fatalf("want one JSON patch, got %d (%v)", len(pa), pa)
			}
			if strings.Contains(string(pa[0].GetPatch()), "pod-template-hash") {
				t.Errorf("patch leaks pod-template-hash: %s", pa[0].GetPatch())
			}
		})
	}
}

// The patch pins the resourceVersion that was compared, so a write that lands
// between the read and the patch is rejected instead of overwritten.
func TestRollbackDeploymentPatchPinsResourceVersion(t *testing.T) {
	client := newAppsClient(
		deploymentObject(podTemplateV2(), nil, nil),
		deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), nil),
	)
	m := NewWorkloadManager(client, appsDiscovery())
	if err := m.RollbackWorkload(context.Background(), "Deployment", "prod", "web", 1); err != nil {
		t.Fatalf("RollbackWorkload: %v", err)
	}
	var ops []map[string]any
	if err := json.Unmarshal(patchActions(client)[0].GetPatch(), &ops); err != nil {
		t.Fatal(err)
	}
	if ops[0]["op"] != "replace" || ops[0]["path"] != "/metadata/resourceVersion" || ops[0]["value"] != "42" {
		t.Fatalf("first op does not pin resourceVersion 42: %v", ops[0])
	}
}

func TestRollbackDeploymentConflictSaysTheWorkloadChanged(t *testing.T) {
	client := newAppsClient(
		deploymentObject(podTemplateV2(), nil, nil),
		deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), nil),
	)
	client.PrependReactor("patch", "deployments", func(clienttesting.Action) (bool, runtime.Object, error) {
		return true, nil, apierrors.NewConflict(schema.GroupResource{Group: "apps", Resource: "deployments"}, "web", errors.New("the object has been modified"))
	})
	err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), "Deployment", "prod", "web", 1)
	if !apierrors.IsConflict(err) || !strings.Contains(err.Error(), "changed while rolling back") {
		t.Fatalf("want a wrapped conflict naming the change, got %v", err)
	}
}

// A controller status write between the read and the patch bumps the
// resourceVersion; the rollback re-reads and succeeds instead of failing.
func TestRollbackRetriesAfterTransientConflict(t *testing.T) {
	cases := []struct {
		kind, name string
		objects    []runtime.Object
	}{
		{"Deployment", "web", []runtime.Object{
			deploymentObject(podTemplateV2(), nil, nil),
			deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), nil),
		}},
		{"StatefulSet", "db", []runtime.Object{
			controllerWorkload("StatefulSet", "db", podTemplateV2()),
			controllerRevisionObject("db", "StatefulSet", "db-1", 1, podTemplateV1()),
		}},
	}
	for _, tc := range cases {
		t.Run(tc.kind, func(t *testing.T) {
			client := newAppsClient(tc.objects...)
			attempts := 0
			client.PrependReactor("patch", "*", func(clienttesting.Action) (bool, runtime.Object, error) {
				attempts++
				if attempts == 1 {
					return true, nil, apierrors.NewConflict(schema.GroupResource{Group: "apps", Resource: "x"}, tc.name, errors.New("the object has been modified"))
				}
				return true, nil, nil
			})
			if err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), tc.kind, "prod", tc.name, 1); err != nil {
				t.Fatalf("RollbackWorkload: %v", err)
			}
			if attempts != 2 {
				t.Fatalf("patch attempts = %d, want 2", attempts)
			}
		})
	}
}

// Annotations follow kubectl rollout undo: the controller's bookkeeping stays
// from the live Deployment; everything else (change-cause included) comes back
// from the target revision, and annotations added since are dropped.
func TestRollbackDeploymentRestoresAnnotationsLikeKubectl(t *testing.T) {
	client := newAppsClient(
		deploymentObject(podTemplateV2(), map[string]any{
			"deployment.kubernetes.io/revision":                "2",
			"kubectl.kubernetes.io/last-applied-configuration": "{}",
			"kubernetes.io/change-cause":                       "deploy v2",
			"added-after-v1":                                   "x",
		}, nil),
		deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), map[string]any{
			"kubernetes.io/change-cause":            "deploy v1",
			"deployment.kubernetes.io/max-replicas": "3",
		}),
	)
	if err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), "Deployment", "prod", "web", 1); err != nil {
		t.Fatalf("RollbackWorkload: %v", err)
	}
	got := getDeployment(t, client).GetAnnotations()
	want := map[string]string{
		"deployment.kubernetes.io/revision":                "2",
		"kubectl.kubernetes.io/last-applied-configuration": "{}",
		"kubernetes.io/change-cause":                       "deploy v1",
	}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("annotations\n got  %v\n want %v", got, want)
	}
}

func TestRollbackDeploymentToCurrentTemplateIsRefused(t *testing.T) {
	// A pod-template-hash label in the live template does not make it a
	// different template.
	client := newAppsClient(
		deploymentObject(withHash(podTemplateV1(), "h1"), nil, nil),
		deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), nil),
	)
	err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), "Deployment", "prod", "web", 1)
	if !errors.Is(err, rollouts.ErrTemplateUnchanged) {
		t.Fatalf("want ErrTemplateUnchanged, got %v", err)
	}
	if n := len(patchActions(client)); n != 0 {
		t.Fatalf("issued %d patches for a no-op rollback", n)
	}
}

func TestRollbackPausedDeploymentIsRefused(t *testing.T) {
	client := newAppsClient(
		deploymentObject(podTemplateV2(), nil, func(o map[string]any) { o["spec"].(map[string]any)["paused"] = true }),
		deploymentReplicaSet("web-h1", "1", "h1", podTemplateV1(), nil),
	)
	err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), "Deployment", "prod", "web", 1)
	if !errors.Is(err, ErrWorkloadPaused) {
		t.Fatalf("want ErrWorkloadPaused, got %v", err)
	}
	if n := len(patchActions(client)); n != 0 {
		t.Fatalf("issued %d patches for a paused Deployment", n)
	}
}

func TestRestartPausedDeploymentIsRefused(t *testing.T) {
	client := newAppsClient(
		deploymentObject(podTemplateV2(), nil, func(o map[string]any) { o["spec"].(map[string]any)["paused"] = true }),
	)
	err := NewWorkloadManager(client, appsDiscovery()).RestartWorkload(context.Background(), "Deployment", "prod", "web")
	if !errors.Is(err, ErrRestartPaused) {
		t.Fatalf("want ErrRestartPaused, got %v", err)
	}
	if n := len(patchActions(client)); n != 0 {
		t.Fatalf("issued %d patches for a paused Deployment", n)
	}
}

func TestRestartRunningDeploymentPatchesTemplate(t *testing.T) {
	client := newAppsClient(deploymentObject(podTemplateV2(), nil, nil))
	if err := NewWorkloadManager(client, appsDiscovery()).RestartWorkload(context.Background(), "Deployment", "prod", "web"); err != nil {
		t.Fatalf("restart: %v", err)
	}
	if n := len(patchActions(client)); n != 1 {
		t.Fatalf("issued %d patches, want 1", n)
	}
}

func controllerRevisionObject(owner, ownerKind, name string, revision int64, template map[string]any) *unstructured.Unstructured {
	data := runtime.DeepCopyJSON(template)
	data["$patch"] = "replace"
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       "ControllerRevision",
		"metadata": map[string]any{
			"namespace": "prod", "name": name,
			"ownerReferences": []any{map[string]any{"apiVersion": "apps/v1", "kind": ownerKind, "name": owner, "uid": owner + "-uid", "controller": true}},
		},
		"revision": revision,
		"data":     map[string]any{"spec": map[string]any{"template": data}},
	}}
}

func controllerWorkload(kind, name string, template map[string]any) *unstructured.Unstructured {
	return &unstructured.Unstructured{Object: map[string]any{
		"apiVersion": "apps/v1",
		"kind":       kind,
		"metadata":   map[string]any{"namespace": "prod", "name": name, "uid": name + "-uid", "resourceVersion": "7"},
		"spec":       map[string]any{"template": runtime.DeepCopyJSON(template)},
	}}
}

// StatefulSet and DaemonSet revisions replay the controller's own patch, whose
// template carries $patch: replace, so the template is replaced, not merged.
func TestRollbackControllerRevisionReplacesTemplate(t *testing.T) {
	for _, kind := range []string{"StatefulSet", "DaemonSet"} {
		t.Run(kind, func(t *testing.T) {
			client := newAppsClient(
				controllerWorkload(kind, "db", podTemplateV2()),
				controllerRevisionObject("db", kind, "db-1", 1, podTemplateV1()),
				controllerRevisionObject("db", kind, "db-2", 2, podTemplateV2()),
			)
			var patch []byte
			client.PrependReactor("patch", "*", func(a clienttesting.Action) (bool, runtime.Object, error) {
				pa := a.(clienttesting.PatchAction)
				if pa.GetPatchType() != types.StrategicMergePatchType {
					t.Errorf("patch type %s, want strategic merge", pa.GetPatchType())
				}
				patch = pa.GetPatch()
				return true, nil, nil
			})
			if err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), kind, "prod", "db", 1); err != nil {
				t.Fatalf("RollbackWorkload: %v", err)
			}
			var body map[string]any
			if err := json.Unmarshal(patch, &body); err != nil {
				t.Fatalf("patch not JSON: %v", err)
			}
			if rv, _, _ := unstructured.NestedString(body, "metadata", "resourceVersion"); rv != "7" {
				t.Errorf("patch does not pin resourceVersion 7: %s", patch)
			}

			// Apply it the way the API server does, to the typed object, and
			// check every revision-2 addition is gone.
			live, _ := json.Marshal(controllerWorkload(kind, "db", podTemplateV2()).Object)
			var typed any = &appsv1.StatefulSet{}
			if kind == "DaemonSet" {
				typed = &appsv1.DaemonSet{}
			}
			merged, err := strategicpatch.StrategicMergePatch(live, patch, typed)
			if err != nil {
				t.Fatalf("apply strategic merge patch: %v", err)
			}
			var result map[string]any
			if err := json.Unmarshal(merged, &result); err != nil {
				t.Fatal(err)
			}
			got, _, _ := unstructured.NestedMap(result, "spec", "template")
			if !reflect.DeepEqual(got, podTemplateV1()) {
				gotJSON, _ := json.Marshal(got)
				t.Errorf("template after rollback is not revision 1: %s", gotJSON)
			}
		})
	}
}

func TestRollbackControllerRevisionToCurrentTemplateIsRefused(t *testing.T) {
	client := newAppsClient(
		controllerWorkload("StatefulSet", "db", podTemplateV1()),
		controllerRevisionObject("db", "StatefulSet", "db-1", 1, podTemplateV1()),
	)
	err := NewWorkloadManager(client, appsDiscovery()).RollbackWorkload(context.Background(), "StatefulSet", "prod", "db", 1)
	if !errors.Is(err, rollouts.ErrTemplateUnchanged) {
		t.Fatalf("want ErrTemplateUnchanged, got %v", err)
	}
	if n := len(patchActions(client)); n != 0 {
		t.Fatalf("issued %d patches for a no-op rollback", n)
	}
}