From b2d56500ba561cd567effd9b4ab875feff76a871 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 3 Aug 2026 19:07:23 +0300 Subject: [PATCH 01/13] added statuses and plugin list --- api/current-types.go | 15 +- api/v1alpha5/backstage_types.go | 15 ++ api/v1alpha5/zz_generated.deepcopy.go | 5 + .../crd/bases/rhdh.redhat.com_backstages.yaml | 6 + .../rhdh/default-config/deployment.yaml | 1 - internal/controller/backstage_controller.go | 21 ++- internal/controller/backstage_status.go | 153 ++++++++++++++++-- pkg/model/runtime.go | 14 +- 8 files changed, 203 insertions(+), 27 deletions(-) diff --git a/api/current-types.go b/api/current-types.go index 9f1a5d1b6..7f444b560 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -49,10 +49,23 @@ type ( // Condition constants const ( - BackstageConditionTypeDeployed BackstageConditionType = bsv1.BackstageConditionTypeDeployed + // Condition types + BackstageConditionTypeDeployed BackstageConditionType = bsv1.BackstageConditionTypeDeployed + BackstageConditionTypeRuntime BackstageConditionType = bsv1.BackstageConditionTypeRuntime + BackstageConditionTypeConfig BackstageConditionType = bsv1.BackstageConditionTypeConfig + + // Deployed condition reasons BackstageConditionReasonDeployed BackstageConditionReason = bsv1.BackstageConditionReasonDeployed BackstageConditionReasonFailed BackstageConditionReason = bsv1.BackstageConditionReasonFailed BackstageConditionReasonInProgress BackstageConditionReason = bsv1.BackstageConditionReasonInProgress + + // Runtime condition reasons + BackstageConditionReasonRunning BackstageConditionReason = bsv1.BackstageConditionReasonRunning + BackstageConditionReasonContainerFailed BackstageConditionReason = bsv1.BackstageConditionReasonContainerFailed + BackstageConditionReasonPending BackstageConditionReason = bsv1.BackstageConditionReasonPending + + // Config condition reasons + BackstageConditionReasonInvalid BackstageConditionReason = bsv1.BackstageConditionReasonInvalid ) // 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..0405a0473 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -11,11 +11,23 @@ type BackstageConditionReason string type BackstageConditionType string const ( + // Condition types BackstageConditionTypeDeployed BackstageConditionType = "Deployed" + BackstageConditionTypeRuntime BackstageConditionType = "Runtime" + BackstageConditionTypeConfig BackstageConditionType = "Config" + // Deployed condition reasons BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" + + // Runtime condition reasons + BackstageConditionReasonRunning BackstageConditionReason = "Running" + BackstageConditionReasonContainerFailed BackstageConditionReason = "ContainerFailed" + BackstageConditionReasonPending BackstageConditionReason = "Pending" + + // Config condition reasons + BackstageConditionReasonInvalid BackstageConditionReason = "Invalid" ) // BackstageSpec defines the desired state of Backstage @@ -266,6 +278,9 @@ type BackstageStatus struct { // Conditions is the list of conditions describing the state of the runtime // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` + // Plugins is the list of enabled dynamic plugin names, sorted alphabetically + // +optional + Plugins []string `json:"plugins,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 1ee510162..1aca07fcb 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -201,6 +201,11 @@ func (in *BackstageStatus) DeepCopyInto(out *BackstageStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Plugins != nil { + in, out := &in.Plugins, &out.Plugins + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackstageStatus. diff --git a/config/crd/bases/rhdh.redhat.com_backstages.yaml b/config/crd/bases/rhdh.redhat.com_backstages.yaml index 1d30468b9..48e99cd2e 100644 --- a/config/crd/bases/rhdh.redhat.com_backstages.yaml +++ b/config/crd/bases/rhdh.redhat.com_backstages.yaml @@ -2161,6 +2161,12 @@ spec: - type type: object type: array + plugins: + description: Plugins is the list of enabled dynamic plugin names, + sorted alphabetically + items: + type: string + type: array type: object type: object served: true diff --git a/config/profile/rhdh/default-config/deployment.yaml b/config/profile/rhdh/default-config/deployment.yaml index e90facb05..74f94de4c 100644 --- a/config/profile/rhdh/default-config/deployment.yaml +++ b/config/profile/rhdh/default-config/deployment.yaml @@ -68,7 +68,6 @@ spec: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "40000000" - # CATALOG_INDEX_IMAGE will be replaced by the value of the `RELATED_IMAGE_catalog_index` env var, if set - name: CATALOG_INDEX_IMAGE value: "quay.io/rhdh/plugin-catalog-index:next" - name: CATALOG_ENTITIES_EXTRACT_DIR diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 4ae61b061..6f160a44a 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "reflect" + "time" "github.com/redhat-developer/rhdh-operator/pkg/model/multiobject" "github.com/redhat-developer/rhdh-operator/pkg/platform" @@ -89,37 +90,41 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // 2. Make some validation to fail fast externalConfig, err := r.preprocessSpec(ctx, backstage) if err != nil { - return ctrl.Result{}, errorAndStatus(&backstage, "failed to preprocess backstage spec", err) + return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeConfig, api.BackstageConditionReasonInvalid, "failed to preprocess backstage spec", err) } // Apply the ServiceMonitor if monitoring is enabled if err := r.applyServiceMonitor(ctx, &backstage); err != nil { - return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply ServiceMonitor", err) + return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeDeployed, api.BackstageConditionReasonFailed, "failed to apply ServiceMonitor", err) } // This creates array of model objects to be reconciled bsModel, err := model.InitObjects(ctx, backstage, externalConfig, r.Platform, r.Scheme) if err != nil { - return ctrl.Result{}, errorAndStatus(&backstage, "failed to initialize backstage model", err) + return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeConfig, api.BackstageConditionReasonInvalid, "failed to initialize backstage model", err) } // Apply the plugin dependencies if err := r.applyPluginDeps(ctx, backstage, bsModel); err != nil { - return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply plugin dependencies", err) + return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeDeployed, api.BackstageConditionReasonFailed, "failed to apply plugin dependencies", err) } // Apply the runtime objects err = r.applyObjects(ctx, bsModel.GetRuntimeObjects()) if err != nil { - return ctrl.Result{}, errorAndStatus(&backstage, "failed to apply backstage objects", err) + return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeDeployed, api.BackstageConditionReasonFailed, "failed to apply backstage objects", err) } - r.setDeploymentStatus(ctx, &backstage, *bsModel) + isReady := r.reconcileStatus(ctx, &backstage, *bsModel) + if !isReady { + // Requeue to check pod status again (for init container failures, etc.) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } return ctrl.Result{}, nil } -func errorAndStatus(backstage *api.Backstage, msg string, err error) error { - setStatusCondition(backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonFailed, fmt.Sprintf("%s %s", msg, err)) +func errorAndStatus(backstage *api.Backstage, condType api.BackstageConditionType, reason api.BackstageConditionReason, msg string, err error) error { + setStatusCondition(backstage, condType, metav1.ConditionFalse, reason, err.Error()) return fmt.Errorf("%s: %w", msg, err) } diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index a5bb28b42..27e8e60ff 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -3,9 +3,11 @@ package controller import ( "context" "fmt" + "sort" "github.com/redhat-developer/rhdh-operator/api" "github.com/redhat-developer/rhdh-operator/pkg/model" + "github.com/redhat-developer/rhdh-operator/pkg/utils" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" @@ -14,7 +16,26 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -func (r *BackstageReconciler) setDeploymentStatus(ctx context.Context, backstage *api.Backstage, backstageModel model.BackstageModel) { +// reconcileStatus updates the Backstage CR status based on deployment and runtime state. +// Returns true if all conditions are satisfied, false otherwise. +func (r *BackstageReconciler) reconcileStatus(ctx context.Context, backstage *api.Backstage, backstageModel model.BackstageModel) bool { + // Set Deployed condition (Deployment/StatefulSet level) + deployedReady := r.setDeployedCondition(ctx, backstage, backstageModel) + + // Set Runtime condition (Pod/Container level) + runtimeHealthy := r.setRuntimeCondition(ctx, backstage) + + // Set enabled plugins in status + setEnabledPlugins(backstage, backstageModel) + + // Both conditions must be satisfied. Currently they track similar state, + // but Runtime could be extended to check additional health indicators + // (e.g., restart counts, probe failures) that Deployed doesn't capture. + return deployedReady && runtimeHealthy +} + +// setDeployedCondition sets the Deployed condition based on Deployment/StatefulSet status. +func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstage *api.Backstage, backstageModel model.BackstageModel) bool { var obj client.Object var resolveState func(client.Object) (api.BackstageConditionReason, string) @@ -33,15 +54,116 @@ func (r *BackstageReconciler) setDeploymentStatus(ctx context.Context, backstage if err := r.Get(ctx, types.NamespacedName{Name: model.DeploymentName(backstage.Name), Namespace: backstage.GetNamespace()}, obj); err != nil { setStatusCondition(backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonFailed, err.Error()) - return + return false } state, msg := resolveState(obj) + isReady := state == api.BackstageConditionReasonDeployed status := metav1.ConditionFalse - if state == api.BackstageConditionReasonDeployed { + if isReady { status = metav1.ConditionTrue } setStatusCondition(backstage, api.BackstageConditionTypeDeployed, status, state, msg) + + return isReady +} + +// setRuntimeCondition sets the Runtime condition based on Pod/Container status. +func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage *api.Backstage) bool { + reason, msg := r.getRuntimeState(ctx, backstage) + + isHealthy := reason == api.BackstageConditionReasonRunning + status := metav1.ConditionFalse + if isHealthy { + status = metav1.ConditionTrue + } + setStatusCondition(backstage, api.BackstageConditionTypeRuntime, status, reason, msg) + + return isHealthy +} + +// getRuntimeState checks pod/container state and returns appropriate reason and message. +func (r *BackstageReconciler) getRuntimeState(ctx context.Context, backstage *api.Backstage) (api.BackstageConditionReason, string) { + podList := &corev1.PodList{} + labelSelector := client.MatchingLabels{ + model.BackstageAppLabel: utils.BackstageAppLabelValue(backstage.Name), + } + if err := r.List(ctx, podList, client.InNamespace(backstage.Namespace), labelSelector); err != nil { + return api.BackstageConditionReasonPending, "unable to list pods" + } + + if len(podList.Items) == 0 { + return api.BackstageConditionReasonPending, "no pods found" + } + + // Sort pods by creation time, newest first + pods := podList.Items + sort.Slice(pods, func(i, j int) bool { + return pods[j].CreationTimestamp.Before(&pods[i].CreationTimestamp) + }) + + // Check pods - single pass for errors and readiness + for _, pod := range pods { + // Check init containers + for _, cs := range pod.Status.InitContainerStatuses { + if reason, msg := checkContainerState(cs, true); reason != "" { + return reason, msg + } + } + + // Check main containers + for _, cs := range pod.Status.ContainerStatuses { + if reason, msg := checkContainerState(cs, false); reason != "" { + return reason, msg + } + } + } + + return api.BackstageConditionReasonRunning, "" +} + +// checkContainerState checks a single container status and returns reason/message if not healthy. +func checkContainerState(cs corev1.ContainerStatus, isInit bool) (api.BackstageConditionReason, string) { + prefix := "container" + if isInit { + prefix = "init container" + } + + // Check for failures (Waiting with error reason) + if cs.State.Waiting != nil && cs.State.Waiting.Reason != "" { + if cs.State.Waiting.Reason == "PodInitializing" { + return "", "" // Normal state, not an error + } + return api.BackstageConditionReasonContainerFailed, fmt.Sprintf("%s %q: %s", prefix, cs.Name, cs.State.Waiting.Reason) + } + + // Check for terminated with error + if cs.State.Terminated != nil && cs.State.Terminated.ExitCode != 0 { + msg := fmt.Sprintf("%s %q failed with exit code %d", prefix, cs.Name, cs.State.Terminated.ExitCode) + if cs.State.Terminated.Message != "" { + msg += ": " + cs.State.Terminated.Message + } else if cs.State.Terminated.Reason != "" { + msg += " (" + cs.State.Terminated.Reason + ")" + } + return api.BackstageConditionReasonContainerFailed, msg + } + + // For init containers: must be terminated successfully + if isInit { + if cs.State.Running != nil { + return api.BackstageConditionReasonPending, fmt.Sprintf("%s %q running", prefix, cs.Name) + } + if cs.State.Terminated == nil { + return api.BackstageConditionReasonPending, fmt.Sprintf("%s %q not completed", prefix, cs.Name) + } + } else { + // For main containers: must be ready + if !cs.Ready { + return api.BackstageConditionReasonPending, fmt.Sprintf("%s %q not ready", prefix, cs.Name) + } + } + + return "", "" // Healthy } func setStatusCondition(backstage *api.Backstage, condType api.BackstageConditionType, status metav1.ConditionStatus, reason api.BackstageConditionReason, msg string) { @@ -54,6 +176,17 @@ func setStatusCondition(backstage *api.Backstage, condType api.BackstageConditio }) } +// setEnabledPlugins sets the list of enabled plugin names in status. +func setEnabledPlugins(backstage *api.Backstage, backstageModel model.BackstageModel) { + enabledPlugins := backstageModel.GetEnabledPlugins() + pluginNames := make([]string, 0, len(enabledPlugins)) + for _, p := range enabledPlugins { + pluginNames = append(pluginNames, p.Package) + } + sort.Strings(pluginNames) + backstage.Status.Plugins = pluginNames +} + func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionReason, msg string) { desired := int32(1) if deploy.Spec.Replicas != nil { @@ -74,12 +207,7 @@ func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionRea } } - // Fallback: aggregate condition info as in-progress - msg = "" - for _, c := range deploy.Status.Conditions { - msg += fmt.Sprintf(" %s=%s(%s);", c.Type, c.Status, c.Message) - } - return api.BackstageConditionReasonInProgress, msg + return api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) } func statefulSetState(deploy *appsv1.StatefulSet) (state api.BackstageConditionReason, msg string) { @@ -97,10 +225,5 @@ func statefulSetState(deploy *appsv1.StatefulSet) (state api.BackstageConditionR return api.BackstageConditionReasonInProgress, "no conditions reported yet" } - // Fallback: aggregate condition info as in-progress - msg = "" - for _, c := range deploy.Status.Conditions { - msg += fmt.Sprintf(" %s=%s(%s);", c.Type, c.Status, c.Message) - } - return api.BackstageConditionReasonInProgress, msg + return api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) } diff --git a/pkg/model/runtime.go b/pkg/model/runtime.go index 85dd0878a..9ba38e4cb 100644 --- a/pkg/model/runtime.go +++ b/pkg/model/runtime.go @@ -104,6 +104,17 @@ func (m *BackstageModel) GetDeploymentGVK() schema.GroupVersionKind { return deployment.deployable.GetObject().GetObjectKind().GroupVersionKind() } +// GetEnabledPlugins returns enabled plugins from the model. +// Returns empty slice if dynamic plugins don't exist in the model. +func (m *BackstageModel) GetEnabledPlugins() []DynaPlugin { + for _, obj := range m.RuntimeObjects { + if obj.GetKey() == DynamicPluginsKey { + return obj.(*DynamicPlugins).enabledPlugins + } + } + return []DynaPlugin{} +} + // Registers config object func registerConfig(key string, factory ObjectFactory, multiple bool, mergeFunc MergeConfigFunc) { for _, obj := range runtimeConfig { @@ -199,8 +210,7 @@ func InitObjects(ctx context.Context, backstage api.Backstage, externalConfig Ex // All objects are now in model, so cross-references are safe // Iterate over RuntimeObjects in their registration order (deterministic) for _, obj := range model.RuntimeObjects { - err := obj.updateAndValidate(backstage, scheme) - if err != nil { + if err := obj.updateAndValidate(backstage, scheme); err != nil { return nil, fmt.Errorf("failed object validation, reason: %w", err) } } From 006ddc2aac46348617cb73da478db2b416339bcd Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Tue, 4 Aug 2026 17:20:23 +0300 Subject: [PATCH 02/13] add status_test --- internal/controller/backstage_status_test.go | 435 +++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 internal/controller/backstage_status_test.go diff --git a/internal/controller/backstage_status_test.go b/internal/controller/backstage_status_test.go new file mode 100644 index 000000000..26675349d --- /dev/null +++ b/internal/controller/backstage_status_test.go @@ -0,0 +1,435 @@ +package controller + +import ( + "testing" + + "github.com/redhat-developer/rhdh-operator/api" + "github.com/stretchr/testify/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func TestCheckContainerState_MainContainer(t *testing.T) { + tests := []struct { + name string + status corev1.ContainerStatus + expectedReason api.BackstageConditionReason + expectedMsg string + }{ + { + name: "ready container", + status: corev1.ContainerStatus{ + Name: "backstage", + Ready: true, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + }, + expectedReason: "", + expectedMsg: "", + }, + { + name: "not ready container", + status: corev1.ContainerStatus{ + Name: "backstage", + Ready: false, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + }, + expectedReason: api.BackstageConditionReasonPending, + expectedMsg: `container "backstage" not ready`, + }, + { + name: "waiting with ImagePullBackOff", + status: corev1.ContainerStatus{ + Name: "backstage", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "ImagePullBackOff", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage": ImagePullBackOff`, + }, + { + name: "waiting with CrashLoopBackOff", + status: corev1.ContainerStatus{ + Name: "backstage", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "CrashLoopBackOff", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage": CrashLoopBackOff`, + }, + { + name: "terminated with error", + status: corev1.ContainerStatus{ + Name: "backstage", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Reason: "Error", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage" failed with exit code 1 (Error)`, + }, + { + name: "terminated with message", + status: corev1.ContainerStatus{ + Name: "backstage", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 137, + Message: "OOMKilled", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage" failed with exit code 137: OOMKilled`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, msg := checkContainerState(tt.status, false) + assert.Equal(t, tt.expectedReason, reason) + assert.Equal(t, tt.expectedMsg, msg) + }) + } +} + +func TestCheckContainerState_InitContainer(t *testing.T) { + tests := []struct { + name string + status corev1.ContainerStatus + expectedReason api.BackstageConditionReason + expectedMsg string + }{ + { + name: "terminated successfully", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + expectedReason: "", + expectedMsg: "", + }, + { + name: "still running", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + }, + expectedReason: api.BackstageConditionReasonPending, + expectedMsg: `init container "install-plugins" running`, + }, + { + name: "waiting PodInitializing", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "PodInitializing", + }, + }, + }, + expectedReason: "", + expectedMsg: "", + }, + { + name: "waiting with error", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{ + Waiting: &corev1.ContainerStateWaiting{ + Reason: "ImagePullBackOff", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `init container "install-plugins": ImagePullBackOff`, + }, + { + name: "terminated with error", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Message: "plugin download failed", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `init container "install-plugins" failed with exit code 1: plugin download failed`, + }, + { + name: "no state yet", + status: corev1.ContainerStatus{ + Name: "install-plugins", + State: corev1.ContainerState{}, + }, + expectedReason: api.BackstageConditionReasonPending, + expectedMsg: `init container "install-plugins" not completed`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, msg := checkContainerState(tt.status, true) + assert.Equal(t, tt.expectedReason, reason) + assert.Equal(t, tt.expectedMsg, msg) + }) + } +} + +func TestDeploymentState(t *testing.T) { + tests := []struct { + name string + deployment *appsv1.Deployment + expectedReason api.BackstageConditionReason + expectedMsg string + }{ + { + name: "all replicas ready", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 1, + }, + }, + expectedReason: api.BackstageConditionReasonDeployed, + expectedMsg: "", + }, + { + name: "multiple replicas ready", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 3, + }, + }, + expectedReason: api.BackstageConditionReasonDeployed, + expectedMsg: "", + }, + { + name: "partial replicas ready", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 1, + Conditions: []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue}, + }, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "1/3 replicas ready", + }, + { + name: "no replicas ready yet", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 0, + Conditions: []appsv1.DeploymentCondition{ + {Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue}, + }, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "0/1 replicas ready", + }, + { + name: "no conditions yet", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 0, + Conditions: []appsv1.DeploymentCondition{}, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "no conditions reported yet", + }, + { + name: "replica failure", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 0, + Conditions: []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentReplicaFailure, + Status: corev1.ConditionTrue, + Message: "quota exceeded", + }, + }, + }, + }, + expectedReason: api.BackstageConditionReasonFailed, + expectedMsg: "quota exceeded", + }, + { + name: "nil replicas defaults to 1", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: nil, + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 1, + }, + }, + expectedReason: api.BackstageConditionReasonDeployed, + expectedMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, msg := deploymentState(tt.deployment) + assert.Equal(t, tt.expectedReason, reason) + assert.Equal(t, tt.expectedMsg, msg) + }) + } +} + +func TestStatefulSetState(t *testing.T) { + tests := []struct { + name string + statefulset *appsv1.StatefulSet + expectedReason api.BackstageConditionReason + expectedMsg string + }{ + { + name: "all replicas ready and updated", + statefulset: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + CurrentReplicas: 1, + UpdatedReplicas: 1, + }, + }, + expectedReason: api.BackstageConditionReasonDeployed, + expectedMsg: "", + }, + { + name: "ready but not updated", + statefulset: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 1, + CurrentReplicas: 1, + UpdatedReplicas: 0, + Conditions: []appsv1.StatefulSetCondition{ + {Type: "Ready"}, + }, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "1/1 replicas ready", + }, + { + name: "partial replicas ready", + statefulset: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 2, + CurrentReplicas: 3, + UpdatedReplicas: 3, + Conditions: []appsv1.StatefulSetCondition{ + {Type: "Ready"}, + }, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "2/3 replicas ready", + }, + { + name: "no conditions yet", + statefulset: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Replicas: ptr.To(int32(1)), + }, + Status: appsv1.StatefulSetStatus{ + ReadyReplicas: 0, + Conditions: []appsv1.StatefulSetCondition{}, + }, + }, + expectedReason: api.BackstageConditionReasonInProgress, + expectedMsg: "no conditions reported yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reason, msg := statefulSetState(tt.statefulset) + assert.Equal(t, tt.expectedReason, reason) + assert.Equal(t, tt.expectedMsg, msg) + }) + } +} + +func TestSetStatusCondition(t *testing.T) { + backstage := &api.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "test-ns", + }, + } + + // Set initial condition + setStatusCondition(backstage, api.BackstageConditionTypeDeployed, metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "starting") + + assert.Len(t, backstage.Status.Conditions, 1) + assert.Equal(t, "Deployed", backstage.Status.Conditions[0].Type) + assert.Equal(t, metav1.ConditionFalse, backstage.Status.Conditions[0].Status) + assert.Equal(t, "DeployInProgress", backstage.Status.Conditions[0].Reason) + assert.Equal(t, "starting", backstage.Status.Conditions[0].Message) + + // Update existing condition + setStatusCondition(backstage, api.BackstageConditionTypeDeployed, metav1.ConditionTrue, api.BackstageConditionReasonDeployed, "") + + assert.Len(t, backstage.Status.Conditions, 1) + assert.Equal(t, metav1.ConditionTrue, backstage.Status.Conditions[0].Status) + assert.Equal(t, "Deployed", backstage.Status.Conditions[0].Reason) + + // Add another condition type + setStatusCondition(backstage, api.BackstageConditionTypeRuntime, metav1.ConditionTrue, api.BackstageConditionReasonRunning, "") + + assert.Len(t, backstage.Status.Conditions, 2) +} From 0c5001433bcd7e5f4f4227d63351d59f5881bfa7 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Fri, 7 Aug 2026 14:56:26 +0300 Subject: [PATCH 03/13] fix statuses, tests and doc --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- docs/design.md | 60 +++++++++++-- integration_tests/default-config_test.go | 13 +-- integration_tests/rhdh-config_test.go | 2 + internal/controller/backstage_status.go | 89 +++++++++++++------- internal/controller/backstage_status_test.go | 64 +++++++++++++- plugin-installer/install_plugins.sh | 12 ++- 11 files changed, 200 insertions(+), 50 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9b7acdd38..ca4d7a346 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index c3ecd6b25..1357c08bd 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index b315b789e..6613a8896 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index c9c126a0f..9c86c46ee 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 88b665d8a..1aca07fcb 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha5 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/docs/design.md b/docs/design.md index 70a1b2bed..db00fbf3d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -86,9 +86,59 @@ Kubernetes doesn’t allow you to change environment variables after a Pod has b ## Status -Backstage Custom Resource contains **Deployed** condition in the Status field. -It is updated by the Operator and can have the following values: -- **DeployInProgress** - Backstage Deployment is not available yet. The current state of Deployment can be seen in the message field -- **Deployed** - Backstage Deployment is being created and application is available -- **DeployFailed** - Backstage Deployment creation failed. The actual error can be seen in the message field +The Backstage Custom Resource contains status conditions that reflect the current state of the deployment. +The Operator maintains two primary condition types: **Deployed** and **Runtime**. + +### Deployed Condition + +The **Deployed** condition reflects the Deployment (or StatefulSet) level status: + +| Reason | Status | Description | +|--------|--------|-------------| +| `Deployed` | True | All replicas are ready. Message shows "X/X replicas ready" | +| `DeployInProgress` | False | Rollout in progress. Message shows "X/X replicas ready" or "no conditions reported yet" | +| `DeployFailed` | False | Deployment failed. Message contains the error details | + +### Runtime Condition + +The **Runtime** condition provides Pod and container-level status, offering more immediate feedback about application health: + +| Reason | Status | Description | +|--------|--------|-------------| +| `Running` | True | All containers are running and healthy | +| `ContainerFailed` | False | A container has failed. See table below for specific scenarios | +| `Pending` | False | Containers not yet ready (e.g., still starting, init container running) | + +**ContainerFailed scenarios:** + +| Scenario | Example Message | +|----------|-----------------| +| Image pull failure | `container "backstage-backend": ImagePullBackOff` | +| Crash loop | `container "backstage-backend" crashed (restart #3), last exit code 1: connection refused` | +| Container terminated | `container "backstage-backend" failed with exit code 137: OOMKilled` | +| Init container failure | `init container "install-plugins" failed with exit code 1: plugin download failed` | + +**Note:** Crash loop detection is smart about recovery. If a container has restart history but is currently Ready, it is considered healthy and will not be reported as crashed. + +### Example Status + +```yaml +status: + conditions: + - type: Deployed + status: "True" + reason: Deployed + message: "1/1 replicas ready" + - type: Runtime + status: "True" + reason: Running + message: "" + plugins: + - "@backstage/plugin-catalog" + - "@backstage/plugin-techdocs" +``` + +### Plugins Status + +When the deployment is healthy (both Deployed and Runtime conditions are True), the status includes a list of enabled dynamic plugins in the `plugins` field. This field is cleared when the deployment is not healthy. diff --git a/integration_tests/default-config_test.go b/integration_tests/default-config_test.go index 3515662f5..58c38e130 100644 --- a/integration_tests/default-config_test.go +++ b/integration_tests/default-config_test.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/redhat-developer/rhdh-operator/pkg/model" @@ -172,18 +173,18 @@ var _ = When("create default backstage", func() { bs := &api.Backstage{} err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: backstageName}, bs) g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(bs.Status.Conditions).To(HaveLen(1)) - g.Expect(bs.Status.Conditions[0].Reason).To(Equal("DeployInProgress")) - g.Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(bs.Status.Conditions).To(HaveLen(2)) + g.Expect(meta.FindStatusCondition(bs.Status.Conditions, string(api.BackstageConditionTypeDeployed)).Reason).To(Equal("DeployInProgress")) + g.Expect(meta.FindStatusCondition(bs.Status.Conditions, string(api.BackstageConditionTypeDeployed)).Status).To(Equal(metav1.ConditionFalse)) }, time.Minute, time.Second).Should(Succeed()) Eventually(func(g Gomega) { bs := &api.Backstage{} err := k8sClient.Get(ctx, types.NamespacedName{Namespace: ns, Name: backstageName}, bs) g.Expect(err).ShouldNot(HaveOccurred()) - g.Expect(bs.Status.Conditions).To(HaveLen(1)) - g.Expect(bs.Status.Conditions[0].Reason).To(Equal("Deployed")) - g.Expect(bs.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(bs.Status.Conditions).To(HaveLen(2)) + g.Expect(meta.FindStatusCondition(bs.Status.Conditions, string(api.BackstageConditionTypeDeployed)).Reason).To(Equal("Deployed")) + g.Expect(meta.FindStatusCondition(bs.Status.Conditions, string(api.BackstageConditionTypeDeployed)).Status).To(Equal(metav1.ConditionTrue)) }, 3*time.Minute, time.Second).Should(Succeed()) }) diff --git a/integration_tests/rhdh-config_test.go b/integration_tests/rhdh-config_test.go index c9ced8bfe..e81a4519a 100644 --- a/integration_tests/rhdh-config_test.go +++ b/integration_tests/rhdh-config_test.go @@ -249,6 +249,7 @@ var _ = When("create default rhdh", func() { }, 10*time.Second, time.Second).Should(Succeed()) + deleteNamespace(ctx, ns) }) It("replaces .npmrc", func() { @@ -307,6 +308,7 @@ var _ = When("create default rhdh", func() { }, 10*time.Second, time.Second).Should(Succeed()) + deleteNamespace(ctx, ns) }) It("creates rhdh with default Lightspeed flavour", func() { diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 27e8e60ff..99310d301 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -17,21 +17,26 @@ import ( ) // reconcileStatus updates the Backstage CR status based on deployment and runtime state. -// Returns true if all conditions are satisfied, false otherwise. +// Returns true if deployment is ready, false otherwise. func (r *BackstageReconciler) reconcileStatus(ctx context.Context, backstage *api.Backstage, backstageModel model.BackstageModel) bool { + // Set Runtime condition first (Pod/Container level) - more immediate state detection + // This catches crash loops before deployment status has chance to show brief "ready" windows + runtimeHealthy := r.setRuntimeCondition(ctx, backstage) + // Set Deployed condition (Deployment/StatefulSet level) deployedReady := r.setDeployedCondition(ctx, backstage, backstageModel) - // Set Runtime condition (Pod/Container level) - runtimeHealthy := r.setRuntimeCondition(ctx, backstage) + // Both deployment and runtime must be healthy for overall readiness + isReady := deployedReady && runtimeHealthy - // Set enabled plugins in status - setEnabledPlugins(backstage, backstageModel) + // Set enabled plugins only when fully healthy + if isReady { + setEnabledPlugins(backstage, backstageModel) + } else { + backstage.Status.Plugins = nil + } - // Both conditions must be satisfied. Currently they track similar state, - // but Runtime could be extended to check additional health indicators - // (e.g., restart counts, probe failures) that Deployed doesn't capture. - return deployedReady && runtimeHealthy + return isReady } // setDeployedCondition sets the Deployed condition based on Deployment/StatefulSet status. @@ -69,8 +74,22 @@ func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstag } // setRuntimeCondition sets the Runtime condition based on Pod/Container status. +// Provides detailed container-level info for debugging (complements Deployed condition). +// Returns true if runtime is healthy (all containers running), false otherwise. func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage *api.Backstage) bool { - reason, msg := r.getRuntimeState(ctx, backstage) + reason, msg := api.BackstageConditionReasonRunning, "" + + podList := &corev1.PodList{} + labelSelector := client.MatchingLabels{ + model.BackstageAppLabel: utils.BackstageAppLabelValue(backstage.Name), + } + if err := r.List(ctx, podList, client.InNamespace(backstage.Namespace), labelSelector); err != nil { + reason, msg = api.BackstageConditionReasonPending, "unable to list pods" + } else if len(podList.Items) == 0 { + reason, msg = api.BackstageConditionReasonPending, "no pods found" + } else { + reason, msg = r.checkPodStates(podList.Items) + } isHealthy := reason == api.BackstageConditionReasonRunning status := metav1.ConditionFalse @@ -82,28 +101,19 @@ func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage return isHealthy } -// getRuntimeState checks pod/container state and returns appropriate reason and message. -func (r *BackstageReconciler) getRuntimeState(ctx context.Context, backstage *api.Backstage) (api.BackstageConditionReason, string) { - podList := &corev1.PodList{} - labelSelector := client.MatchingLabels{ - model.BackstageAppLabel: utils.BackstageAppLabelValue(backstage.Name), - } - if err := r.List(ctx, podList, client.InNamespace(backstage.Namespace), labelSelector); err != nil { - return api.BackstageConditionReasonPending, "unable to list pods" - } - - if len(podList.Items) == 0 { - return api.BackstageConditionReasonPending, "no pods found" - } - +// checkPodStates checks pods for errors, returns Running if all healthy. +func (r *BackstageReconciler) checkPodStates(pods []corev1.Pod) (api.BackstageConditionReason, string) { // Sort pods by creation time, newest first - pods := podList.Items sort.Slice(pods, func(i, j int) bool { return pods[j].CreationTimestamp.Before(&pods[i].CreationTimestamp) }) - // Check pods - single pass for errors and readiness for _, pod := range pods { + // Check pod phase for terminal failures + if pod.Status.Phase == corev1.PodFailed { + return api.BackstageConditionReasonContainerFailed, fmt.Sprintf("pod %q failed", pod.Name) + } + // Check init containers for _, cs := range pod.Status.InitContainerStatuses { if reason, msg := checkContainerState(cs, true); reason != "" { @@ -117,6 +127,12 @@ func (r *BackstageReconciler) getRuntimeState(ctx context.Context, backstage *ap return reason, msg } } + + // If pod is not Running and no container issues found, it's still pending + // This catches the case where pod exists but no container statuses yet + if pod.Status.Phase != corev1.PodRunning { + return api.BackstageConditionReasonPending, fmt.Sprintf("pod %q is %s", pod.Name, pod.Status.Phase) + } } return api.BackstageConditionReasonRunning, "" @@ -148,6 +164,22 @@ func checkContainerState(cs corev1.ContainerStatus, isInit bool) (api.BackstageC return api.BackstageConditionReasonContainerFailed, msg } + // Check for crash loop: container has restarted with a previous failure + // For main containers: only report if not currently Ready (container may have recovered) + // For init containers: always report (they should complete successfully, not restart) + if cs.RestartCount > 0 && cs.LastTerminationState.Terminated != nil { + lastTerm := cs.LastTerminationState.Terminated + if lastTerm.ExitCode != 0 && (isInit || !cs.Ready) { + msg := fmt.Sprintf("%s %q crashed (restart #%d), last exit code %d", prefix, cs.Name, cs.RestartCount, lastTerm.ExitCode) + if lastTerm.Message != "" { + msg += ": " + lastTerm.Message + } else if lastTerm.Reason != "" { + msg += " (" + lastTerm.Reason + ")" + } + return api.BackstageConditionReasonContainerFailed, msg + } + } + // For init containers: must be terminated successfully if isInit { if cs.State.Running != nil { @@ -193,7 +225,7 @@ func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionRea desired = *deploy.Spec.Replicas } if deploy.Status.ReadyReplicas == desired { - return api.BackstageConditionReasonDeployed, "" + return api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } if len(deploy.Status.Conditions) == 0 { @@ -216,9 +248,8 @@ func statefulSetState(deploy *appsv1.StatefulSet) (state api.BackstageConditionR desired = *deploy.Spec.Replicas } - //if deploy.Status.ReadyReplicas == desired { if deploy.Status.ReadyReplicas == desired && deploy.Status.CurrentReplicas == deploy.Status.UpdatedReplicas { - return api.BackstageConditionReasonDeployed, "" + return api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } if len(deploy.Status.Conditions) == 0 { diff --git a/internal/controller/backstage_status_test.go b/internal/controller/backstage_status_test.go index 26675349d..a5de201e6 100644 --- a/internal/controller/backstage_status_test.go +++ b/internal/controller/backstage_status_test.go @@ -96,6 +96,44 @@ func TestCheckContainerState_MainContainer(t *testing.T) { expectedReason: api.BackstageConditionReasonContainerFailed, expectedMsg: `container "backstage" failed with exit code 137: OOMKilled`, }, + { + name: "crash loop - running but has previous failure", + status: corev1.ContainerStatus{ + Name: "backstage", + RestartCount: 3, + Ready: false, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + LastTerminationState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Message: "connection refused", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage" crashed (restart #3), last exit code 1: connection refused`, + }, + { + name: "recovered after crash - running and ready with restart history", + status: corev1.ContainerStatus{ + Name: "backstage", + RestartCount: 2, + Ready: true, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + LastTerminationState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Reason: "Error", + }, + }, + }, + expectedReason: "", + expectedMsg: "", + }, } for _, tt := range tests { @@ -187,6 +225,24 @@ func TestCheckContainerState_InitContainer(t *testing.T) { expectedReason: api.BackstageConditionReasonPending, expectedMsg: `init container "install-plugins" not completed`, }, + { + name: "crash loop - running but has previous failure", + status: corev1.ContainerStatus{ + Name: "install-plugins", + RestartCount: 2, + State: corev1.ContainerState{ + Running: &corev1.ContainerStateRunning{}, + }, + LastTerminationState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + Reason: "Error", + }, + }, + }, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `init container "install-plugins" crashed (restart #2), last exit code 1 (Error)`, + }, } for _, tt := range tests { @@ -216,7 +272,7 @@ func TestDeploymentState(t *testing.T) { }, }, expectedReason: api.BackstageConditionReasonDeployed, - expectedMsg: "", + expectedMsg: "1/1 replicas ready", }, { name: "multiple replicas ready", @@ -229,7 +285,7 @@ func TestDeploymentState(t *testing.T) { }, }, expectedReason: api.BackstageConditionReasonDeployed, - expectedMsg: "", + expectedMsg: "3/3 replicas ready", }, { name: "partial replicas ready", @@ -308,7 +364,7 @@ func TestDeploymentState(t *testing.T) { }, }, expectedReason: api.BackstageConditionReasonDeployed, - expectedMsg: "", + expectedMsg: "1/1 replicas ready", }, } @@ -341,7 +397,7 @@ func TestStatefulSetState(t *testing.T) { }, }, expectedReason: api.BackstageConditionReasonDeployed, - expectedMsg: "", + expectedMsg: "1/1 replicas ready", }, { name: "ready but not updated", diff --git a/plugin-installer/install_plugins.sh b/plugin-installer/install_plugins.sh index a8d5b2913..a17281062 100755 --- a/plugin-installer/install_plugins.sh +++ b/plugin-installer/install_plugins.sh @@ -730,8 +730,10 @@ echo "" # Use xargs for parallel execution # shellcheck disable=SC2016 # Single quotes intentional - variables expand in inner bash +# Capture xargs exit code - it returns 123 if any command fails +XARGS_EXIT=0 grep -v '^#' "${INPUT_FILE}" | grep -v '^$' | \ - xargs -P "${PARALLEL_JOBS}" -I {} bash -c 'download_plugin "$1" "$2"' _ {} "${OUTPUT_DIR}" + xargs -P "${PARALLEL_JOBS}" -I {} bash -c 'download_plugin "$1" "$2"' _ {} "${OUTPUT_DIR}" || XARGS_EXIT=$? echo "" @@ -747,6 +749,14 @@ if [[ -f "${FAILURE_LOG}" ]]; then # Write termination message with failure details write_termination_msg "$(cat "${FAILURE_LOG}")" exit 1 +elif [[ ${XARGS_EXIT} -ne 0 ]]; then + # Fallback if FAILURE_LOG wasn't created but xargs failed + echo "=== FAILED ===" + echo "Plugin installation failed (exit code ${XARGS_EXIT})" + echo "" + echo "Elapsed time: ${ELAPSED}s" + write_termination_msg "Plugin installation failed (exit code ${XARGS_EXIT})" + exit 1 fi echo "=== Complete ===" From 7f5f75140a356ac2232f4d15808522bdd093c41f Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 11:12:44 +0300 Subject: [PATCH 04/13] merge with latest changes --- api/current-types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/current-types.go b/api/current-types.go index ecafdfc2f..4d344f08a 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -66,7 +66,7 @@ const ( // Config condition reasons BackstageConditionReasonInvalid BackstageConditionReason = bsv1.BackstageConditionReasonInvalid - BackstageConditionReasonIdled BackstageConditionReason = bsv1.BackstageConditionReasonIdled + BackstageConditionReasonIdled BackstageConditionReason = bsv1.BackstageConditionReasonIdled ) // AddToScheme adds the current API version's types to the scheme. From 2e5dfd18cbee99d071f17a9f0f5c1d614b365e6a Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 11:21:41 +0300 Subject: [PATCH 05/13] lint fix --- internal/controller/backstage_status.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 1452611c9..3f84db3bc 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -85,7 +85,8 @@ func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstag // Provides detailed container-level info for debugging (complements Deployed condition). // Returns true if runtime is healthy (all containers running), false otherwise. func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage *api.Backstage) bool { - reason, msg := api.BackstageConditionReasonRunning, "" + var reason api.BackstageConditionReason + var msg string podList := &corev1.PodList{} labelSelector := client.MatchingLabels{ From 060b83f07b1044cfde10383e4562e85b7e1781ec Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 13:12:30 +0300 Subject: [PATCH 06/13] script fix and make bundle --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- .../backstage-operator.clusterserviceversion.yaml | 2 +- .../backstage.io/manifests/rhdh.redhat.com_backstages.yaml | 6 ++++++ .../backstage-operator.clusterserviceversion.yaml | 2 +- .../rhdh/manifests/rhdh-default-config_v1_configmap.yaml | 1 - bundle/rhdh/manifests/rhdh.redhat.com_backstages.yaml | 6 ++++++ dist/rhdh/install.yaml | 7 ++++++- plugin-installer/install_plugins.sh | 2 +- 12 files changed, 26 insertions(+), 10 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca4d7a346..9b7acdd38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1357c08bd..c3ecd6b25 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 6613a8896..b315b789e 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index 9c86c46ee..c9c126a0f 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 1aca07fcb..88b665d8a 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha5 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 37a8caf68..7c327b479 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-06T10:03:19Z" + createdAt: "2026-08-10T10:11:38Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/backstage.io/manifests/rhdh.redhat.com_backstages.yaml b/bundle/backstage.io/manifests/rhdh.redhat.com_backstages.yaml index 372b4ca38..43b9eb452 100644 --- a/bundle/backstage.io/manifests/rhdh.redhat.com_backstages.yaml +++ b/bundle/backstage.io/manifests/rhdh.redhat.com_backstages.yaml @@ -2161,6 +2161,12 @@ spec: - type type: object type: array + plugins: + description: Plugins is the list of enabled dynamic plugin names, + sorted alphabetically + items: + type: string + type: array type: object type: object served: true diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 4a429fab3..8b98d2441 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-06T10:03:16Z" + createdAt: "2026-08-10T10:11:35Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml index 113291749..ef5b934e8 100644 --- a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml +++ b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml @@ -227,7 +227,6 @@ data: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "40000000" - # CATALOG_INDEX_IMAGE will be replaced by the value of the `RELATED_IMAGE_catalog_index` env var, if set - name: CATALOG_INDEX_IMAGE value: "quay.io/rhdh/plugin-catalog-index:next" - name: CATALOG_ENTITIES_EXTRACT_DIR diff --git a/bundle/rhdh/manifests/rhdh.redhat.com_backstages.yaml b/bundle/rhdh/manifests/rhdh.redhat.com_backstages.yaml index 372b4ca38..43b9eb452 100644 --- a/bundle/rhdh/manifests/rhdh.redhat.com_backstages.yaml +++ b/bundle/rhdh/manifests/rhdh.redhat.com_backstages.yaml @@ -2161,6 +2161,12 @@ spec: - type type: object type: array + plugins: + description: Plugins is the list of enabled dynamic plugin names, + sorted alphabetically + items: + type: string + type: array type: object type: object served: true diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index d7f1c8f30..5e1cf15f2 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -2173,6 +2173,12 @@ spec: - type type: object type: array + plugins: + description: Plugins is the list of enabled dynamic plugin names, + sorted alphabetically + items: + type: string + type: array type: object type: object served: true @@ -2813,7 +2819,6 @@ data: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "40000000" - # CATALOG_INDEX_IMAGE will be replaced by the value of the `RELATED_IMAGE_catalog_index` env var, if set - name: CATALOG_INDEX_IMAGE value: "quay.io/rhdh/plugin-catalog-index:next" - name: CATALOG_ENTITIES_EXTRACT_DIR diff --git a/plugin-installer/install_plugins.sh b/plugin-installer/install_plugins.sh index a17281062..1a343ebd0 100755 --- a/plugin-installer/install_plugins.sh +++ b/plugin-installer/install_plugins.sh @@ -729,9 +729,9 @@ echo "=== Downloading ${total} plugins to ${OUTPUT_DIR} (${PARALLEL_JOBS} parall echo "" # Use xargs for parallel execution -# shellcheck disable=SC2016 # Single quotes intentional - variables expand in inner bash # Capture xargs exit code - it returns 123 if any command fails XARGS_EXIT=0 +# shellcheck disable=SC2016 # Single quotes intentional - $1/$2 expand in inner bash grep -v '^#' "${INPUT_FILE}" | grep -v '^$' | \ xargs -P "${PARALLEL_JOBS}" -I {} bash -c 'download_plugin "$1" "$2"' _ {} "${OUTPUT_DIR}" || XARGS_EXIT=$? From c07a823f9dc72ad81a1ed3627b29c6182cfae412 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 13:46:10 +0300 Subject: [PATCH 07/13] make bundle --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- dist/backstage.io/install.yaml | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 7c327b479..36a4bfcdf 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-10T10:11:38Z" + createdAt: "2026-08-10T10:28:00Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 8b98d2441..fe6e9fea8 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-10T10:11:35Z" + createdAt: "2026-08-10T10:27:57Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/dist/backstage.io/install.yaml b/dist/backstage.io/install.yaml index 2bf27cedf..7de429f06 100644 --- a/dist/backstage.io/install.yaml +++ b/dist/backstage.io/install.yaml @@ -2173,6 +2173,12 @@ spec: - type type: object type: array + plugins: + description: Plugins is the list of enabled dynamic plugin names, + sorted alphabetically + items: + type: string + type: array type: object type: object served: true From 95246b681ae38f5373912a4bd3a96ab871b44942 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 10 Aug 2026 16:09:53 +0300 Subject: [PATCH 08/13] remove idled condition --- api/current-types.go | 1 - api/v1alpha5/backstage_types.go | 1 - ...kstage-operator.clusterserviceversion.yaml | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- docs/design.md | 21 ++++++++++++++++++- internal/controller/backstage_status.go | 10 +++------ 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/api/current-types.go b/api/current-types.go index 4d344f08a..7f444b560 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -66,7 +66,6 @@ const ( // Config condition reasons BackstageConditionReasonInvalid BackstageConditionReason = bsv1.BackstageConditionReasonInvalid - 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 b5c3880d6..0405a0473 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -20,7 +20,6 @@ const ( BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" - BackstageConditionReasonIdled BackstageConditionReason = "Idled" // Runtime condition reasons BackstageConditionReasonRunning BackstageConditionReason = "Running" diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 36a4bfcdf..2d10f9430 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-10T10:28:00Z" + createdAt: "2026-08-10T13:07:07Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index fe6e9fea8..1385ac004 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-10T10:27:57Z" + createdAt: "2026-08-10T13:07:04Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/docs/design.md b/docs/design.md index db00fbf3d..a3a57da35 100644 --- a/docs/design.md +++ b/docs/design.md @@ -99,6 +99,8 @@ The **Deployed** condition reflects the Deployment (or StatefulSet) level status | `DeployInProgress` | False | Rollout in progress. Message shows "X/X replicas ready" or "no conditions reported yet" | | `DeployFailed` | False | Deployment failed. Message contains the error details | +**Idled instances:** When the `rhdh.redhat.com/idle: true` annotation is set, the deployment scales to 0 replicas. The Deployed condition shows `Deployed` with message "0/0 replicas ready (Idled)". + ### Runtime Condition The **Runtime** condition provides Pod and container-level status, offering more immediate feedback about application health: @@ -138,7 +140,24 @@ status: - "@backstage/plugin-techdocs" ``` +### Idled Example + +When idled via annotation `rhdh.redhat.com/idle: true`: + +```yaml +status: + conditions: + - type: Deployed + status: "True" + reason: Deployed + message: "0/0 replicas ready (Idled)" + - type: Runtime + status: "False" + reason: Pending + message: "no pods found" +``` + ### Plugins Status -When the deployment is healthy (both Deployed and Runtime conditions are True), the status includes a list of enabled dynamic plugins in the `plugins` field. This field is cleared when the deployment is not healthy. +When the deployment is healthy (both Deployed and Runtime conditions are True), the status includes a list of enabled dynamic plugins in the `plugins` field. This field is cleared when the deployment is not healthy or idled. diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 3f84db3bc..699e878f0 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -62,15 +62,11 @@ func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstag return false } - var state api.BackstageConditionReason - var msg string + state, msg := resolveState(obj) if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { - state = api.BackstageConditionReasonIdled - msg = "Instance is idled" - } else { - state, msg = resolveState(obj) + msg += " (Idled)" } - //state, msg := resolveState(obj) + isReady := state == api.BackstageConditionReasonDeployed status := metav1.ConditionFalse if isReady { From 345bb93698797a4e204e228eba8387d6874dc495 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 20 Aug 2026 15:02:36 +0300 Subject: [PATCH 09/13] fixes --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- ...kstage-operator.clusterserviceversion.yaml | 3 +- config/rbac/role.yaml | 1 + internal/controller/backstage_controller.go | 5 +- internal/controller/backstage_status.go | 80 +++--- internal/controller/backstage_status_test.go | 227 +++++++++++++++++- 10 files changed, 279 insertions(+), 47 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9b7acdd38..ca4d7a346 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index c3ecd6b25..1357c08bd 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index b315b789e..6613a8896 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index c9c126a0f..9c86c46ee 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 88b665d8a..1aca07fcb 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha5 import ( - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 1385ac004..2ef7e9a03 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-10T13:07:04Z" + createdAt: "2026-08-20T11:00:23Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed @@ -121,6 +121,7 @@ spec: - "" resources: - persistentvolumes + - pods verbs: - get - list diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index fb0109902..aea779798 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -23,6 +23,7 @@ rules: - "" resources: - persistentvolumes + - pods verbs: - get - list diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 9b3f70037..2c06572be 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -48,7 +48,7 @@ type BackstageReconciler struct { // +kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages/status,verbs=get;update;patch // +kubebuilder:rbac:groups=rhdh.redhat.com,resources=backstages/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=configmaps;secrets;services;persistentvolumeclaims,verbs=get;watch;create;update;list;delete;patch -// +kubebuilder:rbac:groups="",resources=persistentvolumes,verbs=get;list;watch +// +kubebuilder:rbac:groups="",resources=pods;persistentvolumes,verbs=get;list;watch // +kubebuilder:rbac:groups="apps",resources=deployments;statefulsets,verbs=get;watch;create;update;list;delete;patch // +kubebuilder:rbac:groups="route.openshift.io",resources=routes;routes/custom-host,verbs=get;watch;create;update;list;delete;patch // +kubebuilder:rbac:groups="config.openshift.io",resources=ingresses,verbs=get;list;watch @@ -105,6 +105,9 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeConfig, api.BackstageConditionReasonInvalid, "failed to initialize backstage model", err) } + // Config is valid - remove any stale Config condition from previous failures + removeStatusCondition(&backstage, api.BackstageConditionTypeConfig) + // Apply the plugin dependencies if err := r.applyPluginDeps(ctx, backstage, bsModel); err != nil { return ctrl.Result{}, errorAndStatus(&backstage, api.BackstageConditionTypeDeployed, api.BackstageConditionReasonFailed, "failed to apply plugin dependencies", err) diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 699e878f0..7e23e647a 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -42,17 +42,17 @@ func (r *BackstageReconciler) reconcileStatus(ctx context.Context, backstage *ap // setDeployedCondition sets the Deployed condition based on Deployment/StatefulSet status. func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstage *api.Backstage, backstageModel model.BackstageModel) bool { var obj client.Object - var resolveState func(client.Object) (api.BackstageConditionReason, string) + var resolveState func(client.Object) (metav1.ConditionStatus, api.BackstageConditionReason, string) switch backstageModel.GetDeploymentGVK() { case appsv1.SchemeGroupVersion.WithKind("StatefulSet"): obj = &appsv1.StatefulSet{} - resolveState = func(o client.Object) (api.BackstageConditionReason, string) { + resolveState = func(o client.Object) (metav1.ConditionStatus, api.BackstageConditionReason, string) { return statefulSetState(o.(*appsv1.StatefulSet)) } default: obj = &appsv1.Deployment{} - resolveState = func(o client.Object) (api.BackstageConditionReason, string) { + resolveState = func(o client.Object) (metav1.ConditionStatus, api.BackstageConditionReason, string) { return deploymentState(o.(*appsv1.Deployment)) } } @@ -62,25 +62,20 @@ func (r *BackstageReconciler) setDeployedCondition(ctx context.Context, backstag return false } - state, msg := resolveState(obj) - if backstage.GetAnnotations()[model.IdleAnnotation] == "true" { + status, reason, msg := resolveState(obj) + if isIdled(backstage) { msg += " (Idled)" } + setStatusCondition(backstage, api.BackstageConditionTypeDeployed, status, reason, msg) - isReady := state == api.BackstageConditionReasonDeployed - status := metav1.ConditionFalse - if isReady { - status = metav1.ConditionTrue - } - setStatusCondition(backstage, api.BackstageConditionTypeDeployed, status, state, msg) - - return isReady + return status == metav1.ConditionTrue } // setRuntimeCondition sets the Runtime condition based on Pod/Container status. // Provides detailed container-level info for debugging (complements Deployed condition). // Returns true if runtime is healthy (all containers running), false otherwise. func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage *api.Backstage) bool { + var status metav1.ConditionStatus var reason api.BackstageConditionReason var msg string @@ -88,16 +83,17 @@ func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage labelSelector := client.MatchingLabels{ model.BackstageAppLabel: utils.BackstageAppLabelValue(backstage.Name), } + if err := r.List(ctx, podList, client.InNamespace(backstage.Namespace), labelSelector); err != nil { - reason, msg = api.BackstageConditionReasonPending, "unable to list pods" + status, reason, msg = metav1.ConditionFalse, api.BackstageConditionReasonPending, "unable to list pods" } else if len(podList.Items) == 0 { - reason, msg = api.BackstageConditionReasonPending, "no pods found" + status, reason, msg = metav1.ConditionFalse, api.BackstageConditionReasonPending, "no pods found" } else { - reason, msg = r.checkPodStates(podList.Items) + status, reason, msg = checkPodStates(podList.Items) } - isHealthy := reason == api.BackstageConditionReasonRunning - status := metav1.ConditionFalse + // Idled instances have 0 pods by design - consider healthy to avoid requeue loop + isHealthy := status == metav1.ConditionTrue || isIdled(backstage) if isHealthy { status = metav1.ConditionTrue } @@ -106,8 +102,8 @@ func (r *BackstageReconciler) setRuntimeCondition(ctx context.Context, backstage return isHealthy } -// checkPodStates checks pods for errors, returns Running if all healthy. -func (r *BackstageReconciler) checkPodStates(pods []corev1.Pod) (api.BackstageConditionReason, string) { +// checkPodStates checks pods for errors, returns Running/True if all healthy. +func checkPodStates(pods []corev1.Pod) (metav1.ConditionStatus, api.BackstageConditionReason, string) { // Sort pods by creation time, newest first sort.Slice(pods, func(i, j int) bool { return pods[j].CreationTimestamp.Before(&pods[i].CreationTimestamp) @@ -116,31 +112,31 @@ func (r *BackstageReconciler) checkPodStates(pods []corev1.Pod) (api.BackstageCo for _, pod := range pods { // Check pod phase for terminal failures if pod.Status.Phase == corev1.PodFailed { - return api.BackstageConditionReasonContainerFailed, fmt.Sprintf("pod %q failed", pod.Name) + return metav1.ConditionFalse, api.BackstageConditionReasonContainerFailed, fmt.Sprintf("pod %q failed", pod.Name) } // Check init containers for _, cs := range pod.Status.InitContainerStatuses { if reason, msg := checkContainerState(cs, true); reason != "" { - return reason, msg + return metav1.ConditionFalse, reason, msg } } // Check main containers for _, cs := range pod.Status.ContainerStatuses { if reason, msg := checkContainerState(cs, false); reason != "" { - return reason, msg + return metav1.ConditionFalse, reason, msg } } // If pod is not Running and no container issues found, it's still pending // This catches the case where pod exists but no container statuses yet if pod.Status.Phase != corev1.PodRunning { - return api.BackstageConditionReasonPending, fmt.Sprintf("pod %q is %s", pod.Name, pod.Status.Phase) + return metav1.ConditionFalse, api.BackstageConditionReasonPending, fmt.Sprintf("pod %q is %s", pod.Name, pod.Status.Phase) } } - return api.BackstageConditionReasonRunning, "" + return metav1.ConditionTrue, api.BackstageConditionReasonRunning, "" } // checkContainerState checks a single container status and returns reason/message if not healthy. @@ -213,6 +209,14 @@ func setStatusCondition(backstage *api.Backstage, condType api.BackstageConditio }) } +func removeStatusCondition(backstage *api.Backstage, condType api.BackstageConditionType) { + meta.RemoveStatusCondition(&backstage.Status.Conditions, string(condType)) +} + +func isIdled(backstage *api.Backstage) bool { + return backstage.GetAnnotations()[model.IdleAnnotation] == "true" +} + // setEnabledPlugins sets the list of enabled plugin names in status. func setEnabledPlugins(backstage *api.Backstage, backstageModel model.BackstageModel) { enabledPlugins := backstageModel.GetEnabledPlugins() @@ -224,42 +228,42 @@ func setEnabledPlugins(backstage *api.Backstage, backstageModel model.BackstageM backstage.Status.Plugins = pluginNames } -func deploymentState(deploy *appsv1.Deployment) (state api.BackstageConditionReason, msg string) { +func deploymentState(deploy *appsv1.Deployment) (metav1.ConditionStatus, api.BackstageConditionReason, string) { desired := int32(1) if deploy.Spec.Replicas != nil { desired = *deploy.Spec.Replicas } if deploy.Status.ReadyReplicas == desired { - return api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) + return metav1.ConditionTrue, api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } if len(deploy.Status.Conditions) == 0 { - return api.BackstageConditionReasonInProgress, "no conditions reported yet" + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "no conditions reported yet" } // Prefer explicit failure indicators for _, c := range deploy.Status.Conditions { if c.Type == appsv1.DeploymentReplicaFailure && c.Status == corev1.ConditionTrue { - return api.BackstageConditionReasonFailed, c.Message + return metav1.ConditionFalse, api.BackstageConditionReasonFailed, c.Message } } - return api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) } -func statefulSetState(deploy *appsv1.StatefulSet) (state api.BackstageConditionReason, msg string) { +func statefulSetState(sts *appsv1.StatefulSet) (metav1.ConditionStatus, api.BackstageConditionReason, string) { desired := int32(1) - if deploy.Spec.Replicas != nil { - desired = *deploy.Spec.Replicas + if sts.Spec.Replicas != nil { + desired = *sts.Spec.Replicas } - if deploy.Status.ReadyReplicas == desired && deploy.Status.CurrentReplicas == deploy.Status.UpdatedReplicas { - return api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) + if sts.Status.ReadyReplicas == desired && sts.Status.CurrentReplicas == sts.Status.UpdatedReplicas { + return metav1.ConditionTrue, api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } - if len(deploy.Status.Conditions) == 0 { - return api.BackstageConditionReasonInProgress, "no conditions reported yet" + if len(sts.Status.Conditions) == 0 { + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "no conditions reported yet" } - return api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, desired) } diff --git a/internal/controller/backstage_status_test.go b/internal/controller/backstage_status_test.go index a5de201e6..d660edc4c 100644 --- a/internal/controller/backstage_status_test.go +++ b/internal/controller/backstage_status_test.go @@ -2,6 +2,7 @@ package controller import ( "testing" + "time" "github.com/redhat-developer/rhdh-operator/api" "github.com/stretchr/testify/assert" @@ -258,6 +259,7 @@ func TestDeploymentState(t *testing.T) { tests := []struct { name string deployment *appsv1.Deployment + expectedStatus metav1.ConditionStatus expectedReason api.BackstageConditionReason expectedMsg string }{ @@ -271,6 +273,7 @@ func TestDeploymentState(t *testing.T) { ReadyReplicas: 1, }, }, + expectedStatus: metav1.ConditionTrue, expectedReason: api.BackstageConditionReasonDeployed, expectedMsg: "1/1 replicas ready", }, @@ -284,6 +287,7 @@ func TestDeploymentState(t *testing.T) { ReadyReplicas: 3, }, }, + expectedStatus: metav1.ConditionTrue, expectedReason: api.BackstageConditionReasonDeployed, expectedMsg: "3/3 replicas ready", }, @@ -300,6 +304,7 @@ func TestDeploymentState(t *testing.T) { }, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "1/3 replicas ready", }, @@ -316,6 +321,7 @@ func TestDeploymentState(t *testing.T) { }, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "0/1 replicas ready", }, @@ -330,6 +336,7 @@ func TestDeploymentState(t *testing.T) { Conditions: []appsv1.DeploymentCondition{}, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "no conditions reported yet", }, @@ -350,6 +357,7 @@ func TestDeploymentState(t *testing.T) { }, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonFailed, expectedMsg: "quota exceeded", }, @@ -363,6 +371,7 @@ func TestDeploymentState(t *testing.T) { ReadyReplicas: 1, }, }, + expectedStatus: metav1.ConditionTrue, expectedReason: api.BackstageConditionReasonDeployed, expectedMsg: "1/1 replicas ready", }, @@ -370,7 +379,8 @@ func TestDeploymentState(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reason, msg := deploymentState(tt.deployment) + status, reason, msg := deploymentState(tt.deployment) + assert.Equal(t, tt.expectedStatus, status) assert.Equal(t, tt.expectedReason, reason) assert.Equal(t, tt.expectedMsg, msg) }) @@ -381,6 +391,7 @@ func TestStatefulSetState(t *testing.T) { tests := []struct { name string statefulset *appsv1.StatefulSet + expectedStatus metav1.ConditionStatus expectedReason api.BackstageConditionReason expectedMsg string }{ @@ -396,6 +407,7 @@ func TestStatefulSetState(t *testing.T) { UpdatedReplicas: 1, }, }, + expectedStatus: metav1.ConditionTrue, expectedReason: api.BackstageConditionReasonDeployed, expectedMsg: "1/1 replicas ready", }, @@ -414,6 +426,7 @@ func TestStatefulSetState(t *testing.T) { }, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "1/1 replicas ready", }, @@ -432,6 +445,7 @@ func TestStatefulSetState(t *testing.T) { }, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "2/3 replicas ready", }, @@ -446,6 +460,7 @@ func TestStatefulSetState(t *testing.T) { Conditions: []appsv1.StatefulSetCondition{}, }, }, + expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, expectedMsg: "no conditions reported yet", }, @@ -453,7 +468,8 @@ func TestStatefulSetState(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reason, msg := statefulSetState(tt.statefulset) + status, reason, msg := statefulSetState(tt.statefulset) + assert.Equal(t, tt.expectedStatus, status) assert.Equal(t, tt.expectedReason, reason) assert.Equal(t, tt.expectedMsg, msg) }) @@ -489,3 +505,210 @@ func TestSetStatusCondition(t *testing.T) { assert.Len(t, backstage.Status.Conditions, 2) } + +func TestCheckPodStates(t *testing.T) { + tests := []struct { + name string + pods []corev1.Pod + expectedStatus metav1.ConditionStatus + expectedReason api.BackstageConditionReason + expectedMsg string + }{ + { + name: "single healthy pod", + pods: []corev1.Pod{ + { + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "backstage", Ready: true, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, + }, + }, + }, + }, + expectedStatus: metav1.ConditionTrue, + expectedReason: api.BackstageConditionReasonRunning, + expectedMsg: "", + }, + { + name: "pod in failed phase", + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "backstage-abc"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `pod "backstage-abc" failed`, + }, + { + name: "pod pending phase", + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "backstage-abc"}, + Status: corev1.PodStatus{Phase: corev1.PodPending}, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonPending, + expectedMsg: `pod "backstage-abc" is Pending`, + }, + { + name: "init container failed", + pods: []corev1.Pod{ + { + Status: corev1.PodStatus{ + Phase: corev1.PodPending, + InitContainerStatuses: []corev1.ContainerStatus{ + { + Name: "install-plugins", + State: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 1, Reason: "Error"}, + }, + }, + }, + }, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `init container "install-plugins" failed with exit code 1 (Error)`, + }, + { + name: "main container crash loop", + pods: []corev1.Pod{ + { + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + { + Name: "backstage", + Ready: false, + RestartCount: 5, + State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}, + LastTerminationState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ExitCode: 137, Message: "OOMKilled"}, + }, + }, + }, + }, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `container "backstage" crashed (restart #5), last exit code 137: OOMKilled`, + }, + { + name: "multiple pods - newest checked first", + pods: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "backstage-old", + CreationTimestamp: metav1.Time{Time: metav1.Now().Add(-time.Hour)}, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "backstage", Ready: true, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "backstage-new", + CreationTimestamp: metav1.Now(), + }, + Status: corev1.PodStatus{Phase: corev1.PodFailed}, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonContainerFailed, + expectedMsg: `pod "backstage-new" failed`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, reason, msg := checkPodStates(tt.pods) + assert.Equal(t, tt.expectedStatus, status) + assert.Equal(t, tt.expectedReason, reason) + assert.Equal(t, tt.expectedMsg, msg) + }) + } +} + +func TestIsIdled(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + expected bool + }{ + { + name: "no annotations", + annotations: nil, + expected: false, + }, + { + name: "empty annotations", + annotations: map[string]string{}, + expected: false, + }, + { + name: "idle annotation true", + annotations: map[string]string{"rhdh.redhat.com/idle": "true"}, + expected: true, + }, + { + name: "idle annotation false", + annotations: map[string]string{"rhdh.redhat.com/idle": "false"}, + expected: false, + }, + { + name: "idle annotation other value", + annotations: map[string]string{"rhdh.redhat.com/idle": "yes"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backstage := &api.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: tt.annotations, + }, + } + assert.Equal(t, tt.expected, isIdled(backstage)) + }) + } +} + +func TestRemoveStatusCondition(t *testing.T) { + backstage := &api.Backstage{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-bs", + Namespace: "test-ns", + }, + } + + // Set multiple conditions + setStatusCondition(backstage, api.BackstageConditionTypeDeployed, metav1.ConditionTrue, api.BackstageConditionReasonDeployed, "ready") + setStatusCondition(backstage, api.BackstageConditionTypeConfig, metav1.ConditionFalse, api.BackstageConditionReasonInvalid, "invalid config") + setStatusCondition(backstage, api.BackstageConditionTypeRuntime, metav1.ConditionTrue, api.BackstageConditionReasonRunning, "") + + assert.Len(t, backstage.Status.Conditions, 3) + + // Remove Config condition + removeStatusCondition(backstage, api.BackstageConditionTypeConfig) + + assert.Len(t, backstage.Status.Conditions, 2) + + // Verify Config condition is gone + for _, c := range backstage.Status.Conditions { + assert.NotEqual(t, string(api.BackstageConditionTypeConfig), c.Type) + } + + // Remove non-existent condition should be no-op + removeStatusCondition(backstage, api.BackstageConditionTypeConfig) + assert.Len(t, backstage.Status.Conditions, 2) +} From 2d2bc100180fe244c9e17492316cd8df6756a406 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 20 Aug 2026 15:14:33 +0300 Subject: [PATCH 10/13] fixes --- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 2ef7e9a03..7e2698b5a 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-20T11:00:23Z" + createdAt: "2026-08-20T12:14:15Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed From dd884e46cb750bf4f7a00a4864b35c6a4b68474b Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Thu, 20 Aug 2026 15:16:14 +0300 Subject: [PATCH 11/13] fixes --- api/v1alpha1/zz_generated.deepcopy.go | 2 +- api/v1alpha2/zz_generated.deepcopy.go | 2 +- api/v1alpha3/zz_generated.deepcopy.go | 2 +- api/v1alpha4/zz_generated.deepcopy.go | 2 +- api/v1alpha5/zz_generated.deepcopy.go | 2 +- .../manifests/backstage-operator.clusterserviceversion.yaml | 3 ++- .../manifests/backstage-operator.clusterserviceversion.yaml | 2 +- dist/backstage.io/install.yaml | 1 + dist/rhdh/install.yaml | 1 + 9 files changed, 10 insertions(+), 7 deletions(-) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ca4d7a346..9b7acdd38 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 1357c08bd..c3ecd6b25 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha2 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 6613a8896..b315b789e 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha3 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha4/zz_generated.deepcopy.go b/api/v1alpha4/zz_generated.deepcopy.go index 9c86c46ee..c9c126a0f 100644 --- a/api/v1alpha4/zz_generated.deepcopy.go +++ b/api/v1alpha4/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha4 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/api/v1alpha5/zz_generated.deepcopy.go b/api/v1alpha5/zz_generated.deepcopy.go index 1aca07fcb..88b665d8a 100644 --- a/api/v1alpha5/zz_generated.deepcopy.go +++ b/api/v1alpha5/zz_generated.deepcopy.go @@ -5,7 +5,7 @@ package v1alpha5 import ( - v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 2d10f9430..4e7afc4ef 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-10T13:07:07Z" + createdAt: "2026-08-20T12:15:43Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 @@ -80,6 +80,7 @@ spec: - "" resources: - persistentvolumes + - pods verbs: - get - list diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 7e2698b5a..71f4d8c52 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-20T12:14:15Z" + createdAt: "2026-08-20T12:15:40Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/dist/backstage.io/install.yaml b/dist/backstage.io/install.yaml index 7de429f06..6c8423698 100644 --- a/dist/backstage.io/install.yaml +++ b/dist/backstage.io/install.yaml @@ -2326,6 +2326,7 @@ rules: - "" resources: - persistentvolumes + - pods verbs: - get - list diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index 5e1cf15f2..63589203f 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -2326,6 +2326,7 @@ rules: - "" resources: - persistentvolumes + - pods verbs: - get - list From 562e015f1a73313a1679dc7bdac5733849d93079 Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 24 Aug 2026 12:49:41 +0300 Subject: [PATCH 12/13] RolloutStalled reason --- api/current-types.go | 7 +- api/v1alpha5/backstage_types.go | 7 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- ...kstage-operator.clusterserviceversion.yaml | 2 +- docs/design.md | 24 +++++- internal/controller/backstage_controller.go | 2 +- internal/controller/backstage_status.go | 46 ++++++++++- internal/controller/backstage_status_test.go | 79 +++++++++++++++---- 8 files changed, 140 insertions(+), 29 deletions(-) diff --git a/api/current-types.go b/api/current-types.go index 7f444b560..c414a5e6f 100644 --- a/api/current-types.go +++ b/api/current-types.go @@ -55,9 +55,10 @@ const ( BackstageConditionTypeConfig BackstageConditionType = bsv1.BackstageConditionTypeConfig // Deployed condition reasons - BackstageConditionReasonDeployed BackstageConditionReason = bsv1.BackstageConditionReasonDeployed - BackstageConditionReasonFailed BackstageConditionReason = bsv1.BackstageConditionReasonFailed - BackstageConditionReasonInProgress BackstageConditionReason = bsv1.BackstageConditionReasonInProgress + BackstageConditionReasonDeployed BackstageConditionReason = bsv1.BackstageConditionReasonDeployed + BackstageConditionReasonFailed BackstageConditionReason = bsv1.BackstageConditionReasonFailed + BackstageConditionReasonInProgress BackstageConditionReason = bsv1.BackstageConditionReasonInProgress + BackstageConditionReasonRolloutStalled BackstageConditionReason = bsv1.BackstageConditionReasonRolloutStalled // Runtime condition reasons BackstageConditionReasonRunning BackstageConditionReason = bsv1.BackstageConditionReasonRunning diff --git a/api/v1alpha5/backstage_types.go b/api/v1alpha5/backstage_types.go index 0405a0473..4d3e600de 100644 --- a/api/v1alpha5/backstage_types.go +++ b/api/v1alpha5/backstage_types.go @@ -17,9 +17,10 @@ const ( BackstageConditionTypeConfig BackstageConditionType = "Config" // Deployed condition reasons - BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" - BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" - BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" + BackstageConditionReasonDeployed BackstageConditionReason = "Deployed" + BackstageConditionReasonFailed BackstageConditionReason = "DeployFailed" + BackstageConditionReasonInProgress BackstageConditionReason = "DeployInProgress" + BackstageConditionReasonRolloutStalled BackstageConditionReason = "RolloutStalled" // Runtime condition reasons BackstageConditionReasonRunning BackstageConditionReason = "Running" diff --git a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml index 4e7afc4ef..a0cc9b570 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -25,7 +25,7 @@ metadata: } } ] - createdAt: "2026-08-20T12:15:43Z" + createdAt: "2026-08-24T09:48:04Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.42.2 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 diff --git a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml index 71f4d8c52..2d42edc68 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -29,7 +29,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:2.0 - createdAt: "2026-08-20T12:15:40Z" + createdAt: "2026-08-24T09:48:00Z" description: Red Hat Developer Hub is a Red Hat supported version of Backstage. It comes with pre-built plug-ins and configuration settings, supports use of an external database, and can help streamline the process of setting up a self-managed diff --git a/docs/design.md b/docs/design.md index a3a57da35..29860eb84 100644 --- a/docs/design.md +++ b/docs/design.md @@ -95,9 +95,12 @@ The **Deployed** condition reflects the Deployment (or StatefulSet) level status | Reason | Status | Description | |--------|--------|-------------| -| `Deployed` | True | All replicas are ready. Message shows "X/X replicas ready" | -| `DeployInProgress` | False | Rollout in progress. Message shows "X/X replicas ready" or "no conditions reported yet" | +| `Deployed` | True | All replicas are ready and running the current spec. Message shows "X/X replicas ready" | +| `DeployInProgress` | False | Rollout in progress. Message shows "X/X replicas ready, X/X updated" | | `DeployFailed` | False | Deployment failed. Message contains the error details | +| `RolloutStalled` | False | Rolling update stalled - old pods healthy, new pods failing. Message shows "X/X replicas ready, X/X updated (rollout stalled)" | + +**RolloutStalled:** This reason indicates a failed rolling update where old pods continue serving traffic while new pods fail to start. This occurs when Kubernetes' `progressDeadlineSeconds` (default: 600s) is exceeded. To recover, either fix the issue in the Backstage CR or run `kubectl rollout undo deployment/backstage-`. **Idled instances:** When the `rhdh.redhat.com/idle: true` annotation is set, the deployment scales to 0 replicas. The Deployed condition shows `Deployed` with message "0/0 replicas ready (Idled)". @@ -140,6 +143,23 @@ status: - "@backstage/plugin-techdocs" ``` +### Rollout Stalled Example + +When a rolling update fails (e.g., bad image, missing config): + +```yaml +status: + conditions: + - type: Deployed + status: "False" + reason: RolloutStalled + message: "2/2 replicas ready, 0/2 updated (rollout stalled)" + - type: Runtime + status: "False" + reason: ContainerFailed + message: 'container "backstage": ImagePullBackOff' +``` + ### Idled Example When idled via annotation `rhdh.redhat.com/idle: true`: diff --git a/internal/controller/backstage_controller.go b/internal/controller/backstage_controller.go index 2c06572be..3a95da370 100644 --- a/internal/controller/backstage_controller.go +++ b/internal/controller/backstage_controller.go @@ -128,7 +128,7 @@ func (r *BackstageReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } func errorAndStatus(backstage *api.Backstage, condType api.BackstageConditionType, reason api.BackstageConditionReason, msg string, err error) error { - setStatusCondition(backstage, condType, metav1.ConditionFalse, reason, err.Error()) + setStatusCondition(backstage, condType, metav1.ConditionFalse, reason, fmt.Sprintf("%s: %s", msg, err)) return fmt.Errorf("%s: %w", msg, err) } diff --git a/internal/controller/backstage_status.go b/internal/controller/backstage_status.go index 7e23e647a..e6a30937f 100644 --- a/internal/controller/backstage_status.go +++ b/internal/controller/backstage_status.go @@ -233,7 +233,18 @@ func deploymentState(deploy *appsv1.Deployment) (metav1.ConditionStatus, api.Bac if deploy.Spec.Replicas != nil { desired = *deploy.Spec.Replicas } - if deploy.Status.ReadyReplicas == desired { + + ready := deploy.Status.ReadyReplicas + updated := deploy.Status.UpdatedReplicas + + // Check for rollout stalled: ready replicas from old spec, but new spec not rolled out + if updated < desired && isProgressDeadlineExceeded(deploy) { + return metav1.ConditionFalse, api.BackstageConditionReasonRolloutStalled, + fmt.Sprintf("%d/%d replicas ready, %d/%d updated (rollout stalled)", ready, desired, updated, desired) + } + + // All replicas ready AND running the new spec + if ready == desired && updated == desired { return metav1.ConditionTrue, api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } @@ -248,7 +259,24 @@ func deploymentState(deploy *appsv1.Deployment) (metav1.ConditionStatus, api.Bac } } - return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", deploy.Status.ReadyReplicas, desired) + // Rollout in progress + if updated < desired { + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, + fmt.Sprintf("%d/%d replicas ready, %d/%d updated", ready, desired, updated, desired) + } + + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", ready, desired) +} + +// isProgressDeadlineExceeded checks if the deployment's Progressing condition is False +// with reason ProgressDeadlineExceeded, indicating the rollout is stalled. +func isProgressDeadlineExceeded(deploy *appsv1.Deployment) bool { + for _, c := range deploy.Status.Conditions { + if c.Type == appsv1.DeploymentProgressing { + return c.Status == corev1.ConditionFalse && c.Reason == "ProgressDeadlineExceeded" + } + } + return false } func statefulSetState(sts *appsv1.StatefulSet) (metav1.ConditionStatus, api.BackstageConditionReason, string) { @@ -257,7 +285,11 @@ func statefulSetState(sts *appsv1.StatefulSet) (metav1.ConditionStatus, api.Back desired = *sts.Spec.Replicas } - if sts.Status.ReadyReplicas == desired && sts.Status.CurrentReplicas == sts.Status.UpdatedReplicas { + ready := sts.Status.ReadyReplicas + updated := sts.Status.UpdatedReplicas + + // All replicas ready AND running the new spec + if ready == desired && updated == desired { return metav1.ConditionTrue, api.BackstageConditionReasonDeployed, fmt.Sprintf("%d/%d replicas ready", desired, desired) } @@ -265,5 +297,11 @@ func statefulSetState(sts *appsv1.StatefulSet) (metav1.ConditionStatus, api.Back return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, "no conditions reported yet" } - return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", sts.Status.ReadyReplicas, desired) + // Rollout in progress - show updated count + if updated < desired { + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, + fmt.Sprintf("%d/%d replicas ready, %d/%d updated", ready, desired, updated, desired) + } + + return metav1.ConditionFalse, api.BackstageConditionReasonInProgress, fmt.Sprintf("%d/%d replicas ready", ready, desired) } diff --git a/internal/controller/backstage_status_test.go b/internal/controller/backstage_status_test.go index d660edc4c..0024a5c39 100644 --- a/internal/controller/backstage_status_test.go +++ b/internal/controller/backstage_status_test.go @@ -264,13 +264,14 @@ func TestDeploymentState(t *testing.T) { expectedMsg string }{ { - name: "all replicas ready", + name: "all replicas ready and updated", deployment: &appsv1.Deployment{ Spec: appsv1.DeploymentSpec{ Replicas: ptr.To(int32(1)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 1, + ReadyReplicas: 1, + UpdatedReplicas: 1, }, }, expectedStatus: metav1.ConditionTrue, @@ -278,13 +279,14 @@ func TestDeploymentState(t *testing.T) { expectedMsg: "1/1 replicas ready", }, { - name: "multiple replicas ready", + name: "multiple replicas ready and updated", deployment: &appsv1.Deployment{ Spec: appsv1.DeploymentSpec{ Replicas: ptr.To(int32(3)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 3, + ReadyReplicas: 3, + UpdatedReplicas: 3, }, }, expectedStatus: metav1.ConditionTrue, @@ -292,13 +294,14 @@ func TestDeploymentState(t *testing.T) { expectedMsg: "3/3 replicas ready", }, { - name: "partial replicas ready", + name: "partial replicas ready during rollout", deployment: &appsv1.Deployment{ Spec: appsv1.DeploymentSpec{ Replicas: ptr.To(int32(3)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 1, + ReadyReplicas: 1, + UpdatedReplicas: 1, Conditions: []appsv1.DeploymentCondition{ {Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue}, }, @@ -306,7 +309,7 @@ func TestDeploymentState(t *testing.T) { }, expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, - expectedMsg: "1/3 replicas ready", + expectedMsg: "1/3 replicas ready, 1/3 updated", }, { name: "no replicas ready yet", @@ -315,7 +318,8 @@ func TestDeploymentState(t *testing.T) { Replicas: ptr.To(int32(1)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 0, + ReadyReplicas: 0, + UpdatedReplicas: 0, Conditions: []appsv1.DeploymentCondition{ {Type: appsv1.DeploymentProgressing, Status: corev1.ConditionTrue}, }, @@ -323,7 +327,7 @@ func TestDeploymentState(t *testing.T) { }, expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, - expectedMsg: "0/1 replicas ready", + expectedMsg: "0/1 replicas ready, 0/1 updated", }, { name: "no conditions yet", @@ -332,8 +336,9 @@ func TestDeploymentState(t *testing.T) { Replicas: ptr.To(int32(1)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 0, - Conditions: []appsv1.DeploymentCondition{}, + ReadyReplicas: 0, + UpdatedReplicas: 1, + Conditions: []appsv1.DeploymentCondition{}, }, }, expectedStatus: metav1.ConditionFalse, @@ -347,7 +352,8 @@ func TestDeploymentState(t *testing.T) { Replicas: ptr.To(int32(1)), }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 0, + ReadyReplicas: 0, + UpdatedReplicas: 1, Conditions: []appsv1.DeploymentCondition{ { Type: appsv1.DeploymentReplicaFailure, @@ -368,13 +374,58 @@ func TestDeploymentState(t *testing.T) { Replicas: nil, }, Status: appsv1.DeploymentStatus{ - ReadyReplicas: 1, + ReadyReplicas: 1, + UpdatedReplicas: 1, }, }, expectedStatus: metav1.ConditionTrue, expectedReason: api.BackstageConditionReasonDeployed, expectedMsg: "1/1 replicas ready", }, + { + name: "rollout stalled - old pods ready, new pods failing", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(2)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 2, + UpdatedReplicas: 0, + Conditions: []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentProgressing, + Status: corev1.ConditionFalse, + Reason: "ProgressDeadlineExceeded", + }, + }, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonRolloutStalled, + expectedMsg: "2/2 replicas ready, 0/2 updated (rollout stalled)", + }, + { + name: "rollout stalled - partial updated", + deployment: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(3)), + }, + Status: appsv1.DeploymentStatus{ + ReadyReplicas: 2, + UpdatedReplicas: 1, + Conditions: []appsv1.DeploymentCondition{ + { + Type: appsv1.DeploymentProgressing, + Status: corev1.ConditionFalse, + Reason: "ProgressDeadlineExceeded", + }, + }, + }, + }, + expectedStatus: metav1.ConditionFalse, + expectedReason: api.BackstageConditionReasonRolloutStalled, + expectedMsg: "2/3 replicas ready, 1/3 updated (rollout stalled)", + }, } for _, tt := range tests { @@ -428,7 +479,7 @@ func TestStatefulSetState(t *testing.T) { }, expectedStatus: metav1.ConditionFalse, expectedReason: api.BackstageConditionReasonInProgress, - expectedMsg: "1/1 replicas ready", + expectedMsg: "1/1 replicas ready, 0/1 updated", }, { name: "partial replicas ready", From a6560aed907930dbd9d0358ad07d9e34ef837f0e Mon Sep 17 00:00:00 2001 From: gazarenkov Date: Mon, 24 Aug 2026 13:00:25 +0300 Subject: [PATCH 13/13] idled doc --- docs/admin.md | 12 ++++++------ docs/design.md | 4 ---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/admin.md b/docs/admin.md index e22a77a60..b44d4fc79 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -194,14 +194,14 @@ 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 +```yaml +- type: Deployed + status: "True" + reason: Deployed + message: "0/0 replicas ready (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. +> **Note:** To determine if an instance is idled, check the `rhdh.redhat.com/idle` annotation on the Backstage CR. Absence of this annotation means the instance is not idled. ### Waking an instance diff --git a/docs/design.md b/docs/design.md index 29860eb84..b04d3d804 100644 --- a/docs/design.md +++ b/docs/design.md @@ -171,10 +171,6 @@ status: status: "True" reason: Deployed message: "0/0 replicas ready (Idled)" - - type: Runtime - status: "False" - reason: Pending - message: "no pods found" ``` ### Plugins Status