Files
cozystack/internal/lineagecontrollerwebhook/webhook_test.go
T
Aleksei Sviridkin 77b8dc8935 fix(lineage-webhook): keep large integers intact when patching
With no decoder injected, the webhook decodes the admitted object with
encoding/json, which turns every number into float64, so an int64
above 2^53 comes back rounded. jsonpatch up to v2.4 compared both
sides as float64 and hid this; from v2.5 it compares json.Number, and
the webhook would emit a replace that silently rewrites such a field.
Decode with apimachinery's json, which keeps integers as int64, and
pin that the patch touches only the lineage labels and scheduling.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-09-24 16:55:03 +03:00

144 lines
4.9 KiB
Go

package lineagecontrollerwebhook
import (
"context"
"sort"
"testing"
schedulerapi "github.com/cozystack/cozystack-scheduler/pkg/apis/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 /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)
}
}
}