Files
cozystack/internal/lineagecontrollerwebhook/appdef_validator_test.go
IvanHunters 0bae19fccc feat(lineage-webhook): refuse a second ApplicationDefinition for a kind
A definition declaring an application kind that another definition already
declares now fails when it is applied, instead of sitting next to the first
one where it has no effect. An update that keeps its kind is allowed, so a
duplicate that predates the check keeps upgrading, and so is a second
definition from the same Helm release, since a release that renames its
definition object creates the new one before it deletes the old one.

The webhook is registered with failurePolicy Ignore: ApplicationDefinitions
ship with the platform itself, so an outage of the webhook must never block
a bootstrap or an upgrade, and a failure to read the existing definitions
lets the request through with a warning rather than answering with an error,
which Ignore would not cover. It is the feedback layer; which definition
manages a kind is decided by the ownership rule whatever it lets through.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-25 09:39:35 +03:00

125 lines
5.3 KiB
Go

package lineagecontrollerwebhook
import (
"context"
"encoding/json"
"strings"
"testing"
admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
)
func appDef(name, kind string) *cozyv1alpha1.ApplicationDefinition {
d := &cozyv1alpha1.ApplicationDefinition{
TypeMeta: metav1.TypeMeta{APIVersion: "cozystack.io/v1alpha1", Kind: "ApplicationDefinition"},
ObjectMeta: metav1.ObjectMeta{Name: name},
}
d.Spec.Application.Kind = kind
return d
}
func admissionReq(t *testing.T, op admissionv1.Operation, obj, old *cozyv1alpha1.ApplicationDefinition) admission.Request {
t.Helper()
raw, err := json.Marshal(obj)
if err != nil {
t.Fatal(err)
}
req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{Operation: op, Name: obj.Name, Object: runtime.RawExtension{Raw: raw}}}
if old != nil {
oldRaw, err := json.Marshal(old)
if err != nil {
t.Fatal(err)
}
req.OldObject = runtime.RawExtension{Raw: oldRaw}
}
return req
}
func TestAppDefKindValidator(t *testing.T) {
scheme := newWebhookScheme(t)
existing := appDef("redis", "Redis")
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing, appDef("shadow", "Redis")).Build()
v := NewApplicationDefinitionKindValidator(cl, scheme)
cases := []struct {
name string
req admission.Request
allowed bool
}{
{"create with a kind another definition owns is refused", admissionReq(t, admissionv1.Create, appDef("tap-redis", "Redis"), nil), false},
{"create with a free kind is allowed", admissionReq(t, admissionv1.Create, appDef("pg", "Postgres"), nil), true},
{"update of the owner itself is allowed", admissionReq(t, admissionv1.Update, appDef("redis", "Redis"), appDef("redis", "Redis")), true},
// A duplicate that predates the check keeps upgrading; ownership, not
// admission, keeps it from managing the kind's releases.
{"update of a pre-existing duplicate that keeps its kind is allowed", admissionReq(t, admissionv1.Update, appDef("shadow", "Redis"), appDef("shadow", "Redis")), true},
{"update switching to a claimed kind is refused", admissionReq(t, admissionv1.Update, appDef("pg", "Redis"), appDef("pg", "Postgres")), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := v.Handle(context.TODO(), tc.req)
if resp.Allowed != tc.allowed {
t.Fatalf("allowed = %v, want %v (%v)", resp.Allowed, tc.allowed, resp.Result)
}
})
}
}
func helmOwned(d *cozyv1alpha1.ApplicationDefinition, release string) *cozyv1alpha1.ApplicationDefinition {
d.Annotations = map[string]string{"meta.helm.sh/release-name": release, "meta.helm.sh/release-namespace": "cozy-system"}
return d
}
// A Helm release that renames its definition object creates the new one before
// it deletes the old one, so both briefly declare the kind. Refusing that would
// fail the platform upgrade that carries the rename.
func TestAppDefKindValidatorAllowsRenameWithinOneHelmRelease(t *testing.T) {
scheme := newWebhookScheme(t)
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(helmOwned(appDef("redis", "Redis"), "redis-rd")).Build()
v := NewApplicationDefinitionKindValidator(cl, scheme)
if resp := v.Handle(context.TODO(), admissionReq(t, admissionv1.Create, helmOwned(appDef("redis-v2", "Redis"), "redis-rd"), nil)); !resp.Allowed {
t.Fatalf("rename within one Helm release refused: %v", resp.Result)
}
resp := v.Handle(context.TODO(), admissionReq(t, admissionv1.Create, helmOwned(appDef("tap-redis", "Redis"), "tap-redis-rd"), nil))
if resp.Allowed {
t.Fatal("a definition from another Helm release must still be refused")
}
otherNS := helmOwned(appDef("redis-elsewhere", "Redis"), "redis-rd")
otherNS.Annotations["meta.helm.sh/release-namespace"] = "tenant-foo"
if resp := v.Handle(context.TODO(), admissionReq(t, admissionv1.Create, otherNS, nil)); resp.Allowed {
t.Fatal("a release of the same name in another namespace is a different release and must be refused")
}
if !strings.Contains(resp.Result.Message, "redis") {
t.Fatalf("the refusal must name the definition that declares the kind, got %q", resp.Result.Message)
}
}
// The check is feedback, not the guarantee, so failing to read the existing
// definitions must let the request through rather than refuse it: a 500 answer
// is a verdict, which failurePolicy Ignore does not cover.
func TestAppDefKindValidatorFailsOpenOnListError(t *testing.T) {
scheme := newWebhookScheme(t)
cl := interceptor.NewClient(fake.NewClientBuilder().WithScheme(scheme).Build(), interceptor.Funcs{
List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
return errors.NewServiceUnavailable("cache not started")
},
})
v := NewApplicationDefinitionKindValidator(cl, scheme)
resp := v.Handle(context.TODO(), admissionReq(t, admissionv1.Create, appDef("tap-redis", "Redis"), nil))
if !resp.Allowed {
t.Fatalf("a failed read must not refuse the request: %v", resp.Result)
}
if len(resp.Warnings) == 0 {
t.Fatal("letting a request through unchecked must say so in a warning")
}
}