Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/current-types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions api/v1alpha5/backstage_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
BackstageConditionReasonDeployed BackstageConditionReason = "Deployed"
BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed"
BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress"
BackstageConditionReasonIdled BackstageConditionReason = "Idled"
)

// BackstageSpec defines the desired state of Backstage
Expand Down
54 changes: 54 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -913,6 +914,59 @@ spec:
$patch: delete
```

### Instance Idling

Copy link
Copy Markdown
Member

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 ?


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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.
Let's consider removing this section, it is confusing.


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:
Expand Down
171 changes: 171 additions & 0 deletions integration_tests/idle_test.go
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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this test?
It is a bit redundant IMO?

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

})
5 changes: 5 additions & 0 deletions internal/controller/backstage_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ 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
}
Expand Down
6 changes: 6 additions & 0 deletions pkg/model/db-statefulset.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"k8s.io/apimachinery/pkg/runtime"
"k8s.io/utils/ptr"

corev1 "k8s.io/api/core/v1"

Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/model/deployable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/model/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/model/deployment_obj.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ func (d *DeploymentObj) SpecReplicas() *int32 {
return d.Obj.Spec.Replicas
}

func (d *DeploymentObj) SetReplicas(r *int32) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be better to have something like Idle() function instead?
IMO it would be more straightforward and we hardly need replicas to be changed internally by other reason?

d.Obj.Spec.Replicas = r
}

// toStatefulSet converts a Deployment to a StatefulSet
func toStatefulSet(dep *appv1.Deployment) *appv1.StatefulSet {
ss := &appv1.StatefulSet{
Expand Down
38 changes: 38 additions & 0 deletions pkg/model/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,44 @@ func TestDeploymentKind(t *testing.T) {
assert.Equal(t, depPodSpec, ssPodSpec)
}

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)

model, err := InitObjects(context.TODO(), bs, testObj.externalConfig, platform.Default, testObj.scheme)
assert.NoError(t, err)

deployment := model.getDeployment()
assert.NotNil(t, deployment)
assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas())

dbSS := model.GetRuntimeObject(DbStatefulSetKey).(*DbStatefulSet)
assert.NotNil(t, dbSS)
assert.NotNil(t, dbSS.statefulSet)
assert.Equal(t, int32(0), *dbSS.statefulSet.Spec.Replicas)
}

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)

deployment := model.getDeployment()
assert.Equal(t, int32(0), *deployment.deployable.SpecReplicas())
}

func TestPatchedStatefulSet(t *testing.T) {
bs := *deploymentTestBackstage.DeepCopy()
bs.Spec.Deployment = &api.BackstageDeployment{}
Expand Down
1 change: 1 addition & 0 deletions pkg/model/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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"
Expand Down
4 changes: 4 additions & 0 deletions pkg/model/statefulset_obj.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading