mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-24 03:57:23 +09:00
Nothing enforced that spec.application.kind is unique, and each consumer that maps a kind back to a definition resolved a duplicate its own way: the chartRef reconciler let every definition rewrite every release of its kind, the lineage webhook and the CA projection reconciler took whichever came first in an unordered list, and cozystack-api registered one resource per definition. A second definition declaring a kind already in use could therefore repoint that kind's tenant releases at its own artifact. Add one ownership rule, the definition created first owns the kind with ties broken by name, and route every consumer through it. A definition that arrives later with a kind already in use no longer manages that kind's releases whatever it is named: the chartRef reconciler skips it with a Warning event and re-reconciles the next owner when the current one goes, cozystack-api registers the owner only and logs the rest, its restart hash moves when ownership moves, and the lineage and CA projection lookups resolve the owner. Assisted-by: LLM Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
345 lines
12 KiB
Go
345 lines
12 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
|
"github.com/cozystack/cozystack/pkg/config"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
)
|
|
|
|
func newAppDefScheme(t *testing.T) *runtime.Scheme {
|
|
t.Helper()
|
|
scheme := runtime.NewScheme()
|
|
if err := cozyv1alpha1.AddToScheme(scheme); err != nil {
|
|
t.Fatalf("add cozyv1alpha1: %v", err)
|
|
}
|
|
if err := appsv1.AddToScheme(scheme); err != nil {
|
|
t.Fatalf("add appsv1: %v", err)
|
|
}
|
|
return scheme
|
|
}
|
|
|
|
// TestApplicationDefinition_NoEventsIsNoop pins the early-exit path:
|
|
// when no event has been recorded yet (lastEvent zero), Reconcile must
|
|
// return an empty Result without touching the deployment or computing
|
|
// hashes.
|
|
func TestApplicationDefinition_NoEventsIsNoop(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
|
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
}
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.RequeueAfter != 0 {
|
|
t.Fatalf("expected empty result, got %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_WithinDebounceRequeues pins the debounce-window
|
|
// path: an event was just recorded, within the debounce window. Reconcile
|
|
// must return a RequeueAfter equal to the remaining wait, no work done.
|
|
func TestApplicationDefinition_WithinDebounceRequeues(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
|
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
}
|
|
r.lastEvent = time.Now()
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.RequeueAfter <= 0 {
|
|
t.Fatalf("expected positive RequeueAfter, got %+v", res)
|
|
}
|
|
if res.RequeueAfter > 5*time.Second {
|
|
t.Fatalf("RequeueAfter exceeds debounce window: %v", res.RequeueAfter)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_AlreadyHandledIsNoop pins the lastHandled
|
|
// gate: lastHandled >= lastEvent means this event has already been
|
|
// processed; Reconcile is a no-op.
|
|
func TestApplicationDefinition_AlreadyHandledIsNoop(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
|
|
|
now := time.Now().Add(-10 * time.Second)
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
lastEvent: now,
|
|
lastHandled: now,
|
|
}
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if res.RequeueAfter != 0 {
|
|
t.Fatalf("expected empty result, got %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_MissingDeploymentIgnored pins the
|
|
// IgnoreNotFound path: when the cozystack-api Deployment is absent
|
|
// (initial install in progress, etc.), Reconcile must not return an
|
|
// error.
|
|
func TestApplicationDefinition_MissingDeploymentIgnored(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
|
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
lastEvent: time.Now().Add(-10 * time.Second),
|
|
}
|
|
|
|
res, err := r.Reconcile(context.TODO(), reconcile.Request{})
|
|
if err != nil {
|
|
t.Fatalf("expected nil error on missing deployment, got %v", err)
|
|
}
|
|
if res.RequeueAfter != 0 {
|
|
t.Fatalf("expected empty result, got %+v", res)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_HashChangePatchesDeployment pins the happy
|
|
// path: an event has elapsed past debounce, the deployment exists with
|
|
// either no hash annotation or a stale hash, Reconcile must patch the
|
|
// pod template's cozystack.io/config-hash annotation.
|
|
func TestApplicationDefinition_HashChangePatchesDeployment(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
|
|
dep := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "cozystack-api",
|
|
Namespace: "cozy-system",
|
|
},
|
|
}
|
|
appDef := &cozyv1alpha1.ApplicationDefinition{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "harbor"},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(dep, appDef).
|
|
Build()
|
|
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
lastEvent: time.Now().Add(-10 * time.Second),
|
|
}
|
|
|
|
if _, err := r.Reconcile(context.TODO(), reconcile.Request{}); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
got := &appsv1.Deployment{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "cozystack-api", Namespace: "cozy-system"}, got); err != nil {
|
|
t.Fatalf("get deployment: %v", err)
|
|
}
|
|
hash, ok := got.Spec.Template.Annotations["cozystack.io/config-hash"]
|
|
if !ok || hash == "" {
|
|
t.Fatalf("expected config-hash annotation set, got %q (present=%v)", hash, ok)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_HashUnchangedSkipsPatch pins the no-op
|
|
// path: same hash already on the deployment means no patch is issued.
|
|
// We seed the deployment with the hash that the reconciler will compute
|
|
// and verify the lastHandled marker advances without changing the
|
|
// annotation.
|
|
func TestApplicationDefinition_HashUnchangedSkipsPatch(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
|
|
appDef := &cozyv1alpha1.ApplicationDefinition{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "harbor"},
|
|
}
|
|
|
|
// First, run the reconciler against a deployment with empty annotation
|
|
// to capture the hash it computes.
|
|
dep := &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "cozystack-api",
|
|
Namespace: "cozy-system",
|
|
},
|
|
}
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(dep, appDef).
|
|
Build()
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fakeClient,
|
|
Scheme: scheme,
|
|
Debounce: 5 * time.Second,
|
|
lastEvent: time.Now().Add(-10 * time.Second),
|
|
}
|
|
if _, err := r.Reconcile(context.TODO(), reconcile.Request{}); err != nil {
|
|
t.Fatalf("first reconcile: %v", err)
|
|
}
|
|
|
|
got := &appsv1.Deployment{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "cozystack-api", Namespace: "cozy-system"}, got); err != nil {
|
|
t.Fatalf("get deployment: %v", err)
|
|
}
|
|
expectedHash := got.Spec.Template.Annotations["cozystack.io/config-hash"]
|
|
|
|
// Now drive a second reconcile cycle with a fresh event but the same
|
|
// state — hash will match, no patch should be needed. We can only
|
|
// verify the no-op via lastHandled advancing; the patch itself is a
|
|
// no-op so the annotation stays identical.
|
|
r.lastEvent = time.Now().Add(-10 * time.Second)
|
|
r.lastHandled = time.Time{}
|
|
if _, err := r.Reconcile(context.TODO(), reconcile.Request{}); err != nil {
|
|
t.Fatalf("second reconcile: %v", err)
|
|
}
|
|
|
|
again := &appsv1.Deployment{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "cozystack-api", Namespace: "cozy-system"}, again); err != nil {
|
|
t.Fatalf("get deployment after second reconcile: %v", err)
|
|
}
|
|
if again.Spec.Template.Annotations["cozystack.io/config-hash"] != expectedHash {
|
|
t.Fatalf("hash mutated when it should have been stable: was %q now %q",
|
|
expectedHash, again.Spec.Template.Annotations["cozystack.io/config-hash"])
|
|
}
|
|
if r.lastHandled.IsZero() {
|
|
t.Fatalf("expected lastHandled advanced after no-op reconcile")
|
|
}
|
|
}
|
|
|
|
// hashWithAnnotations computes the config hash for a single ApplicationDefinition
|
|
// carrying the given metadata annotations.
|
|
func hashWithAnnotations(t *testing.T, annotations map[string]string) string {
|
|
t.Helper()
|
|
|
|
scheme := newAppDefScheme(t)
|
|
appDef := &cozyv1alpha1.ApplicationDefinition{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "vm-instance", Annotations: annotations},
|
|
}
|
|
r := &ApplicationDefinitionReconciler{
|
|
Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(appDef).Build(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
hash, err := r.computeConfigHash(context.TODO())
|
|
if err != nil {
|
|
t.Fatalf("computeConfigHash: %v", err)
|
|
}
|
|
return hash
|
|
}
|
|
|
|
// TestApplicationDefinition_HashCoversReleaseAnnotations pins that a change
|
|
// confined to a release.cozystack.io/* annotation moves the hash. cozystack-api
|
|
// reads those annotations once, at start-up (pkg/cmd/server/start.go), so the
|
|
// config-hash rollout is the only thing that makes such a change take effect.
|
|
// A hash built from spec alone leaves the annotation inert on an existing
|
|
// cluster until the api pod happens to restart for an unrelated reason, which
|
|
// is indistinguishable from the change never having been delivered.
|
|
func TestApplicationDefinition_HashCoversReleaseAnnotations(t *testing.T) {
|
|
bare := hashWithAnnotations(t, nil)
|
|
annotated := hashWithAnnotations(t, map[string]string{
|
|
config.HelmInstallDisableWaitAnnotation: "true",
|
|
})
|
|
|
|
if bare == annotated {
|
|
t.Fatalf("hash ignores %s: %q for both", config.HelmInstallDisableWaitAnnotation, bare)
|
|
}
|
|
|
|
flipped := hashWithAnnotations(t, map[string]string{
|
|
config.HelmInstallDisableWaitAnnotation: "false",
|
|
})
|
|
if flipped == annotated {
|
|
t.Fatalf("hash ignores the annotation's value: %q for both true and false", annotated)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_HashIgnoresUnrelatedAnnotations pins the other half:
|
|
// only the release.cozystack.io/ prefix counts. kubectl and Flux rewrite their
|
|
// own annotations on every apply, and hashing those would roll out cozystack-api
|
|
// on every reconcile of every ApplicationDefinition.
|
|
func TestApplicationDefinition_HashIgnoresUnrelatedAnnotations(t *testing.T) {
|
|
bare := hashWithAnnotations(t, nil)
|
|
noisy := hashWithAnnotations(t, map[string]string{
|
|
"kubectl.kubernetes.io/last-applied-configuration": `{"metadata":{"name":"vm-instance"}}`,
|
|
"meta.helm.sh/release-name": "vm-instance-rd",
|
|
})
|
|
|
|
if bare != noisy {
|
|
t.Fatalf("unrelated annotations moved the hash: %q vs %q", bare, noisy)
|
|
}
|
|
}
|
|
|
|
// TestApplicationDefinition_HashTracksKindOwnership pins that the config hash
|
|
// that restarts cozystack-api moves when ownership of a kind moves, even though
|
|
// no spec changed: ownership depends on creation time, which the spec does not
|
|
// carry. It also pins that a cluster with no duplicate kinds hashes exactly as
|
|
// before ownership existed, so the upgrade itself does not restart the API.
|
|
func TestApplicationDefinition_HashTracksKindOwnership(t *testing.T) {
|
|
scheme := newAppDefScheme(t)
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
mk := func(name string, at time.Time) *cozyv1alpha1.ApplicationDefinition {
|
|
d := &cozyv1alpha1.ApplicationDefinition{ObjectMeta: metav1.ObjectMeta{Name: name, CreationTimestamp: metav1.NewTime(at)}}
|
|
d.Spec.Application.Kind = "Harbor"
|
|
return d
|
|
}
|
|
hash := func(objs ...*cozyv1alpha1.ApplicationDefinition) string {
|
|
b := fake.NewClientBuilder().WithScheme(scheme)
|
|
for _, o := range objs {
|
|
b = b.WithObjects(o)
|
|
}
|
|
r := &ApplicationDefinitionReconciler{Client: b.Build(), Scheme: scheme}
|
|
h, err := r.computeConfigHash(context.TODO())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Same names, same specs; only which one was created first differs.
|
|
harborOwns := hash(mk("harbor", base), mk("tap-harbor", base.Add(time.Hour)))
|
|
tapOwns := hash(mk("harbor", base.Add(2*time.Hour)), mk("tap-harbor", base.Add(time.Hour)))
|
|
if harborOwns == tapOwns {
|
|
t.Fatal("hash did not change when ownership of the kind moved to another definition")
|
|
}
|
|
|
|
single := mk("harbor", base)
|
|
view, err := json.Marshal([]struct {
|
|
Name string `json:"name"`
|
|
Spec cozyv1alpha1.ApplicationDefinitionSpec `json:"spec"`
|
|
}{{Name: single.Name, Spec: single.Spec}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sum := sha256.Sum256(view)
|
|
if got := hash(single); got != hex.EncodeToString(sum[:]) {
|
|
t.Fatal("hash of a cluster without duplicate kinds changed format, which would restart cozystack-api on upgrade")
|
|
}
|
|
}
|