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/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 4f6d60ee9..b41fe9ef6 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) diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 49c832175..78ce50133 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -115,6 +115,11 @@ 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) { + 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/pkg/model/db-statefulset.go b/pkg/model/db-statefulset.go index c9b76dbe7..871f2fe60 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,9 +96,19 @@ func (b *DbStatefulSet) updateAndValidate(backstage api.Backstage, scheme *runti b.setDbSecretEnvVar(b.container(), secret.Name) } } + 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)) + } +} + func (b *DbStatefulSet) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) { b.statefulSet.SetName(DbStatefulSetName(backstage.Name)) utils.GenerateLabel(&b.statefulSet.Spec.Template.Labels, BackstageAppLabel, utils.BackstageDbAppLabelValue(backstage.Name)) diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 9de637d62..3031bf9fd 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -141,6 +141,13 @@ func (b *BackstageDeployment) updateAndValidate(backstage api.Backstage, _ *runt 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 8c6fb0f4e..dfea83455 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,6 +60,10 @@ 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/deployment_test.go b/pkg/model/deployment_test.go index 5e0c35934..bf02efd6f 100644 --- a/pkg/model/deployment_test.go +++ b/pkg/model/deployment_test.go @@ -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{} 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 85dd0878a..74bf89884 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -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" @@ -205,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 c0521e6aa..fd9fe36e1 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,6 +60,10 @@ 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{