From 4e26280d90be77f6f04352cac991c163bb7b3666 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 4 Aug 2026 08:42:42 +0200 Subject: [PATCH 01/10] feat: add rhdh.redhat.com/idle annotation support for idling/waking instances When the `rhdh.redhat.com/idle: "true"` annotation is set on a Backstage CR, the operator overrides replicas to 0 on the Backstage Deployment and (if enabled) the local DB StatefulSet. Removing the annotation triggers a wake-up, restoring replicas to the user's patched value or defaulting to 1. This enables external controllers (e.g. Dev Sandbox idler) to scale RHDH instances to zero without conflicting with the operator's Server-Side Apply field ownership of the replicas field. State machine: - Normal: replicas not managed by idle logic (HPA-friendly) - Idle (annotation="true"): replicas forced to 0, status reason set to "Idled" - Wake (annotation removed, was Idled): replicas restored, then released Ref: RHIDP-15995 Assisted-by: Claude --- api/current-types.go | 1 + api/v1alpha5/backstage_types.go | 1 + integration_tests/idle_test.go | 158 ++++++++++++++++++++ internal/controller/backstage_controller.go | 15 ++ internal/controller/backstage_status.go | 9 ++ pkg/model/deployable.go | 2 + pkg/model/deployment_obj.go | 4 + pkg/model/deployment_test.go | 135 +++++++++++++++++ pkg/model/runtime.go | 36 +++++ pkg/model/statefulset_obj.go | 4 + 10 files changed, 365 insertions(+) create mode 100644 integration_tests/idle_test.go diff --git a/api/current-types.go b/api/current-types.go index 9f1a5d1b6..37183fbf0 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -53,6 +53,7 @@ const ( BackstageConditionReasonDeployed BackstageConditionReason = bsv1.BackstageConditionReasonDeployed BackstageConditionReasonFailed BackstageConditionReason = bsv1.BackstageConditionReasonFailed BackstageConditionReasonInProgress BackstageConditionReason = bsv1.BackstageConditionReasonInProgress + BackstageConditionReasonIdled BackstageConditionReason = bsv1.BackstageConditionReasonIdled ) // AddToScheme adds the current API version's types to the scheme. diff --git a/api/v1alpha5/backstage_types.go b/api/v1alpha5/backstage_types.go index d66ed7977..ee9a0ed44 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -16,6 +16,7 @@ const ( BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" + BackstageConditionReasonIdled BackstageConditionReason = "Idled" ) // BackstageSpec defines the desired state of Backstage diff --git a/integration_tests/idle_test.go b/integration_tests/idle_test.go new file mode 100644 index 000000000..7b037fa41 --- /dev/null +++ b/integration_tests/idle_test.go @@ -0,0 +1,158 @@ +package integration_tests + +import ( + "context" + "fmt" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/redhat-developer/rhdh-operator/api" + "github.com/redhat-developer/rhdh-operator/pkg/model" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = When("backstage idle annotation", func() { + + var ( + ctx context.Context + ns string + ) + + BeforeEach(func() { + ctx = context.Background() + ns = createNamespace(ctx) + }) + + AfterEach(func() { + deleteNamespace(ctx, ns) + }) + + It("idles and wakes the instance", func() { + backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") + + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy).NotTo(BeNil()) + }, time.Minute, time.Second).Should(Succeed()) + + By("setting idle annotation to true") + bs := &api.Backstage{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + if bs.Annotations == nil { + bs.Annotations = map[string]string{} + } + bs.Annotations[model.IdleAnnotation] = "true" + Expect(k8sClient.Update(ctx, bs)).To(Succeed()) + + _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + By("verifying deployment replicas=0") + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + Expect(err).ShouldNot(HaveOccurred()) + Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + + By("verifying DB StatefulSet replicas=0") + ss := &appsv1.StatefulSet{} + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) + Expect(err).ShouldNot(HaveOccurred()) + Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(0))) + + By("verifying status condition is Idled") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + Expect(bs.Status.Conditions).To(HaveLen(1)) + Expect(bs.Status.Conditions[0].Reason).To(Equal("Idled")) + Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + + By("removing idle annotation (wake)") + delete(bs.Annotations, model.IdleAnnotation) + Expect(k8sClient.Update(ctx, bs)).To(Succeed()) + + _, err = NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + By("verifying deployment replicas restored to 1") + deploy, err = backstageDeployment(ctx, k8sClient, ns, backstageName) + Expect(err).ShouldNot(HaveOccurred()) + Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + + By("verifying DB StatefulSet replicas restored to 1") + err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) + Expect(err).ShouldNot(HaveOccurred()) + Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + + By("verifying status condition is no longer Idled") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + }) + + It("wakes with user-specified replicas from deployment patch", func() { + backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") + + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy).NotTo(BeNil()) + }, time.Minute, time.Second).Should(Succeed()) + + By("setting idle annotation") + bs := &api.Backstage{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + if bs.Annotations == nil { + bs.Annotations = map[string]string{} + } + bs.Annotations[model.IdleAnnotation] = "true" + Expect(k8sClient.Update(ctx, bs)).To(Succeed()) + + _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + By("verifying deployment is idled") + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + Expect(err).ShouldNot(HaveOccurred()) + Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + + By("removing idle annotation and adding replicas via deployment patch") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + delete(bs.Annotations, model.IdleAnnotation) + Expect(k8sClient.Update(ctx, bs)).To(Succeed()) + + _, err = NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, + }) + Expect(err).To(Not(HaveOccurred())) + + By("verifying deployment replicas restored to 1 (default, no patch)") + deploy, err = backstageDeployment(ctx, k8sClient, ns, backstageName) + Expect(err).ShouldNot(HaveOccurred()) + Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + }) + + It("does not touch replicas when not idled and never was", func() { + backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") + + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy).NotTo(BeNil()) + }, time.Minute, time.Second).Should(Succeed()) + + By("verifying status was never Idled") + bs := &api.Backstage{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + }) +}) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 4ae61b061..7f6296109 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -103,6 +103,16 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to initialize backstage model", err) } + // Handle idle/wake annotation + isIdle := backstage.GetAnnotations()[model.IdleAnnotation] == "true" + wasIdled := hasConditionReason(&backstage, api.BackstageConditionReasonIdled) + + if isIdle { + bsModel.SetIdleReplicas() + } else if wasIdled { + bsModel.WakeReplicas() + } + // Apply the plugin dependencies if err := r.applyPluginDeps(ctx, backstage, bsModel); err != nil { return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply plugin dependencies", err) @@ -114,6 +124,11 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } + if isIdle { + setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonIdled, "Instance is idled") + return ctrl.Result{}, nil + } + r.setDeploymentStatus(ctx, &backstage, *bsModel) return ctrl.Result{}, nil } diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index a5bb28b42..1c131f48b 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -54,6 +54,15 @@ func setStatusCondition(backstage *api.Backstage, condType api.BackstageConditio }) } +func hasConditionReason(backstage *api.Backstage, reason api.BackstageConditionReason) bool { + for _, c := range backstage.Status.Conditions { + if c.Type == string(api.BackstageConditionTypeDeployed) && c.Reason == string(reason) { + return true + } + } + return false +} + func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionReason, msg string) { desired := int32(1) if deploy.Spec.Replicas != nil { diff --git a/pkg/model/deployable.go b/pkg/model/deployable.go index ddcefe99f..44e79d46b 100644 --- a/pkg/model/deployable.go +++ b/pkg/model/deployable.go @@ -24,6 +24,8 @@ type Deployable interface { SpecSelector() *metav1.LabelSelector // SpecReplicas returns the spec.replicas of the deployable object SpecReplicas() *int32 + // SetReplicas sets the spec.replicas of the deployable object + SetReplicas(r *int32) // ConvertTo converts the deployable object to the specified kind (Deployment or StatefulSet) ConvertTo(kind string) (Deployable, error) // SetEmpty sets the deployable object to an empty object of its type diff --git a/pkg/model/deployment_obj.go b/pkg/model/deployment_obj.go index 8c6fb0f4e..4ecd3b0f0 100644 --- a/pkg/model/deployment_obj.go +++ b/pkg/model/deployment_obj.go @@ -56,6 +56,10 @@ func (d *DeploymentObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } +func (d *DeploymentObj) SetReplicas(r *int32) { + d.Obj.Spec.Replicas = r +} + // toStatefulSet converts a Deployment to a StatefulSet func toStatefulSet(dep *appv1.Deployment) *appv1.StatefulSet { ss := &appv1.StatefulSet{ diff --git a/pkg/model/deployment_test.go b/pkg/model/deployment_test.go index 5e0c35934..a9f79847a 100644 --- a/pkg/model/deployment_test.go +++ b/pkg/model/deployment_test.go @@ -385,6 +385,141 @@ func TestDeploymentKind(t *testing.T) { assert.Equal(t, depPodSpec, ssPodSpec) } +func TestSetIdleReplicas(t *testing.T) { + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) + assert.NoError(t, err) + + deployment := model.getDeployment() + assert.NotNil(t, deployment) + + model.SetIdleReplicas() + + assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas()) + + dbSS := model.getDbStatefulSet() + assert.NotNil(t, dbSS) + assert.NotNil(t, dbSS.statefulSet) + assert.Equal(t, int32(0), *dbSS.statefulSet.Spec.Replicas) +} + +func TestWakeReplicasWithExistingValue(t *testing.T) { + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) + assert.NoError(t, err) + + deployment := model.getDeployment() + assert.NotNil(t, deployment.deployable.SpecReplicas()) + + model.WakeReplicas() + + // replicas already set by default config — WakeReplicas should not override + assert.Equal(t, int32(1), *deployment.deployable.SpecReplicas()) + + dbSS := model.getDbStatefulSet() + assert.NotNil(t, dbSS) + assert.Equal(t, int32(1), *dbSS.statefulSet.Spec.Replicas) +} + +func TestWakeReplicasFromNil(t *testing.T) { + depObj := &DeploymentObj{Obj: &appv1.Deployment{}} + assert.Nil(t, depObj.SpecReplicas()) + + ssObj := &StatefulSetObj{Obj: &appv1.StatefulSet{}} + assert.Nil(t, ssObj.SpecReplicas()) + + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} + testObj := createBackstageTest(bs).withDefaultConfig(true) + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) + assert.NoError(t, err) + + // Simulate replicas being nil (as in production default config) + deployment := model.getDeployment() + deployment.deployable.SetReplicas(nil) + dbSS := model.getDbStatefulSet() + dbSS.statefulSet.Spec.Replicas = nil + + model.WakeReplicas() + + assert.Equal(t, int32(1), *deployment.deployable.SpecReplicas()) + assert.Equal(t, int32(1), *dbSS.statefulSet.Spec.Replicas) +} + +func TestWakeReplicasPreservesPatchValue(t *testing.T) { + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(false)} + bs.Spec.Deployment = &api.BackstageDeployment{ + Patch: &apiextensionsv1.JSON{ + Raw: []byte(` +spec: + replicas: 3 +`), + }, + } + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) + assert.NoError(t, err) + + deployment := model.getDeployment() + assert.Equal(t, int32(3), *deployment.deployable.SpecReplicas()) + + model.WakeReplicas() + + assert.Equal(t, int32(3), *deployment.deployable.SpecReplicas()) +} + +func TestIdleWithExternalDb(t *testing.T) { + bs := *deploymentTestBackstage.DeepCopy() + bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(false)} + + testObj := createBackstageTest(bs).withDefaultConfig(true) + + model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) + assert.NoError(t, err) + + assert.Nil(t, model.getDbStatefulSet()) + + model.SetIdleReplicas() + + deployment := model.getDeployment() + assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas()) + + model.WakeReplicas() + + // Replicas already set to 0 by SetIdleReplicas, WakeReplicas leaves it + // because SpecReplicas() is non-nil. In the real controller flow, the + // model is rebuilt from scratch on the wake reconcile, so SpecReplicas() + // would reflect the default config value, not the idled 0. +} + +func TestSetReplicasOnDeployable(t *testing.T) { + depObj := &DeploymentObj{Obj: &appv1.Deployment{}} + assert.Nil(t, depObj.SpecReplicas()) + + depObj.SetReplicas(ptr.To(int32(5))) + assert.Equal(t, int32(5), *depObj.SpecReplicas()) + + depObj.SetReplicas(nil) + assert.Nil(t, depObj.SpecReplicas()) + + ssObj := &StatefulSetObj{Obj: &appv1.StatefulSet{}} + assert.Nil(t, ssObj.SpecReplicas()) + + ssObj.SetReplicas(ptr.To(int32(2))) + assert.Equal(t, int32(2), *ssObj.SpecReplicas()) +} + func TestPatchedStatefulSet(t *testing.T) { bs := *deploymentTestBackstage.DeepCopy() bs.Spec.Deployment = &api.BackstageDeployment{} diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go index 85dd0878a..b2bc0633c 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -8,6 +8,7 @@ import ( "github.com/redhat-developer/rhdh-operator/pkg/platform" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" "github.com/redhat-developer/rhdh-operator/pkg/model/multiobject" @@ -24,6 +25,7 @@ import ( ) const BackstageAppLabel = "rhdh.redhat.com/app" +const IdleAnnotation = "rhdh.redhat.com/idle" const ConfiguredNameAnnotation = "rhdh.redhat.com/configured-name" const DefaultMountPathAnnotation = "rhdh.redhat.com/mount-path" const DefaultSubPathAnnotation = "rhdh.redhat.com/sub-path" @@ -99,6 +101,40 @@ func (m *BackstageModel) getDeployment() *BackstageDeployment { return obj.(*BackstageDeployment) } +func (m *BackstageModel) getDbStatefulSet() *DbStatefulSet { + obj := m.GetRuntimeObject(DbStatefulSetKey) + if obj == nil { + return nil + } + return obj.(*DbStatefulSet) +} + +// SetIdleReplicas forces replicas=0 on all managed workloads. +func (m *BackstageModel) SetIdleReplicas() { + if dep := m.getDeployment(); dep != nil { + dep.deployable.SetReplicas(ptr.To(int32(0))) + } + if db := m.getDbStatefulSet(); db != nil && db.statefulSet != nil { + db.statefulSet.Spec.Replicas = ptr.To(int32(0)) + } +} + +// WakeReplicas restores replicas after idling. If replicas was not explicitly +// set (e.g. by spec.deployment.patch), defaults to 1. If already set by the +// user's patch, leaves it unchanged. +func (m *BackstageModel) WakeReplicas() { + if dep := m.getDeployment(); dep != nil { + if dep.deployable.SpecReplicas() == nil { + dep.deployable.SetReplicas(ptr.To(int32(1))) + } + } + if db := m.getDbStatefulSet(); db != nil && db.statefulSet != nil { + if db.statefulSet.Spec.Replicas == nil { + db.statefulSet.Spec.Replicas = ptr.To(int32(1)) + } + } +} + func (m *BackstageModel) GetDeploymentGVK() schema.GroupVersionKind { deployment := m.getDeployment() return deployment.deployable.GetObject().GetObjectKind().GroupVersionKind() diff --git a/pkg/model/statefulset_obj.go b/pkg/model/statefulset_obj.go index c0521e6aa..19a19bb4d 100644 --- a/pkg/model/statefulset_obj.go +++ b/pkg/model/statefulset_obj.go @@ -56,6 +56,10 @@ func (d *StatefulSetObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } +func (d *StatefulSetObj) SetReplicas(r *int32) { + d.Obj.Spec.Replicas = r +} + // toDeployment converts a StatefulSet to a Deployment func toDeployment(ss *appv1.StatefulSet) *appv1.Deployment { dep := &appv1.Deployment{ From fd536558fe7f989ca2d5614f63ef2b6950a37fa6 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 4 Aug 2026 09:02:38 +0200 Subject: [PATCH 02/10] docs: add Instance Idling section to configuration guide Documents the rhdh.redhat.com/idle annotation: how to idle and wake instances, behavior with local vs external DB, HPA compatibility, and status condition transitions. Ref: RHIDP-15995 Assisted-by: Claude --- docs/configuration.md | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 4f6d60ee9..d664f80af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,6 +29,7 @@ It is highly recommended to read the [Design](design.md) document to understand - [Deployment Configuration](#deployment-configuration) - [Deployment Kind](#deployment-kind) - [Deployment Patching](#deployment-patching) + - [Instance Idling](#instance-idling) - [Database Configuration](#database-configuration) @@ -913,6 +914,57 @@ spec: $patch: delete ``` +### Instance Idling + +The Operator supports idling and waking Backstage instances via the `rhdh.redhat.com/idle` annotation on the Backstage CR. This is useful for environments like Dev Sandbox where instances should be scaled to zero when inactive. + +#### How it works + +When the annotation `rhdh.redhat.com/idle` is set to `"true"` on the Backstage CR, the Operator overrides replicas to 0 on both the Backstage Deployment (or StatefulSet) and the local DB StatefulSet (if enabled). The status condition reason is set to `Idled`. + +When the annotation is removed (or set to any value other than `"true"`), the Operator restores replicas: +- If the user specified replicas via `spec.deployment.patch`, that value is preserved. +- Otherwise, replicas defaults to 1. + +On the next reconciliation after waking, the Operator stops managing the replicas field, releasing field ownership so that Horizontal Pod Autoscalers (HPAs) or other external controllers can manage scaling. + +When the local DB is disabled (`spec.database.enableLocalDb: false`), only the Backstage Deployment is affected. The external database is never touched. + +#### Idling an instance + +```bash +kubectl annotate backstage rhdh.redhat.com/idle=true +``` + +Or declaratively: + +```yaml +apiVersion: rhdh.redhat.com/v1alpha5 +kind: Backstage +metadata: + name: my-backstage + annotations: + rhdh.redhat.com/idle: "true" +spec: {} +``` + +After reconciliation, the Backstage Deployment and local DB StatefulSet (if enabled) will have `replicas: 0`, and the status condition will show: + +``` +Type: Deployed +Status: False +Reason: Idled +Message: Instance is idled +``` + +#### Waking an instance + +```bash +kubectl annotate backstage rhdh.redhat.com/idle- +``` + +The Operator detects that the instance was previously idled (via the `Idled` status condition reason) and restores replicas. The status condition transitions back to its normal deployed state. + ### Database Configuration Backstage uses PostgreSQL as a storage solution. The Operator can: From 820c33f3ff2b78b0f1f8038f0b7b73255975ef85 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 4 Aug 2026 09:24:44 +0200 Subject: [PATCH 03/10] fix: wrap idle integration test assertions in Eventually blocks The CI runs with USE_EXISTING_CONTROLLER=true, where ReconcileAny is a no-op and reconciliation happens asynchronously via the background controller. Synchronous assertions after ReconcileAny fail because the controller hasn't processed the annotation change yet. Wrap all post-idle and post-wake assertions in Eventually blocks to poll until the controller reconciles. Ref: RHIDP-15995 Assisted-by: Claude --- integration_tests/idle_test.go | 77 +++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/integration_tests/idle_test.go b/integration_tests/idle_test.go index 7b037fa41..54831eeaa 100644 --- a/integration_tests/idle_test.go +++ b/integration_tests/idle_test.go @@ -57,23 +57,30 @@ var _ = When("backstage idle annotation", func() { Expect(err).To(Not(HaveOccurred())) By("verifying deployment replicas=0") - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - Expect(err).ShouldNot(HaveOccurred()) - Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + }, time.Minute, time.Second).Should(Succeed()) By("verifying DB StatefulSet replicas=0") - ss := &appsv1.StatefulSet{} - err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) - Expect(err).ShouldNot(HaveOccurred()) - Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(0))) + Eventually(func(g Gomega) { + ss := &appsv1.StatefulSet{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(0))) + }, time.Minute, time.Second).Should(Succeed()) By("verifying status condition is Idled") - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - Expect(bs.Status.Conditions).To(HaveLen(1)) - Expect(bs.Status.Conditions[0].Reason).To(Equal("Idled")) - Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + g.Expect(bs.Status.Conditions).To(HaveLen(1)) + g.Expect(bs.Status.Conditions[0].Reason).To(Equal("Idled")) + g.Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + }, time.Minute, time.Second).Should(Succeed()) By("removing idle annotation (wake)") + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) delete(bs.Annotations, model.IdleAnnotation) Expect(k8sClient.Update(ctx, bs)).To(Succeed()) @@ -83,18 +90,25 @@ var _ = When("backstage idle annotation", func() { Expect(err).To(Not(HaveOccurred())) By("verifying deployment replicas restored to 1") - deploy, err = backstageDeployment(ctx, k8sClient, ns, backstageName) - Expect(err).ShouldNot(HaveOccurred()) - Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + }, time.Minute, time.Second).Should(Succeed()) By("verifying DB StatefulSet replicas restored to 1") - err = k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) - Expect(err).ShouldNot(HaveOccurred()) - Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + Eventually(func(g Gomega) { + ss := &appsv1.StatefulSet{} + err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) + }, time.Minute, time.Second).Should(Succeed()) By("verifying status condition is no longer Idled") - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + g.Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + }, time.Minute, time.Second).Should(Succeed()) }) It("wakes with user-specified replicas from deployment patch", func() { @@ -121,9 +135,11 @@ var _ = When("backstage idle annotation", func() { Expect(err).To(Not(HaveOccurred())) By("verifying deployment is idled") - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - Expect(err).ShouldNot(HaveOccurred()) - Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) + }, time.Minute, time.Second).Should(Succeed()) By("removing idle annotation and adding replicas via deployment patch") Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) @@ -136,9 +152,11 @@ var _ = When("backstage idle annotation", func() { Expect(err).To(Not(HaveOccurred())) By("verifying deployment replicas restored to 1 (default, no patch)") - deploy, err = backstageDeployment(ctx, k8sClient, ns, backstageName) - Expect(err).ShouldNot(HaveOccurred()) - Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + Eventually(func(g Gomega) { + deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) + g.Expect(err).ShouldNot(HaveOccurred()) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + }, time.Minute, time.Second).Should(Succeed()) }) It("does not touch replicas when not idled and never was", func() { @@ -151,8 +169,11 @@ var _ = When("backstage idle annotation", func() { }, time.Minute, time.Second).Should(Succeed()) By("verifying status was never Idled") - bs := &api.Backstage{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + Eventually(func(g Gomega) { + bs := &api.Backstage{} + g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) + g.Expect(bs.Status.Conditions).NotTo(BeEmpty()) + g.Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) + }, time.Minute, time.Second).Should(Succeed()) }) }) From 18a27ea8f8d74d8e74cd34a371051dad3077bad4 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 4 Aug 2026 10:05:37 +0200 Subject: [PATCH 04/10] refactor: simplify idle annotation to check in model updateAndValidate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the idle annotation check (replicas=0) into the updateAndValidate methods of BackstageDeployment and DbStatefulSet, removing the separate idle/wake block from the controller. Wake logic is unnecessary because the model is rebuilt from scratch on every reconcile — when the annotation is removed, replicas naturally get their default or patched value. Assisted-by: Claude --- internal/controller/backstage_controller.go | 12 +-- internal/controller/backstage_status.go | 9 -- pkg/model/db-statefulset.go | 6 ++ pkg/model/deployment.go | 4 + pkg/model/deployment_test.go | 113 ++------------------ pkg/model/runtime.go | 35 ------ 6 files changed, 19 insertions(+), 160 deletions(-) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 7f6296109..37c4cdace 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -103,16 +103,6 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to initialize backstage model", err) } - // Handle idle/wake annotation - isIdle := backstage.GetAnnotations()[model.IdleAnnotation] == "true" - wasIdled := hasConditionReason(&backstage, api.BackstageConditionReasonIdled) - - if isIdle { - bsModel.SetIdleReplicas() - } else if wasIdled { - bsModel.WakeReplicas() - } - // Apply the plugin dependencies if err := r.applyPluginDeps(ctx, backstage, bsModel); err != nil { return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply plugin dependencies", err) @@ -124,7 +114,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } - if isIdle { + if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonIdled, "Instance is idled") return ctrl.Result{}, nil } diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 1c131f48b..a5bb28b42 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -54,15 +54,6 @@ func setStatusCondition(backstage *api.Backstage, condType api.BackstageConditio }) } -func hasConditionReason(backstage *api.Backstage, reason api.BackstageConditionReason) bool { - for _, c := range backstage.Status.Conditions { - if c.Type == string(api.BackstageConditionTypeDeployed) && c.Reason == string(reason) { - return true - } - } - return false -} - func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionReason, msg string) { desired := int32(1) if deploy.Spec.Replicas != nil { diff --git a/pkg/model/db-statefulset.go b/pkg/model/db-statefulset.go index c9b76dbe7..b763f9083 100644 --- a/pkg/model/db-statefulset.go +++ b/pkg/model/db-statefulset.go @@ -5,6 +5,7 @@ import ( "os" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" corev1 "k8s.io/api/core/v1" @@ -95,6 +96,11 @@ func (b *DbStatefulSet) updateAndValidate(backstage api.Backstage, scheme *runti b.setDbSecretEnvVar(b.container(), secret.Name) } } + + if backstage.GetAnnotations()[IdleAnnotation] == "true" { + b.statefulSet.Spec.Replicas = ptr.To(int32(0)) + } + return nil } diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 9de637d62..fe8baa65c 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -138,6 +138,10 @@ func (b *BackstageDeployment) updateAndValidate(backstage api.Backstage, _ *runt return fmt.Errorf("can not add env vars from db secret: %w", err) } + if backstage.GetAnnotations()[IdleAnnotation] == "true" { + b.deployable.SetReplicas(ptr.To(int32(0))) + } + return nil } diff --git a/pkg/model/deployment_test.go b/pkg/model/deployment_test.go index a9f79847a..bf02efd6f 100644 --- a/pkg/model/deployment_test.go +++ b/pkg/model/deployment_test.go @@ -385,9 +385,12 @@ func TestDeploymentKind(t *testing.T) { assert.Equal(t, depPodSpec, ssPodSpec) } -func TestSetIdleReplicas(t *testing.T) { +func TestIdleAnnotationSetsReplicasToZero(t *testing.T) { bs := *deploymentTestBackstage.DeepCopy() bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} + bs.Annotations = map[string]string{ + IdleAnnotation: "true", + } testObj := createBackstageTest(bs).withDefaultConfig(true) @@ -396,128 +399,28 @@ func TestSetIdleReplicas(t *testing.T) { deployment := model.getDeployment() assert.NotNil(t, deployment) - - model.SetIdleReplicas() - assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas()) - dbSS := model.getDbStatefulSet() + dbSS := model.GetRuntimeObject(DbStatefulSetKey).(*DbStatefulSet) assert.NotNil(t, dbSS) assert.NotNil(t, dbSS.statefulSet) assert.Equal(t, int32(0), *dbSS.statefulSet.Spec.Replicas) } -func TestWakeReplicasWithExistingValue(t *testing.T) { - bs := *deploymentTestBackstage.DeepCopy() - bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} - - testObj := createBackstageTest(bs).withDefaultConfig(true) - - model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) - assert.NoError(t, err) - - deployment := model.getDeployment() - assert.NotNil(t, deployment.deployable.SpecReplicas()) - - model.WakeReplicas() - - // replicas already set by default config — WakeReplicas should not override - assert.Equal(t, int32(1), *deployment.deployable.SpecReplicas()) - - dbSS := model.getDbStatefulSet() - assert.NotNil(t, dbSS) - assert.Equal(t, int32(1), *dbSS.statefulSet.Spec.Replicas) -} - -func TestWakeReplicasFromNil(t *testing.T) { - depObj := &DeploymentObj{Obj: &appv1.Deployment{}} - assert.Nil(t, depObj.SpecReplicas()) - - ssObj := &StatefulSetObj{Obj: &appv1.StatefulSet{}} - assert.Nil(t, ssObj.SpecReplicas()) - - bs := *deploymentTestBackstage.DeepCopy() - bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(true)} - testObj := createBackstageTest(bs).withDefaultConfig(true) - model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) - assert.NoError(t, err) - - // Simulate replicas being nil (as in production default config) - deployment := model.getDeployment() - deployment.deployable.SetReplicas(nil) - dbSS := model.getDbStatefulSet() - dbSS.statefulSet.Spec.Replicas = nil - - model.WakeReplicas() - - assert.Equal(t, int32(1), *deployment.deployable.SpecReplicas()) - assert.Equal(t, int32(1), *dbSS.statefulSet.Spec.Replicas) -} - -func TestWakeReplicasPreservesPatchValue(t *testing.T) { - bs := *deploymentTestBackstage.DeepCopy() - bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(false)} - bs.Spec.Deployment = &api.BackstageDeployment{ - Patch: &apiextensionsv1.JSON{ - Raw: []byte(` -spec: - replicas: 3 -`), - }, - } - - testObj := createBackstageTest(bs).withDefaultConfig(true) - - model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) - assert.NoError(t, err) - - deployment := model.getDeployment() - assert.Equal(t, int32(3), *deployment.deployable.SpecReplicas()) - - model.WakeReplicas() - - assert.Equal(t, int32(3), *deployment.deployable.SpecReplicas()) -} - func TestIdleWithExternalDb(t *testing.T) { bs := *deploymentTestBackstage.DeepCopy() bs.Spec.Database = &api.Database{EnableLocalDb: ptr.To(false)} + bs.Annotations = map[string]string{ + IdleAnnotation: "true", + } testObj := createBackstageTest(bs).withDefaultConfig(true) model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme) assert.NoError(t, err) - assert.Nil(t, model.getDbStatefulSet()) - - model.SetIdleReplicas() - deployment := model.getDeployment() assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas()) - - model.WakeReplicas() - - // Replicas already set to 0 by SetIdleReplicas, WakeReplicas leaves it - // because SpecReplicas() is non-nil. In the real controller flow, the - // model is rebuilt from scratch on the wake reconcile, so SpecReplicas() - // would reflect the default config value, not the idled 0. -} - -func TestSetReplicasOnDeployable(t *testing.T) { - depObj := &DeploymentObj{Obj: &appv1.Deployment{}} - assert.Nil(t, depObj.SpecReplicas()) - - depObj.SetReplicas(ptr.To(int32(5))) - assert.Equal(t, int32(5), *depObj.SpecReplicas()) - - depObj.SetReplicas(nil) - assert.Nil(t, depObj.SpecReplicas()) - - ssObj := &StatefulSetObj{Obj: &appv1.StatefulSet{}} - assert.Nil(t, ssObj.SpecReplicas()) - - ssObj.SetReplicas(ptr.To(int32(2))) - assert.Equal(t, int32(2), *ssObj.SpecReplicas()) } func TestPatchedStatefulSet(t *testing.T) { diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go index b2bc0633c..a0536913a 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -8,7 +8,6 @@ import ( "github.com/redhat-developer/rhdh-operator/pkg/platform" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/utils/ptr" "github.com/redhat-developer/rhdh-operator/pkg/model/multiobject" @@ -101,40 +100,6 @@ func (m *BackstageModel) getDeployment() *BackstageDeployment { return obj.(*BackstageDeployment) } -func (m *BackstageModel) getDbStatefulSet() *DbStatefulSet { - obj := m.GetRuntimeObject(DbStatefulSetKey) - if obj == nil { - return nil - } - return obj.(*DbStatefulSet) -} - -// SetIdleReplicas forces replicas=0 on all managed workloads. -func (m *BackstageModel) SetIdleReplicas() { - if dep := m.getDeployment(); dep != nil { - dep.deployable.SetReplicas(ptr.To(int32(0))) - } - if db := m.getDbStatefulSet(); db != nil && db.statefulSet != nil { - db.statefulSet.Spec.Replicas = ptr.To(int32(0)) - } -} - -// WakeReplicas restores replicas after idling. If replicas was not explicitly -// set (e.g. by spec.deployment.patch), defaults to 1. If already set by the -// user's patch, leaves it unchanged. -func (m *BackstageModel) WakeReplicas() { - if dep := m.getDeployment(); dep != nil { - if dep.deployable.SpecReplicas() == nil { - dep.deployable.SetReplicas(ptr.To(int32(1))) - } - } - if db := m.getDbStatefulSet(); db != nil && db.statefulSet != nil { - if db.statefulSet.Spec.Replicas == nil { - db.statefulSet.Spec.Replicas = ptr.To(int32(1)) - } - } -} - func (m *BackstageModel) GetDeploymentGVK() schema.GroupVersionKind { deployment := m.getDeployment() return deployment.deployable.GetObject().GetObjectKind().GroupVersionKind() From a02dddc7e69f1f8e49eb52694a5d1ceb9d4afd50 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Tue, 4 Aug 2026 11:17:51 +0200 Subject: [PATCH 05/10] fix: improve idle docs and test patch-restore behavior - Document namespace scope, CRD prerequisite, and CI/monitoring contract for the Idled status condition - Fix integration test to actually set replicas via spec.deployment.patch and assert wake restores to the patched value (3), not just the default Assisted-by: Claude --- docs/configuration.md | 8 +++++--- integration_tests/idle_test.go | 36 +++++++++++++--------------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d664f80af..b121cbbc6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -920,7 +920,7 @@ The Operator supports idling and waking Backstage instances via the `rhdh.redhat #### How it works -When the annotation `rhdh.redhat.com/idle` is set to `"true"` on the Backstage CR, the Operator overrides replicas to 0 on both the Backstage Deployment (or StatefulSet) and the local DB StatefulSet (if enabled). The status condition reason is set to `Idled`. +When the annotation `rhdh.redhat.com/idle` is set to `"true"` on the Backstage CR, the Operator overrides replicas to 0 on both the Backstage Deployment (or StatefulSet) and the local DB StatefulSet (if enabled), in the same namespace as the Backstage CR. The status condition reason is set to `Idled`. When the annotation is removed (or set to any value other than `"true"`), the Operator restores replicas: - If the user specified replicas via `spec.deployment.patch`, that value is preserved. @@ -936,7 +936,7 @@ When the local DB is disabled (`spec.database.enableLocalDb: false`), only the B kubectl annotate backstage rhdh.redhat.com/idle=true ``` -Or declaratively: +Or declaratively (assumes the RHDH Operator is installed, which registers the `Backstage` CRD): ```yaml apiVersion: rhdh.redhat.com/v1alpha5 @@ -957,13 +957,15 @@ Reason: Idled Message: Instance is idled ``` +> **Note for CI and monitoring scripts:** An idled instance reports `Deployed=False` with `Reason=Idled`. Scripts that wait for `Deployed=True` should check the `Reason` field to distinguish an intentionally idled instance from a deployment failure. To ensure readiness checks succeed, remove the `rhdh.redhat.com/idle` annotation before waiting for deployment. + #### Waking an instance ```bash kubectl annotate backstage rhdh.redhat.com/idle- ``` -The Operator detects that the instance was previously idled (via the `Idled` status condition reason) and restores replicas. The status condition transitions back to its normal deployed state. +The Operator restores replicas from the default config or from the user's `spec.deployment.patch` value. The status condition transitions back to its normal deployed state. ### Database Configuration diff --git a/integration_tests/idle_test.go b/integration_tests/idle_test.go index 54831eeaa..34abec8fb 100644 --- a/integration_tests/idle_test.go +++ b/integration_tests/idle_test.go @@ -6,6 +6,7 @@ import ( "time" appsv1 "k8s.io/api/apps/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -112,12 +113,20 @@ var _ = When("backstage idle annotation", func() { }) It("wakes with user-specified replicas from deployment patch", func() { - backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") + spec := api.BackstageSpec{ + Deployment: &api.BackstageDeployment{ + Patch: &apiextensionsv1.JSON{ + Raw: []byte(`{"spec":{"replicas":3}}`), + }, + }, + } + backstageName := createAndReconcileBackstage(ctx, ns, spec, "") + By("verifying deployment starts with patched replicas=3") Eventually(func(g Gomega) { deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy).NotTo(BeNil()) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(3))) }, time.Minute, time.Second).Should(Succeed()) By("setting idle annotation") @@ -141,7 +150,7 @@ var _ = When("backstage idle annotation", func() { g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) }, time.Minute, time.Second).Should(Succeed()) - By("removing idle annotation and adding replicas via deployment patch") + By("removing idle annotation (wake)") Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) delete(bs.Annotations, model.IdleAnnotation) Expect(k8sClient.Update(ctx, bs)).To(Succeed()) @@ -151,29 +160,12 @@ var _ = When("backstage idle annotation", func() { }) Expect(err).To(Not(HaveOccurred())) - By("verifying deployment replicas restored to 1 (default, no patch)") + By("verifying deployment replicas restored to patched value 3") Eventually(func(g Gomega) { deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) + g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(3))) }, time.Minute, time.Second).Should(Succeed()) }) - It("does not touch replicas when not idled and never was", func() { - backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") - - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy).NotTo(BeNil()) - }, time.Minute, time.Second).Should(Succeed()) - - By("verifying status was never Idled") - Eventually(func(g Gomega) { - bs := &api.Backstage{} - g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - g.Expect(bs.Status.Conditions).NotTo(BeEmpty()) - g.Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) - }, time.Minute, time.Second).Should(Succeed()) - }) }) From c66096921c667b03be561a6d9533d3aa0c5782ce Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Wed, 5 Aug 2026 16:22:33 +0200 Subject: [PATCH 06/10] refactor: introduce Idleable interface and simplify idle implementation Address review feedback: - Add Idleable interface with Idle() method and ShouldIdle predicate - Implement Idle() on BackstageDeployment, DbStatefulSet, DeploymentObj, and StatefulSetObj with compile-time interface checks - Centralize idle logic in InitObjects instead of per-object updateAndValidate - Replace generic SetReplicas with domain-specific Idle() on Deployable objects - Move Instance Idling docs from configuration.md to admin.md - Remove redundant integration test - Use model.ShouldIdle in controller Co-authored-by: Gennady Azarenkov Assisted-by: Claude --- docs/admin.md | 35 ++++++++++++- docs/configuration.md | 53 ------------------- integration_tests/idle_test.go | 57 --------------------- internal/controller/backstage_controller.go | 2 +- pkg/model/db-statefulset.go | 11 ++-- pkg/model/deployable.go | 2 - pkg/model/deployment.go | 11 ++-- pkg/model/deployment_obj.go | 12 +++-- pkg/model/idler.go | 14 +++++ pkg/model/runtime.go | 9 ++++ pkg/model/statefulset_obj.go | 12 +++-- 11 files changed, 89 insertions(+), 129 deletions(-) create mode 100644 pkg/model/idler.go diff --git a/docs/admin.md b/docs/admin.md index 11a08536b..e22a77a60 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -176,4 +176,37 @@ This command queries multiple resource types at once: `all` covers common resour oc get pvc -n | grep backstage-psql- ``` -Review carefully before deleting, especially PersistentVolumeClaims which contain data. \ No newline at end of file +Review carefully before deleting, especially PersistentVolumeClaims which contain data. + +## Instance Idling + +The Operator supports idling and waking Backstage instances via the `rhdh.redhat.com/idle` annotation on the Backstage CR. When set to `"true"`, the Operator scales all managed workloads (Backstage Deployment or StatefulSet, and the local DB StatefulSet if enabled) to zero replicas in the same namespace as the CR. + +When the annotation is removed, the next reconciliation restores replicas to their normal values. + +When the local DB is disabled (`spec.database.enableLocalDb: false`), only the Backstage Deployment is affected. + +### Idling an instance + +```bash +kubectl annotate backstage rhdh.redhat.com/idle=true +``` + +After reconciliation, the status condition will show: + +``` +Type: Deployed +Status: False +Reason: Idled +Message: Instance is idled +``` + +> **Note for CI and monitoring scripts:** An idled instance reports `Deployed=False` with `Reason=Idled`. Scripts that wait for `Deployed=True` should check the `Reason` field to distinguish an intentionally idled instance from a deployment failure. To ensure readiness checks succeed, remove the `rhdh.redhat.com/idle` annotation before waiting for deployment. + +### Waking an instance + +```bash +kubectl annotate backstage rhdh.redhat.com/idle- +``` + +The status condition transitions back to its normal deployed state. \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index b121cbbc6..b41fe9ef6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -914,59 +914,6 @@ spec: $patch: delete ``` -### Instance Idling - -The Operator supports idling and waking Backstage instances via the `rhdh.redhat.com/idle` annotation on the Backstage CR. This is useful for environments like Dev Sandbox where instances should be scaled to zero when inactive. - -#### How it works - -When the annotation `rhdh.redhat.com/idle` is set to `"true"` on the Backstage CR, the Operator overrides replicas to 0 on both the Backstage Deployment (or StatefulSet) and the local DB StatefulSet (if enabled), in the same namespace as the Backstage CR. The status condition reason is set to `Idled`. - -When the annotation is removed (or set to any value other than `"true"`), the Operator restores replicas: -- If the user specified replicas via `spec.deployment.patch`, that value is preserved. -- Otherwise, replicas defaults to 1. - -On the next reconciliation after waking, the Operator stops managing the replicas field, releasing field ownership so that Horizontal Pod Autoscalers (HPAs) or other external controllers can manage scaling. - -When the local DB is disabled (`spec.database.enableLocalDb: false`), only the Backstage Deployment is affected. The external database is never touched. - -#### Idling an instance - -```bash -kubectl annotate backstage rhdh.redhat.com/idle=true -``` - -Or declaratively (assumes the RHDH Operator is installed, which registers the `Backstage` CRD): - -```yaml -apiVersion: rhdh.redhat.com/v1alpha5 -kind: Backstage -metadata: - name: my-backstage - annotations: - rhdh.redhat.com/idle: "true" -spec: {} -``` - -After reconciliation, the Backstage Deployment and local DB StatefulSet (if enabled) will have `replicas: 0`, and the status condition will show: - -``` -Type: Deployed -Status: False -Reason: Idled -Message: Instance is idled -``` - -> **Note for CI and monitoring scripts:** An idled instance reports `Deployed=False` with `Reason=Idled`. Scripts that wait for `Deployed=True` should check the `Reason` field to distinguish an intentionally idled instance from a deployment failure. To ensure readiness checks succeed, remove the `rhdh.redhat.com/idle` annotation before waiting for deployment. - -#### Waking an instance - -```bash -kubectl annotate backstage rhdh.redhat.com/idle- -``` - -The Operator restores replicas from the default config or from the user's `spec.deployment.patch` value. The status condition transitions back to its normal deployed state. - ### Database Configuration Backstage uses PostgreSQL as a storage solution. The Operator can: diff --git a/integration_tests/idle_test.go b/integration_tests/idle_test.go index 34abec8fb..ee57718cc 100644 --- a/integration_tests/idle_test.go +++ b/integration_tests/idle_test.go @@ -6,7 +6,6 @@ import ( "time" appsv1 "k8s.io/api/apps/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -112,60 +111,4 @@ var _ = When("backstage idle annotation", func() { }, time.Minute, time.Second).Should(Succeed()) }) - It("wakes with user-specified replicas from deployment patch", func() { - spec := api.BackstageSpec{ - Deployment: &api.BackstageDeployment{ - Patch: &apiextensionsv1.JSON{ - Raw: []byte(`{"spec":{"replicas":3}}`), - }, - }, - } - backstageName := createAndReconcileBackstage(ctx, ns, spec, "") - - By("verifying deployment starts with patched replicas=3") - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(3))) - }, time.Minute, time.Second).Should(Succeed()) - - By("setting idle annotation") - bs := &api.Backstage{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - if bs.Annotations == nil { - bs.Annotations = map[string]string{} - } - bs.Annotations[model.IdleAnnotation] = "true" - Expect(k8sClient.Update(ctx, bs)).To(Succeed()) - - _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, - }) - Expect(err).To(Not(HaveOccurred())) - - By("verifying deployment is idled") - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) - }, time.Minute, time.Second).Should(Succeed()) - - By("removing idle annotation (wake)") - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - delete(bs.Annotations, model.IdleAnnotation) - Expect(k8sClient.Update(ctx, bs)).To(Succeed()) - - _, err = NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, - }) - Expect(err).To(Not(HaveOccurred())) - - By("verifying deployment replicas restored to patched value 3") - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(3))) - }, time.Minute, time.Second).Should(Succeed()) - }) - }) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index ff93790d5..78ce50133 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -115,7 +115,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } - if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { + if model.ShouldIdle(backstage) { setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonIdled, "Instance is idled") return ctrl.Result{}, nil } diff --git a/pkg/model/db-statefulset.go b/pkg/model/db-statefulset.go index b763f9083..871f2fe60 100644 --- a/pkg/model/db-statefulset.go +++ b/pkg/model/db-statefulset.go @@ -97,11 +97,16 @@ func (b *DbStatefulSet) updateAndValidate(backstage api.Backstage, scheme *runti } } - if backstage.GetAnnotations()[IdleAnnotation] == "true" { + return nil +} + +// compile-time check +var _ Idler = (*DbStatefulSet)(nil) + +func (b *DbStatefulSet) Idle() { + if b.statefulSet != nil { b.statefulSet.Spec.Replicas = ptr.To(int32(0)) } - - return nil } func (b *DbStatefulSet) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) { diff --git a/pkg/model/deployable.go b/pkg/model/deployable.go index 44e79d46b..ddcefe99f 100644 --- a/pkg/model/deployable.go +++ b/pkg/model/deployable.go @@ -24,8 +24,6 @@ type Deployable interface { SpecSelector() *metav1.LabelSelector // SpecReplicas returns the spec.replicas of the deployable object SpecReplicas() *int32 - // SetReplicas sets the spec.replicas of the deployable object - SetReplicas(r *int32) // ConvertTo converts the deployable object to the specified kind (Deployment or StatefulSet) ConvertTo(kind string) (Deployable, error) // SetEmpty sets the deployable object to an empty object of its type diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index fe8baa65c..3031bf9fd 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -138,13 +138,16 @@ func (b *BackstageDeployment) updateAndValidate(backstage api.Backstage, _ *runt return fmt.Errorf("can not add env vars from db secret: %w", err) } - if backstage.GetAnnotations()[IdleAnnotation] == "true" { - b.deployable.SetReplicas(ptr.To(int32(0))) - } - return nil } +// compile-time check +var _ Idler = (*BackstageDeployment)(nil) + +func (b *BackstageDeployment) Idle() { + b.deployable.(Idler).Idle() +} + func (b *BackstageDeployment) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) { b.deployable.GetObject().SetName(DeploymentName(backstage.Name)) utils.GenerateLabel(&b.deployable.PodObjectMeta().Labels, BackstageAppLabel, utils.BackstageAppLabelValue(backstage.Name)) diff --git a/pkg/model/deployment_obj.go b/pkg/model/deployment_obj.go index 4ecd3b0f0..089f5dac3 100644 --- a/pkg/model/deployment_obj.go +++ b/pkg/model/deployment_obj.go @@ -6,11 +6,15 @@ import ( appv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" ) -// compile-time check -var _ Deployable = (*DeploymentObj)(nil) +// compile-time checks +var ( + _ Deployable = (*DeploymentObj)(nil) + _ Idler = (*DeploymentObj)(nil) +) type DeploymentObj struct { Obj *appv1.Deployment @@ -56,8 +60,8 @@ func (d *DeploymentObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } -func (d *DeploymentObj) SetReplicas(r *int32) { - d.Obj.Spec.Replicas = r +func (d *DeploymentObj) Idle() { + d.Obj.Spec.Replicas = ptr.To(int32(0)) } // toStatefulSet converts a Deployment to a StatefulSet diff --git a/pkg/model/idler.go b/pkg/model/idler.go new file mode 100644 index 000000000..a741930e4 --- /dev/null +++ b/pkg/model/idler.go @@ -0,0 +1,14 @@ +package model + +import "github.com/redhat-developer/rhdh-operator/api" + +// Idler is implemented by RuntimeObjects whose workloads can be scaled to +// zero when the Backstage CR carries the idle annotation. +type Idler interface { + Idle() +} + +// ShouldIdle reports whether the Backstage CR requests idling. +func ShouldIdle(backstage api.Backstage) bool { + return backstage.GetAnnotations()[IdleAnnotation] == "true" +} diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go index a0536913a..74bf89884 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -206,6 +206,15 @@ func InitObjects(ctx context.Context, backstage api.Backstage, externalConfig Ex } } + // Phase 3: idle all workloads if the annotation requests it + if ShouldIdle(backstage) { + for _, obj := range model.RuntimeObjects { + if idleable, ok := obj.(Idler); ok { + idleable.Idle() + } + } + } + return model, nil } diff --git a/pkg/model/statefulset_obj.go b/pkg/model/statefulset_obj.go index 19a19bb4d..9f92ea978 100644 --- a/pkg/model/statefulset_obj.go +++ b/pkg/model/statefulset_obj.go @@ -6,11 +6,15 @@ import ( appv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" ) -// compile-time check -var _ Deployable = (*StatefulSetObj)(nil) +// compile-time checks +var ( + _ Deployable = (*StatefulSetObj)(nil) + _ Idler = (*StatefulSetObj)(nil) +) type StatefulSetObj struct { Obj *appv1.StatefulSet @@ -56,8 +60,8 @@ func (d *StatefulSetObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } -func (d *StatefulSetObj) SetReplicas(r *int32) { - d.Obj.Spec.Replicas = r +func (d *StatefulSetObj) Idle() { + d.Obj.Spec.Replicas = ptr.To(int32(0)) } // toDeployment converts a StatefulSet to a Deployment From 5112ed7864d0a4b81891ea31423e9f06d31a55d8 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Wed, 5 Aug 2026 16:50:02 +0200 Subject: [PATCH 07/10] chore: remove idle integration tests Unit tests in deployment_test.go provide sufficient coverage for idle behavior via InitObjects. Assisted-by: Claude --- integration_tests/idle_test.go | 114 --------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 integration_tests/idle_test.go diff --git a/integration_tests/idle_test.go b/integration_tests/idle_test.go deleted file mode 100644 index ee57718cc..000000000 --- a/integration_tests/idle_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package integration_tests - -import ( - "context" - "fmt" - "time" - - appsv1 "k8s.io/api/apps/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/redhat-developer/rhdh-operator/api" - "github.com/redhat-developer/rhdh-operator/pkg/model" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = When("backstage idle annotation", func() { - - var ( - ctx context.Context - ns string - ) - - BeforeEach(func() { - ctx = context.Background() - ns = createNamespace(ctx) - }) - - AfterEach(func() { - deleteNamespace(ctx, ns) - }) - - It("idles and wakes the instance", func() { - backstageName := createAndReconcileBackstage(ctx, ns, api.BackstageSpec{}, "") - - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy).NotTo(BeNil()) - }, time.Minute, time.Second).Should(Succeed()) - - By("setting idle annotation to true") - bs := &api.Backstage{} - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - if bs.Annotations == nil { - bs.Annotations = map[string]string{} - } - bs.Annotations[model.IdleAnnotation] = "true" - Expect(k8sClient.Update(ctx, bs)).To(Succeed()) - - _, err := NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, - }) - Expect(err).To(Not(HaveOccurred())) - - By("verifying deployment replicas=0") - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(0))) - }, time.Minute, time.Second).Should(Succeed()) - - By("verifying DB StatefulSet replicas=0") - Eventually(func(g Gomega) { - ss := &appsv1.StatefulSet{} - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(0))) - }, time.Minute, time.Second).Should(Succeed()) - - By("verifying status condition is Idled") - Eventually(func(g Gomega) { - g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - g.Expect(bs.Status.Conditions).To(HaveLen(1)) - g.Expect(bs.Status.Conditions[0].Reason).To(Equal("Idled")) - g.Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) - }, time.Minute, time.Second).Should(Succeed()) - - By("removing idle annotation (wake)") - Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - delete(bs.Annotations, model.IdleAnnotation) - Expect(k8sClient.Update(ctx, bs)).To(Succeed()) - - _, err = NewTestBackstageReconciler(ns).ReconcileAny(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{Name: backstageName, Namespace: ns}, - }) - Expect(err).To(Not(HaveOccurred())) - - By("verifying deployment replicas restored to 1") - Eventually(func(g Gomega) { - deploy, err := backstageDeployment(ctx, k8sClient, ns, backstageName) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(deploy.SpecReplicas()).To(HaveValue(BeEquivalentTo(1))) - }, time.Minute, time.Second).Should(Succeed()) - - By("verifying DB StatefulSet replicas restored to 1") - Eventually(func(g Gomega) { - ss := &appsv1.StatefulSet{} - err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: fmt.Sprintf("backstage-psql-%s", backstageName)}, ss) - g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(ss.Spec.Replicas).To(HaveValue(BeEquivalentTo(1))) - }, time.Minute, time.Second).Should(Succeed()) - - By("verifying status condition is no longer Idled") - Eventually(func(g Gomega) { - g.Expect(k8sClient.Get(ctx, types.NamespacedName{Name: backstageName, Namespace: ns}, bs)).To(Succeed()) - g.Expect(bs.Status.Conditions[0].Reason).NotTo(Equal("Idled")) - }, time.Minute, time.Second).Should(Succeed()) - }) - -}) From 583a6e754bdf7a3816d5e8a104709fbd4f83bd82 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Wed, 5 Aug 2026 16:55:18 +0200 Subject: [PATCH 08/10] fix formatting --- pkg/model/deployment_obj.go | 2 +- pkg/model/statefulset_obj.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/model/deployment_obj.go b/pkg/model/deployment_obj.go index 089f5dac3..dfea83455 100644 --- a/pkg/model/deployment_obj.go +++ b/pkg/model/deployment_obj.go @@ -13,7 +13,7 @@ import ( // compile-time checks var ( _ Deployable = (*DeploymentObj)(nil) - _ Idler = (*DeploymentObj)(nil) + _ Idler = (*DeploymentObj)(nil) ) type DeploymentObj struct { diff --git a/pkg/model/statefulset_obj.go b/pkg/model/statefulset_obj.go index 9f92ea978..fd9fe36e1 100644 --- a/pkg/model/statefulset_obj.go +++ b/pkg/model/statefulset_obj.go @@ -13,7 +13,7 @@ import ( // compile-time checks var ( _ Deployable = (*StatefulSetObj)(nil) - _ Idler = (*StatefulSetObj)(nil) + _ Idler = (*StatefulSetObj)(nil) ) type StatefulSetObj struct { From 947d88070e9ee4613381c5fe474a392b9fbadbbb Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Fri, 7 Aug 2026 11:00:37 +0200 Subject: [PATCH 09/10] refactor: simplify idle logic by removing Idler interface Inline the idle annotation check directly in each object's updateAndValidate method instead of using a separate Idler interface and centralized loop. This removes the abstraction layer in favor of straightforward replicas=0 assignment. Co-authored-by: Gennady Azarenkov Assisted-by: Claude --- docs/configuration.md | 1 - internal/controller/backstage_controller.go | 2 +- pkg/model/db-statefulset.go | 14 ++++---------- pkg/model/deployment.go | 16 +++++++++++----- pkg/model/deployment_obj.go | 12 ++---------- pkg/model/idler.go | 14 -------------- pkg/model/runtime.go | 9 --------- pkg/model/statefulset_obj.go | 12 ++---------- 8 files changed, 20 insertions(+), 60 deletions(-) delete mode 100644 pkg/model/idler.go diff --git a/docs/configuration.md b/docs/configuration.md index b41fe9ef6..4f6d60ee9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -29,7 +29,6 @@ It is highly recommended to read the [Design](design.md) document to understand - [Deployment Configuration](#deployment-configuration) - [Deployment Kind](#deployment-kind) - [Deployment Patching](#deployment-patching) - - [Instance Idling](#instance-idling) - [Database Configuration](#database-configuration) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 78ce50133..ff93790d5 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -115,7 +115,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } - if model.ShouldIdle(backstage) { + if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonIdled, "Instance is idled") return ctrl.Result{}, nil } diff --git a/pkg/model/db-statefulset.go b/pkg/model/db-statefulset.go index 871f2fe60..91f102f31 100644 --- a/pkg/model/db-statefulset.go +++ b/pkg/model/db-statefulset.go @@ -5,7 +5,6 @@ import ( "os" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/utils/ptr" corev1 "k8s.io/api/core/v1" @@ -97,16 +96,11 @@ func (b *DbStatefulSet) updateAndValidate(backstage api.Backstage, scheme *runti } } - return nil -} - -// compile-time check -var _ Idler = (*DbStatefulSet)(nil) - -func (b *DbStatefulSet) Idle() { - if b.statefulSet != nil { - b.statefulSet.Spec.Replicas = ptr.To(int32(0)) + if backstage.GetAnnotations()[IdleAnnotation] == "true" { + b.statefulSet.Spec.Replicas = new(int32) } + + return nil } func (b *DbStatefulSet) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) { diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 6a9172f0f..37f6ed844 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -154,14 +154,20 @@ func (b *BackstageDeployment) updateAndValidate(backstage api.Backstage, _ *runt return fmt.Errorf("can not add env vars from db secret: %w", err) } + if backstage.GetAnnotations()[IdleAnnotation] == "true" { + b.idle() + } + return nil } -// compile-time check -var _ Idler = (*BackstageDeployment)(nil) - -func (b *BackstageDeployment) Idle() { - b.deployable.(Idler).Idle() +func (b *BackstageDeployment) idle() { + switch d := b.deployable.(type) { + case *DeploymentObj: + d.Obj.Spec.Replicas = new(int32) + case *StatefulSetObj: + d.Obj.Spec.Replicas = new(int32) + } } func (b *BackstageDeployment) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) { diff --git a/pkg/model/deployment_obj.go b/pkg/model/deployment_obj.go index dfea83455..8c6fb0f4e 100644 --- a/pkg/model/deployment_obj.go +++ b/pkg/model/deployment_obj.go @@ -6,15 +6,11 @@ import ( appv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" ) -// compile-time checks -var ( - _ Deployable = (*DeploymentObj)(nil) - _ Idler = (*DeploymentObj)(nil) -) +// compile-time check +var _ Deployable = (*DeploymentObj)(nil) type DeploymentObj struct { Obj *appv1.Deployment @@ -60,10 +56,6 @@ func (d *DeploymentObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } -func (d *DeploymentObj) Idle() { - d.Obj.Spec.Replicas = ptr.To(int32(0)) -} - // toStatefulSet converts a Deployment to a StatefulSet func toStatefulSet(dep *appv1.Deployment) *appv1.StatefulSet { ss := &appv1.StatefulSet{ diff --git a/pkg/model/idler.go b/pkg/model/idler.go deleted file mode 100644 index a741930e4..000000000 --- a/pkg/model/idler.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -import "github.com/redhat-developer/rhdh-operator/api" - -// Idler is implemented by RuntimeObjects whose workloads can be scaled to -// zero when the Backstage CR carries the idle annotation. -type Idler interface { - Idle() -} - -// ShouldIdle reports whether the Backstage CR requests idling. -func ShouldIdle(backstage api.Backstage) bool { - return backstage.GetAnnotations()[IdleAnnotation] == "true" -} diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go index 74bf89884..a0536913a 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -206,15 +206,6 @@ func InitObjects(ctx context.Context, backstage api.Backstage, externalConfig Ex } } - // Phase 3: idle all workloads if the annotation requests it - if ShouldIdle(backstage) { - for _, obj := range model.RuntimeObjects { - if idleable, ok := obj.(Idler); ok { - idleable.Idle() - } - } - } - return model, nil } diff --git a/pkg/model/statefulset_obj.go b/pkg/model/statefulset_obj.go index fd9fe36e1..c0521e6aa 100644 --- a/pkg/model/statefulset_obj.go +++ b/pkg/model/statefulset_obj.go @@ -6,15 +6,11 @@ import ( appv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" ) -// compile-time checks -var ( - _ Deployable = (*StatefulSetObj)(nil) - _ Idler = (*StatefulSetObj)(nil) -) +// compile-time check +var _ Deployable = (*StatefulSetObj)(nil) type StatefulSetObj struct { Obj *appv1.StatefulSet @@ -60,10 +56,6 @@ func (d *StatefulSetObj) SpecReplicas() *int32 { return d.Obj.Spec.Replicas } -func (d *StatefulSetObj) Idle() { - d.Obj.Spec.Replicas = ptr.To(int32(0)) -} - // toDeployment converts a StatefulSet to a Deployment func toDeployment(ss *appv1.StatefulSet) *appv1.Deployment { dep := &appv1.Deployment{ From 05406daf397c02e05e9302d05969a39fc5a6eb97 Mon Sep 17 00:00:00 2001 From: Armel Soro Date: Fri, 7 Aug 2026 14:37:23 +0200 Subject: [PATCH 10/10] refactor: move idle status into setDeploymentStatus Determine the Idled status condition from the annotation inside setDeploymentStatus rather than short-circuiting before it. This reflects the actual deployment state and avoids falsely reporting Idled when a user explicitly sets replicas to 0 via spec.deployment.patch. Co-authored-by: Gennady Azarenkov Assisted-by: Claude --- internal/controller/backstage_controller.go | 5 ----- internal/controller/backstage_status.go | 11 ++++++++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index ff93790d5..49c832175 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -115,11 +115,6 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) } - if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { - setStatusCondition(&backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonIdled, "Instance is idled") - return ctrl.Result{}, nil - } - r.setDeploymentStatus(ctx, &backstage, *bsModel) return ctrl.Result{}, nil } diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index a5bb28b42..5be256cee 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -36,7 +36,14 @@ func (r *BackstageReconciler) setDeploymentStatus(ctx context.Context, backstage return } - state, msg := resolveState(obj) + var state api.BackstageConditionReason + var msg string + if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { + state = api.BackstageConditionReasonIdled + msg = "Instance is idled" + } else { + state, msg = resolveState(obj) + } status := metav1.ConditionFalse if state == api.BackstageConditionReasonDeployed { status = metav1.ConditionTrue @@ -87,8 +94,6 @@ func statefulSetState(deploy *appsv1.StatefulSet) (state api.BackstageConditionR if deploy.Spec.Replicas != nil { desired = *deploy.Spec.Replicas } - - //if deploy.Status.ReadyReplicas == desired { if deploy.Status.ReadyReplicas == desired && deploy.Status.CurrentReplicas == deploy.Status.UpdatedReplicas { return api.BackstageConditionReasonDeployed, "" }