-
Notifications
You must be signed in to change notification settings - Fork 41
feat: add rhdh.redhat.com/idle annotation for idling/waking workloads
#3318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4e26280
fd53655
820c33f
18a27ea
a02dddc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,59 @@ 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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There should not be any diff specified or not, Backstage spec should be untouchable. |
||
|
|
||
| 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 <cr-name> 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 <cr-name> 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| package integration_tests | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "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" | ||
|
|
||
| "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()) | ||
| }) | ||
|
|
||
| It("wakes with user-specified replicas from deployment patch", func() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need this test? |
||
| 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()) | ||
| }) | ||
|
|
||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,10 @@ func (d *DeploymentObj) SpecReplicas() *int32 { | |
| return d.Obj.Spec.Replicas | ||
| } | ||
|
|
||
| func (d *DeploymentObj) SetReplicas(r *int32) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. May be better to have something like Idle() function instead? |
||
| d.Obj.Spec.Replicas = r | ||
| } | ||
|
|
||
| // toStatefulSet converts a Deployment to a StatefulSet | ||
| func toStatefulSet(dep *appv1.Deployment) *appv1.StatefulSet { | ||
| ss := &appv1.StatefulSet{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure configuration.yaml is the best place for it.
Let's better consider admin.md ?