Ctrl K
ComunidadGuías…

internal/lineagecontrollerwebhook/webhook_test.go

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

Archivo original ↗Preguntar a KiBot
package lineagecontrollerwebhook

import (
	"context"
	"encoding/json"
	"sort"
	"strings"
	"testing"

	schedulerapi "github.com/cozystack/cozystack-scheduler/pkg/apis/v1alpha1"
	cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
	corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1"
	admissionv1 "k8s.io/api/admission/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/meta"
	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"
	dynamicfake "k8s.io/client-go/dynamic/fake"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

const podWithNumericFields = `{
  "apiVersion": "v1",
  "kind": "Pod",
  "metadata": {
    "name": "harbor-demo-core-0",
    "namespace": "tenant-demo",
    "labels": {"helm.toolkit.fluxcd.io/name": "harbor-demo"},
    "annotations": {"existing": "kept"}
  },
  "spec": {
    "terminationGracePeriodSeconds": 30,
    "activeDeadlineSeconds": 9007199254740993,
    "containers": [{
      "name": "core",
      "image": "goharbor/harbor-core:v2.11.0",
      "ports": [{"containerPort": 8080, "protocol": "TCP"}],
      "resources": {
        "limits": {"cpu": "500m", "memory": "512Mi"},
        "requests": {"cpu": "0.1", "memory": "128Mi"}
      }
    }]
  }
}`

func newHandleTestWebhook(t *testing.T) *LineageControllerWebhook {
	t.Helper()
	scheme := newWebhookScheme(t)
	if err := clientgoscheme.AddToScheme(scheme); err != nil {
		t.Fatalf("add client-go scheme: %v", err)
	}
	ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{
		Name:   "tenant-demo",
		Labels: map[string]string{schedulerapi.SchedulingClassLabel: "gpu"},
	}}

	hrGVK := schema.GroupVersionKind{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"}
	appGVK := schema.GroupVersionKind{Group: "apps.cozystack.io", Version: "v1alpha1", Kind: "Harbor"}
	scGVK := schema.GroupVersionKind{Group: schedulerapi.Group, Version: schedulerapi.Version, Kind: "SchedulingClass"}
	mapper := meta.NewDefaultRESTMapper(nil)
	mapper.Add(hrGVK, meta.RESTScopeNamespace)
	mapper.Add(appGVK, meta.RESTScopeNamespace)

	obj := func(gvk schema.GroupVersionKind, namespace, name string, labels map[string]string) *unstructured.Unstructured {
		u := &unstructured.Unstructured{}
		u.SetGroupVersionKind(gvk)
		u.SetNamespace(namespace)
		u.SetName(name)
		u.SetLabels(labels)
		return u
	}
	dyn := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(),
		obj(hrGVK, "tenant-demo", "harbor-demo", map[string]string{
			ManagerKindKey:  "Harbor",
			ManagerGroupKey: "apps.cozystack.io",
			ManagerNameKey:  "demo",
		}),
		obj(appGVK, "tenant-demo", "demo", nil),
		obj(scGVK, "", "gpu", nil),
	)

	w := &LineageControllerWebhook{
		Client:    fake.NewClientBuilder().WithScheme(scheme).WithObjects(ns).Build(),
		Scheme:    scheme,
		dynClient: dyn,
		mapper:    mapper,
	}
	w.initConfig()
	return w
}

// TestHandle_PatchTouchesOnlyLineageAndScheduling pins that the JSON
// patch computed from the re-marshalled Pod contains the lineage labels
// and scheduler fields and nothing else: numbers the webhook never
// touched, including an int64 beyond float64 precision, must not show
// up as replace operations.
func TestHandle_PatchTouchesOnlyLineageAndScheduling(t *testing.T) {
	w := newHandleTestWebhook(t)

	resp := w.Handle(context.Background(), admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
		Kind:      metav1.GroupVersionKind{Version: "v1", Kind: "Pod"},
		Namespace: "tenant-demo",
		Name:      "harbor-demo-core-0",
		Operation: admissionv1.Create,
		Object:    runtime.RawExtension{Raw: []byte(podWithNumericFields)},
	}})

	if !resp.Allowed {
		t.Fatalf("expected allowed response, got %+v", resp.Result)
	}

	want := map[string]any{
		"add /metadata/labels/internal.cozystack.io~1managed-by-cozystack":   "true",
		"add /metadata/labels/apps.cozystack.io~1application.group":          "apps.cozystack.io",
		"add /metadata/labels/apps.cozystack.io~1application.kind":           "Harbor",
		"add /metadata/labels/apps.cozystack.io~1application.name":           "demo",
		"add /metadata/labels/internal.cozystack.io~1tenantresource":         "false",
		"add /metadata/annotations/scheduler.cozystack.io~1scheduling-class": "gpu",
		"add /metadata/labels/scheduler.cozystack.io~1scheduling-class":      "gpu",
		"add /spec/schedulerName":                                            schedulerapi.SchedulerName,
	}

	got := map[string]any{}
	var keys []string
	for _, p := range resp.Patches {
		k := p.Operation + " " + p.Path
		got[k] = p.Value
		keys = append(keys, k)
	}
	sort.Strings(keys)
	if len(resp.Patches) != len(want) {
		t.Errorf("expected %d patch operations, got %d: %v", len(want), len(resp.Patches), keys)
	}
	for k, v := range want {
		if got[k] != v {
			t.Errorf("patch %q = %#v, want %#v", k, got[k], v)
		}
	}
	for _, p := range resp.Patches {
		if _, ok := want[p.Operation+" "+p.Path]; !ok {
			t.Errorf("unexpected patch operation %s %s = %#v", p.Operation, p.Path, p.Value)
		}
	}
}

// TestHandle_ReadmissionShortCircuit pins which UPDATEs skip the owner walk.
// Every object points at the harbor-demo HelmRelease but names a stale
// application, so an object that goes through the walk gets its labels
// rewritten, and one that skips it gets no patch at all.
func TestHandle_ReadmissionShortCircuit(t *testing.T) {
	fullLabels := func() map[string]string {
		return map[string]string{
			"helm.toolkit.fluxcd.io/name":       "harbor-demo",
			ManagedObjectKey:                    "true",
			ManagerGroupKey:                     "apps.cozystack.io",
			ManagerKindKey:                      "Harbor",
			ManagerNameKey:                      "stale",
			corev1alpha1.TenantResourceLabelKey: "false",
		}
	}
	escape := func(k string) string {
		return "/metadata/labels/" + strings.ReplaceAll(k, "/", "~1")
	}

	tests := []struct {
		name   string
		kind   string
		labels func() map[string]string
		// wantPatches lists "op path" entries that must appear; nil means the
		// response must carry no patch at all.
		wantPatches []string
	}{
		{
			name:   "secret with every lineage label skips the walk",
			kind:   "Secret",
			labels: fullLabels,
		},
		{
			name:        "pod with every lineage label still gets the scheduling fields",
			kind:        "Pod",
			labels:      fullLabels,
			wantPatches: []string{"add /spec/schedulerName", "replace " + escape(ManagerNameKey)},
		},
		{
			name: "secret marked unmanaged goes through the walk",
			kind: "Secret",
			labels: func() map[string]string {
				l := fullLabels()
				l[ManagedObjectKey] = "false"
				return l
			},
			wantPatches: []string{"replace " + escape(ManagedObjectKey), "replace " + escape(ManagerNameKey)},
		},
		{
			name: "secret missing the application kind goes through the walk",
			kind: "Secret",
			labels: func() map[string]string {
				l := fullLabels()
				delete(l, ManagerKindKey)
				return l
			},
			wantPatches: []string{"add " + escape(ManagerKindKey), "replace " + escape(ManagerNameKey)},
		},
		{
			name: "secret missing the tenantresource label goes through the walk",
			kind: "Secret",
			labels: func() map[string]string {
				l := fullLabels()
				delete(l, corev1alpha1.TenantResourceLabelKey)
				return l
			},
			wantPatches: []string{"add " + escape(corev1alpha1.TenantResourceLabelKey), "replace " + escape(ManagerNameKey)},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := newHandleTestWebhook(t)
			w.config.Store(&runtimeConfig{appCRDMap: map[appRef]*cozyv1alpha1.ApplicationDefinition{
				{"apps.cozystack.io", "Harbor"}: {Spec: cozyv1alpha1.ApplicationDefinitionSpec{
					Application: cozyv1alpha1.ApplicationDefinitionApplication{Kind: "Harbor"},
				}},
			}})

			obj := &unstructured.Unstructured{}
			obj.SetAPIVersion("v1")
			obj.SetKind(tt.kind)
			obj.SetNamespace("tenant-demo")
			obj.SetName("harbor-demo-core")
			obj.SetLabels(tt.labels())
			if tt.kind == "Pod" {
				obj.Object["spec"] = map[string]any{"containers": []any{map[string]any{"name": "core", "image": "core"}}}
			}
			raw, err := json.Marshal(obj)
			if err != nil {
				t.Fatalf("marshal object: %v", err)
			}

			resp := w.Handle(context.Background(), admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
				Kind:      metav1.GroupVersionKind{Version: "v1", Kind: tt.kind},
				Namespace: "tenant-demo",
				Name:      "harbor-demo-core",
				Operation: admissionv1.Update,
				Object:    runtime.RawExtension{Raw: raw},
				OldObject: runtime.RawExtension{Raw: raw},
			}})
			if !resp.Allowed {
				t.Fatalf("expected allowed response, got %+v", resp.Result)
			}

			got := map[string]bool{}
			var keys []string
			for _, p := range resp.Patches {
				k := p.Operation + " " + p.Path
				got[k] = true
				keys = append(keys, k)
			}
			sort.Strings(keys)
			if tt.wantPatches == nil {
				if len(resp.Patches) != 0 {
					t.Errorf("expected no patch, got %v", keys)
				}
				return
			}
			for _, k := range tt.wantPatches {
				if !got[k] {
					t.Errorf("missing patch %q, got %v", k, keys)
				}
			}
		})
	}
}

const testSchedulingClass = "co-region-test"

// newSchedulingClassCR builds an unstructured SchedulingClass CR the fake
// dynamic client can serve.
func newSchedulingClassCR(name string) *unstructured.Unstructured {
	u := &unstructured.Unstructured{}
	u.SetGroupVersionKind(schema.GroupVersionKind{
		Group:   schedulerapi.Group,
		Version: schedulerapi.Version,
		Kind:    "SchedulingClass",
	})
	u.SetName(name)
	return u
}

// newWebhookTestEnv builds a LineageControllerWebhook wired with fakes,
// pre-populated with a Namespace carrying the scheduling-class label and a
// SchedulingClass CR by that name.
func newWebhookTestEnv(t *testing.T, namespace string, classOnNamespace string, classExists bool) *LineageControllerWebhook {
	t.Helper()

	testScheme := runtime.NewScheme()
	_ = clientgoscheme.AddToScheme(testScheme)

	ns := &corev1.Namespace{
		ObjectMeta: metav1.ObjectMeta{
			Name: namespace,
		},
	}
	if classOnNamespace != "" {
		ns.Labels = map[string]string{
			schedulerapi.SchedulingClassLabel: classOnNamespace,
		}
	}

	c := fake.NewClientBuilder().
		WithScheme(testScheme).
		WithObjects(ns).
		Build()

	gvr := schema.GroupVersionResource{
		Group:    schedulerapi.Group,
		Version:  schedulerapi.Version,
		Resource: schedulerapi.Resource,
	}

	dynObjs := []runtime.Object{}
	if classExists {
		dynObjs = append(dynObjs, newSchedulingClassCR(classOnNamespace))
	}
	dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(
		testScheme,
		map[schema.GroupVersionResource]string{gvr: "SchedulingClassList"},
		dynObjs...,
	)

	return &LineageControllerWebhook{
		Client:    c,
		Scheme:    testScheme,
		dynClient: dyn,
	}
}

// newPod returns a minimal unstructured Pod for mutation tests.
func newPod(namespace, name string) *unstructured.Unstructured {
	u := &unstructured.Unstructured{}
	u.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Pod"})
	u.SetNamespace(namespace)
	u.SetName(name)
	return u
}

func TestApplySchedulingClass_SetsLabelAnnotationAndSchedulerName(t *testing.T) {
	ctx := context.Background()
	h := newWebhookTestEnv(t, "tenant-acme", testSchedulingClass, true)
	pod := newPod("tenant-acme", "db-0")

	if err := h.applySchedulingClass(ctx, pod, nil, "tenant-acme"); err != nil {
		t.Fatalf("applySchedulingClass returned error: %v", err)
	}

	gotLabel := pod.GetLabels()[schedulerapi.SchedulingClassLabel]
	if gotLabel != testSchedulingClass {
		t.Errorf("expected pod label %s=%s, got %q", schedulerapi.SchedulingClassLabel, testSchedulingClass, gotLabel)
	}

	gotAnnotation := pod.GetAnnotations()[schedulerapi.SchedulingClassAnnotation]
	if gotAnnotation != testSchedulingClass {
		t.Errorf("expected pod annotation %s=%s, got %q", schedulerapi.SchedulingClassAnnotation, testSchedulingClass, gotAnnotation)
	}

	gotSched, _, _ := unstructured.NestedString(pod.Object, "spec", "schedulerName")
	if gotSched != schedulerapi.SchedulerName {
		t.Errorf("expected pod spec.schedulerName=%s, got %q", schedulerapi.SchedulerName, gotSched)
	}
}

func TestApplySchedulingClass_PreservesExistingLabels(t *testing.T) {
	ctx := context.Background()
	h := newWebhookTestEnv(t, "tenant-acme", testSchedulingClass, true)
	pod := newPod("tenant-acme", "db-0")
	pod.SetLabels(map[string]string{
		"app.kubernetes.io/name": "postgres",
		"role":                   "primary",
	})

	if err := h.applySchedulingClass(ctx, pod, nil, "tenant-acme"); err != nil {
		t.Fatalf("applySchedulingClass returned error: %v", err)
	}

	labels := pod.GetLabels()
	if labels[schedulerapi.SchedulingClassLabel] != testSchedulingClass {
		t.Errorf("scheduling-class label not set")
	}
	if labels["app.kubernetes.io/name"] != "postgres" {
		t.Errorf("existing label app.kubernetes.io/name was clobbered: %q", labels["app.kubernetes.io/name"])
	}
	if labels["role"] != "primary" {
		t.Errorf("existing label role was clobbered: %q", labels["role"])
	}
}

func TestApplySchedulingClass_NonPodIsNoOp(t *testing.T) {
	ctx := context.Background()
	h := newWebhookTestEnv(t, "tenant-acme", testSchedulingClass, true)

	svc := &unstructured.Unstructured{}
	svc.SetGroupVersionKind(schema.GroupVersionKind{Version: "v1", Kind: "Service"})
	svc.SetNamespace("tenant-acme")
	svc.SetName("svc-0")

	if err := h.applySchedulingClass(ctx, svc, nil, "tenant-acme"); err != nil {
		t.Fatalf("applySchedulingClass returned error: %v", err)
	}

	if _, ok := svc.GetLabels()[schedulerapi.SchedulingClassLabel]; ok {
		t.Errorf("non-Pod object should not be mutated")
	}
	if _, ok := svc.GetAnnotations()[schedulerapi.SchedulingClassAnnotation]; ok {
		t.Errorf("non-Pod object should not be mutated")
	}
}

func TestApplySchedulingClass_NoClassOnNamespaceIsNoOp(t *testing.T) {
	ctx := context.Background()
	h := newWebhookTestEnv(t, "tenant-acme", "", false)
	pod := newPod("tenant-acme", "db-0")

	if err := h.applySchedulingClass(ctx, pod, nil, "tenant-acme"); err != nil {
		t.Fatalf("applySchedulingClass returned error: %v", err)
	}

	if _, ok := pod.GetLabels()[schedulerapi.SchedulingClassLabel]; ok {
		t.Errorf("pod label should not be set when namespace has no scheduling-class label")
	}
	if _, ok := pod.GetAnnotations()[schedulerapi.SchedulingClassAnnotation]; ok {
		t.Errorf("pod annotation should not be set when namespace has no scheduling-class label")
	}
	if sched, _, _ := unstructured.NestedString(pod.Object, "spec", "schedulerName"); sched != "" {
		t.Errorf("pod spec.schedulerName should not be set when namespace has no scheduling-class label, got %q", sched)
	}
}

func TestApplySchedulingClass_MissingClassCRSkipsInjection(t *testing.T) {
	ctx := context.Background()
	// Namespace carries the label, but the SchedulingClass CR is NOT created.
	h := newWebhookTestEnv(t, "tenant-acme", testSchedulingClass, false)
	pod := newPod("tenant-acme", "db-0")

	if err := h.applySchedulingClass(ctx, pod, nil, "tenant-acme"); err != nil {
		t.Fatalf("applySchedulingClass returned error: %v", err)
	}

	// Defensive behavior: pod must not be left referencing a non-existent
	// scheduler, otherwise it would stay Pending forever.
	if _, ok := pod.GetLabels()[schedulerapi.SchedulingClassLabel]; ok {
		t.Errorf("pod label should not be set when SchedulingClass CR is missing")
	}
	if _, ok := pod.GetAnnotations()[schedulerapi.SchedulingClassAnnotation]; ok {
		t.Errorf("pod annotation should not be set when SchedulingClass CR is missing")
	}
	if sched, _, _ := unstructured.NestedString(pod.Object, "spec", "schedulerName"); sched != "" {
		t.Errorf("pod spec.schedulerName should not be set when SchedulingClass CR is missing, got %q", sched)
	}
}