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