Files
mattia-eleuteri 250f89dc72 feat(scheduler): set scheduling-class label on mutated pods
The lineage-controller-webhook now sets a
scheduler.cozystack.io/scheduling-class label on mutated pods (in
addition to the existing annotation of the same name).

This unlocks SchedulingClass authors writing podAffinity rules whose
labelSelector targets pods across different applications that share
the same class. A natural pattern becomes: define a class whose name
is the affinity-group identifier, attach it to several applications,
and have the scheduler co-locate them at the chosen topology key
(region, zone, hostname).

Behavior preserved when the SchedulingClass CR is missing: pods are
left untouched (no scheduler injection, no label) so they do not stay
Pending on a non-existent scheduler.

Tests cover label/annotation/schedulerName injection, existing label
preservation, non-Pod no-op, no-class no-op, and missing-CR no-op.

Signed-off-by: mattia-eleuteri <mattia@hidora.io>
2026-10-01 05:59:24 +03:00

463 lines
16 KiB
Go

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)
}
}