Files
cozystack/internal/controller/applicationdefinition_controller_test.go
T
IvanHunters 1ed32aec58 fix(controller): roll cozystack-api when a release annotation changes
The ApplicationDefinition config hash was built from name and spec alone, so
a definition whose whole change is a release.cozystack.io/* annotation left
the hash untouched and cozystack-api kept running with the configuration it
read at its last start-up. Those annotations are only ever read there, once,
in buildResourceFromCRD, which makes the config-hash rollout the single
mechanism that delivers a change in one: without it the annotation is inert
until the api pod restarts for an unrelated reason, and an inert annotation
is indistinguishable from one that was never delivered.

Hash the release.cozystack.io/* annotations alongside the spec. Only that
prefix, because kubectl and Helm rewrite annotations of their own on every
apply and hashing those would roll the api Deployment on every reconcile.

Assisted-by: LLM
Signed-off-by: IvanHunters <xorokhotnikov@gmail.com>
2026-09-23 20:18:05 +03:00

295 lines
9.9 KiB
Go

package controller
import (
"context"
"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)
}
}