Skip to content

Refactor runtime object handling #2918

Open
gazarenkov wants to merge 11 commits into
redhat-developer:mainfrom
gazarenkov:refactor-runtime-flow
Open

Refactor runtime object handling #2918
gazarenkov wants to merge 11 commits into
redhat-developer:mainfrom
gazarenkov:refactor-runtime-flow

Conversation

@gazarenkov
Copy link
Copy Markdown
Member

@gazarenkov gazarenkov commented May 28, 2026

Description

This PR refactors the core operator runtime model to simplify object initialization flow

Key Changes

  1. Simplified RuntimeObject Interface
  • Consolidated addToModel() signature: now takes config parameter directly instead of separate setObject() call
  • Merged setMetaInfo() into updateAndValidate() phase
  • Added GetKey() method for object type identification
  • Removed ExternalConfigContributor interface - functionality integrated into main flow
  • Removed the intermediate setObject() and separate setMetaInfo() steps.
  1. Simplified Initialization Flow (3 phases → 2 phases)
  • Phase 1: Choose config (overlay OR default, not both), initialize object via addToModel(), register in model
  • Phase 2: Update references, validate, set metadata via updateAndValidate()
  1. Deterministic Object Ordering
  • Objects maintain registration order
  1. Cleaner API
  • Removed key parameter from setRuntimeObject(key, obj) → setRuntimeObject(obj)
  • Objects self-identify via GetKey() method
  • GetRuntimeObjects() returns slice instead of map for predictable iteration

Which issue(s) does this PR fix or relate to

https://redhat.atlassian.net/browse/RHDHBUGS-2975

PR acceptance criteria

  • Tests
  • Documentation

How to test changes / Special notes to the reviewer

It does not change the behaviour

@sonarqubecloud
Copy link
Copy Markdown

@codecov
Copy link
Copy Markdown

codecov Bot commented May 28, 2026

Codecov Report

❌ Patch coverage is 80.72562% with 85 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.59%. Comparing base (9c71119) to head (40b824b).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pkg/model/dynamic-plugins.go 74.41% 5 Missing and 6 partials ⚠️
pkg/model/runtime.go 74.35% 6 Missing and 4 partials ⚠️
pkg/model/appconfig.go 78.37% 4 Missing and 4 partials ⚠️
pkg/model/secretfiles.go 80.48% 4 Missing and 4 partials ⚠️
internal/controller/backstage_controller.go 0.00% 6 Missing ⚠️
pkg/model/configmapenvs.go 80.64% 3 Missing and 3 partials ⚠️
pkg/model/configmapfiles.go 83.78% 3 Missing and 3 partials ⚠️
pkg/model/deployment.go 79.31% 3 Missing and 3 partials ⚠️
pkg/model/pvcs.go 83.33% 3 Missing and 3 partials ⚠️
pkg/model/secretenvs.go 81.81% 3 Missing and 3 partials ⚠️
... and 4 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #2918      +/-   ##
==========================================
+ Coverage   61.07%   61.59%   +0.52%     
==========================================
  Files          37       37              
  Lines        2109     2148      +39     
==========================================
+ Hits         1288     1323      +35     
  Misses        686      686              
- Partials      135      139       +4     
Flag Coverage Δ
nightly ?
unittests 61.59% <80.72%> (+0.52%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
pkg/model/db-service.go 100.00% <100.00%> (+12.90%) ⬆️
pkg/model/db-statefulset.go 100.00% <100.00%> (+8.51%) ⬆️
pkg/model/service.go 92.00% <90.00%> (+8.00%) ⬆️
pkg/model/db-secret.go 94.73% <90.47%> (+0.61%) ⬆️
pkg/model/route.go 67.88% <88.88%> (-0.92%) ⬇️
internal/controller/plugin-deps.go 0.00% <0.00%> (ø)
internal/controller/backstage_controller.go 0.00% <0.00%> (ø)
pkg/model/configmapenvs.go 84.21% <80.64%> (+0.87%) ⬆️
pkg/model/configmapfiles.go 86.36% <83.78%> (+0.99%) ⬆️
pkg/model/deployment.go 82.17% <79.31%> (+1.77%) ⬆️
... and 6 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rm3l
Copy link
Copy Markdown
Member

rm3l commented Jun 1, 2026

/agentic_review

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge Bot commented Jun 1, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used
✅ Tickets: RHDHBUGS-2975
✅ Compliance rules (platform): 18 rules

Grey Divider


Action required

1. Deployment nil deref panic 🐞 Bug ☼ Reliability
Description
BackstageDeployment.addToModel() and Object() dereference b.deployable without guarding against it
being nil when config is absent, which can panic during InitObjects instead of returning an error.
This can crash reconciliation if deployment.yaml is missing from both overlay and defaults.
Code

pkg/model/deployment.go[R74-101]

Relevance

⭐⭐⭐ High

Team accepted “avoid panics, return errors” in deployment handling (PR #1949).

PR-#1949

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The factory creates BackstageDeployment without initializing deployable, but addToModel only
sets deployable when config is non-nil and then dereferences it unconditionally. InitObjects can
pass config=nil when neither overlay nor defaults provide a deployment object.

pkg/model/deployment.go[40-119]
pkg/model/runtime.go[149-193]
PR-#1949

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`BackstageDeployment` can panic because `b.deployable` is never initialized when `config == nil`, yet `addToModel()` and `Object()` call `b.deployable.GetObject()`.

### Issue Context
- Factory returns an empty `&BackstageDeployment{}`.
- `addToModel()` only initializes `b.deployable` when `config != nil`.
- If config is missing, current code still calls `b.deployable.GetObject()` (nil deref).

### Fix Focus Areas
- pkg/model/deployment.go[40-119]
- pkg/model/runtime.go[149-193]

### Expected fix
- Make `Object()` return `nil` when `b.deployable == nil`.
- In `addToModel()`, if `config == nil` (deployment is required), return a clear error (similar to previous behavior) rather than skipping/continuing.
- Optionally, add a validation step in `InitObjects()` ensuring required objects (deployment+service) exist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. External CM SSA create fails 🐞 Bug ≡ Correctness
Description
DynamicPlugins.addToModel can set p.ConfigMap to ExternalConfig.DynamicPlugins (loaded from a live
cluster ConfigMap) and then rename it; applying this renamed object via server-side apply can fail
because the object still carries cluster-populated metadata (e.g., resourceVersion/UID). This breaks
the spec-only dynamic plugins path when no default dynamic-plugins.yaml is present.
Code

pkg/model/dynamic-plugins.go[R95-169]

Relevance

⭐⭐ Medium

No historical evidence about SSA create failures from resourceVersion/UID when reusing live
ConfigMaps.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The external dynamic plugins ConfigMap is loaded from the cluster (thus with populated ObjectMeta),
stored in ExternalConfig, then directly reused and renamed in DynamicPlugins. The controller applies
objects via SSA, which can create resources and is sensitive to create-invalid metadata fields.

internal/controller/spec_preprocessor.go[147-154]
pkg/model/dynamic-plugins.go[95-170]
internal/controller/backstage_controller.go[159-176]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
When `DynamicPluginsConfigMapName` is set and there is no default dynamic-plugins config object, the code reuses the *live* external ConfigMap object (`ExternalConfig.DynamicPlugins`) and then renames/owns it. That reused object includes `ObjectMeta` fields from the cluster (`resourceVersion`, `uid`, `managedFields`, etc.). Applying it under a new name using server-side apply can fail on create.

### Issue Context
- `preprocessSpec()` loads the external ConfigMap via `client.Get`, populating full metadata.
- `DynamicPlugins.addToModel()` assigns `p.ConfigMap = specPlugins` and then `SetName(...)`.
- `applyPayload()` uses SSA apply, which will create the object if it doesn't exist.

### Fix Focus Areas
- pkg/model/dynamic-plugins.go[85-170]
- internal/controller/spec_preprocessor.go[147-154]
- internal/controller/backstage_controller.go[159-176]

### Expected fix
Choose one of:
1) **Preferred:** Create a fresh `corev1.ConfigMap{}` and copy only `Data`/`BinaryData` (and any required annotations) from the external ConfigMap; do not reuse the external object's `ObjectMeta`.
2) If reuse is required, explicitly clear create-blocking fields before apply (e.g., `ResourceVersion`, `UID`, `ManagedFields`, `OwnerReferences`, possibly `CreationTimestamp`).

Add/adjust tests to simulate external ConfigMaps with populated metadata (resourceVersion/uid) and ensure reconciliation still succeeds.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. LocalDB config not validated 🐞 Bug ☼ Reliability
Description
DbService/DbStatefulSet now silently no-op when local DB is enabled but their configs are missing,
even though IsLocalDbEnabled defaults to true. This can lead to a Backstage instance missing its
default local DB resources without an explicit error, making failures harder to diagnose.
Code

pkg/model/db-service.go[R43-61]

Relevance

⭐⭐ Medium

No historical review evidence on localDB missing-config error vs silent skip; unclear team
preference.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Local DB is enabled by default, but DbService/DbStatefulSet only populate their underlying objects
when config is present and otherwise succeed, causing the required resources to be absent with no
error signal.

api/v1alpha5/backstage_types.go[372-378]
pkg/model/db-service.go[43-62]
pkg/model/db-statefulset.go[49-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Local DB is enabled by default (`IsLocalDbEnabled()` returns true when `spec.database` is nil), but `DbService` and `DbStatefulSet` no longer error if their config is absent (`config == nil`). This can silently omit required DB objects.

### Issue Context
- `BackstageSpec.IsLocalDbEnabled()` defaults to true.
- `DbService.addToModel()` / `DbStatefulSet.addToModel()` only set underlying objects when `config != nil` and otherwise return nil.

### Fix Focus Areas
- pkg/model/db-service.go[43-62]
- pkg/model/db-statefulset.go[49-71]
- api/v1alpha5/backstage_types.go[372-378]

### Expected fix
- If `model.localDbEnabled == true` and `config == nil` for `db-service.yaml` and/or `db-statefulset.yaml`, return a clear error indicating the required default/overlay config is missing.
- Alternatively, add a post-init validation step in `InitObjects()` that ensures these objects exist when local DB is enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

4. Apply order no longer optimized 🐞 Bug ☼ Reliability
Description
The model no longer performs a dependency-aware ordering step (previously pushing deployables last),
and applyObjects now applies in init registration order. This can apply Deployments/StatefulSets
before referenced ConfigMaps/Secrets/Services, causing transient pod failures and extra reconcile
churn.
Code

pkg/model/runtime.go[R82-92]

Relevance

⭐ Low

Recent work favored deterministic ordering; no evidence they want dependency-aware apply ordering
(PR #2785).

PR-#2785

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Runtime objects are now returned in registration order without further sorting, and the controller
applies them sequentially in that order, making the apply order sensitive to init registration
rather than resource dependencies.

pkg/model/runtime.go[82-92]
internal/controller/backstage_controller.go[126-149]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Object apply ordering is now purely registration-order-based, and the prior optimization that placed deployable objects last was removed. This can create temporary invalid states (deployables referencing resources created later in the same reconcile).

### Issue Context
- `GetRuntimeObjects()` preserves registration order.
- Controller applies objects strictly in the order provided.

### Fix Focus Areas
- pkg/model/runtime.go[82-92]
- internal/controller/backstage_controller.go[126-149]

### Expected fix
- Reintroduce explicit ordering (e.g., apply ConfigMaps/Secrets/PVCs/Services first, then StatefulSets/Deployments, then Routes).
- Implement ordering either by sorting the slice returned from `GetRuntimeObjects()` or by applying in ordered groups in the controller.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread pkg/model/deployment.go
Comment on lines 74 to +101
}

// implementation of RuntimeObject interface
func (b *BackstageDeployment) setObject(obj runtime.Object) {

var err error
b.deployable, err = CreateDeployable(obj)
if err != nil {
panic(fmt.Sprintf("cannot set deployment object: %v", err))
}
func (b *BackstageDeployment) GetKey() string {
return DeploymentKey
}

// implementation of RuntimeObject interface
func (b *BackstageDeployment) addToModel(model *BackstageModel, backstage api.Backstage) (bool, error) {
if b.deployable.GetObject() == nil {
return false, fmt.Errorf("backstage Deployment is not initialized, make sure there is deployment.yaml in default or raw configuration")
func (b *BackstageDeployment) addToModel(model *BackstageModel, backstage api.Backstage, config runtime.Object, scheme *runtime.Scheme) error {
// Set the deployment from config parameter if not nil
if config != nil {
var err error
b.deployable, err = CreateDeployable(config)
if err != nil {
return fmt.Errorf("cannot set deployment object: %w", err)
}
}

if BackstageContainerIndex(b.podSpec()) < 0 {
return false, fmt.Errorf("backstage Deployment is not initialized, Backstage Container is not identified")
}
b.model = model

if b.deployable.PodObjectMeta().Annotations == nil {
b.deployable.PodObjectMeta().Annotations = map[string]string{}
}
b.deployable.PodObjectMeta().Annotations[ExtConfigHashAnnotation] = model.ExternalConfig.WatchingHash
// Call setMetaInfo if deployment is not nil
if b.deployable.GetObject() != nil {
model.setRuntimeObject(b)
b.setMetaInfo(backstage, scheme)

model.backstageDeployment = b
model.setRuntimeObject(b)
b.model = model
if BackstageContainerIndex(b.podSpec()) < 0 {
return fmt.Errorf("backstage Deployment is not initialized, Backstage Container is not identified")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Deployment nil deref panic 🐞 Bug ☼ Reliability

BackstageDeployment.addToModel() and Object() dereference b.deployable without guarding against it
being nil when config is absent, which can panic during InitObjects instead of returning an error.
This can crash reconciliation if deployment.yaml is missing from both overlay and defaults.
Agent Prompt
### Issue description
`BackstageDeployment` can panic because `b.deployable` is never initialized when `config == nil`, yet `addToModel()` and `Object()` call `b.deployable.GetObject()`.

### Issue Context
- Factory returns an empty `&BackstageDeployment{}`.
- `addToModel()` only initializes `b.deployable` when `config != nil`.
- If config is missing, current code still calls `b.deployable.GetObject()` (nil deref).

### Fix Focus Areas
- pkg/model/deployment.go[40-119]
- pkg/model/runtime.go[149-193]

### Expected fix
- Make `Object()` return `nil` when `b.deployable == nil`.
- In `addToModel()`, if `config == nil` (deployment is required), return a clear error (similar to previous behavior) rather than skipping/continuing.
- Optionally, add a validation step in `InitObjects()` ensuring required objects (deployment+service) exist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +95 to 169
if backstage.Spec.Application != nil && backstage.Spec.Application.DynamicPluginsConfigMapName != "" {
specPlugins := &p.model.ExternalConfig.DynamicPlugins

// if the ConfigMap is set but does not have the data or expected key
if specPlugins.Data == nil || specPlugins.Data[DynamicPluginsFile] == "" {
return fmt.Errorf("dynamic plugin configMap expects '%s' Data key", DynamicPluginsFile)
}

//// resolve references
// TODO
//plugins, err := GetPluginsData(specPlugins)
//if err != nil {
// return err
//}
//for _, plugin := range plugins {
// if plugin.Package
//
//}

if p.ConfigMap != nil {
// Merge user's config with default config
//mergedData, err := p.mergeWith(specPlugins.Data[DynamicPluginsFile])
mergedData, err := MergePluginsData(p.ConfigMap.Data[DynamicPluginsFile], specPlugins.Data[DynamicPluginsFile])
if err != nil {
return fmt.Errorf("failed to merge dynamic plugins config: %w", err)
}
p.ConfigMap.Data[DynamicPluginsFile] = mergedData
} else {
// No default config, point to user's config
p.ConfigMap = specPlugins
}
}

p.setMetaInfo(backstage, scheme)
// Always add wrapper to model (unconditional)
model.setRuntimeObject(p)
model.DynamicPlugins = *p
return true, nil

return nil
}

// implementation of RuntimeObject interface
// ConfigMap name must be the same as (deployment.yaml).spec.template.spec.volumes.name.dynamic-plugins-conf.ConfigMap.name
func (p *DynamicPlugins) updateAndValidate(backstage api.Backstage) error {
// TODO
// extract pluginConfigs
// merge with app-config (deep merge)
func (p *DynamicPlugins) updateAndValidate(backstage api.Backstage, scheme *runtime.Scheme) error {

// Only proceed if there's a ConfigMap to mount or dynamic plugins config in spec
if p.ConfigMap == nil && (backstage.Spec.Application == nil || backstage.Spec.Application.DynamicPluginsConfigMapName == "") {
// No dynamic plugins configuration, nothing to do
return nil
}

deployment := p.model.getDeployment()
if deployment == nil {
return fmt.Errorf("backstage deployment not found in model")
}

_, initContainer := p.getInitContainer()
if initContainer == nil {
return fmt.Errorf("failed to find initContainer named %s", dynamicPluginInitContainerName)
}
if backstage.Spec.Application == nil || backstage.Spec.Application.DynamicPluginsConfigMapName == "" {
if err := p.model.backstageDeployment.mountFilesFrom(containersFilter{names: []string{dynamicPluginInitContainerName}}, ConfigMapObjectKind,
p.ConfigMap.Name, initContainer.WorkingDir, DynamicPluginsFile, true, utils.SortedKeys(p.ConfigMap.Data)); err != nil {
return fmt.Errorf("failed to mount dynamic plugins configMap: %w", err)
}

if err := deployment.mountFilesFrom(containersFilter{names: []string{dynamicPluginInitContainerName}}, ConfigMapObjectKind,
p.ConfigMap.Name, initContainer.WorkingDir, DynamicPluginsFile, true, utils.SortedKeys(p.ConfigMap.Data)); err != nil {
return fmt.Errorf("failed to mount dynamic plugins configMap: %w", err)
}

return nil
}

// implementation of RuntimeObject interface
func (p *DynamicPlugins) setMetaInfo(backstage api.Backstage, scheme *runtime.Scheme) {
p.ConfigMap.SetName(DynamicPluginsDefaultName(backstage.Name))
setMetaInfo(p.ConfigMap, backstage, scheme)
}

func (p *DynamicPlugins) addExternalConfig(spec api.BackstageSpec) error {
if spec.Application != nil && spec.Application.DynamicPluginsConfigMapName != "" {

_, initContainer := p.getInitContainer()
if initContainer == nil {
return fmt.Errorf("failed to find initContainer named %s", dynamicPluginInitContainerName)
}

dp := &p.model.ExternalConfig.DynamicPlugins

// if the ConfigMap is set but does not have the data or expected key
if dp.Data == nil || dp.Data[DynamicPluginsFile] == "" {
return fmt.Errorf("dynamic plugin configMap expects '%s' Data key", DynamicPluginsFile)
}

if p.ConfigMap != nil {
mergedData, err := p.mergeWith(dp.Data[DynamicPluginsFile])
if err != nil {
return fmt.Errorf("failed to merge dynamic plugins config: %w", err)
}
p.ConfigMap.Data[DynamicPluginsFile] = mergedData
err = p.model.backstageDeployment.mountFilesFrom(containersFilter{names: []string{dynamicPluginInitContainerName}}, ConfigMapObjectKind,
p.ConfigMap.Name, initContainer.WorkingDir, DynamicPluginsFile, true, utils.SortedKeys(p.ConfigMap.Data))
if err != nil {
return fmt.Errorf("failed to mount dynamic plugins configMap: %w", err)
}
} else {
err := p.model.backstageDeployment.mountFilesFrom(containersFilter{names: []string{dynamicPluginInitContainerName}}, ConfigMapObjectKind,
dp.Name, initContainer.WorkingDir, DynamicPluginsFile, true, utils.SortedKeys(dp.Data))
if err != nil {
return fmt.Errorf("failed to mount dynamic plugins configMap: %w", err)
}
}

if p.ConfigMap != nil {
p.ConfigMap.SetName(DynamicPluginsDefaultName(backstage.Name))
setMetaInfo(p.ConfigMap, backstage, scheme)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. External cm ssa create fails 🐞 Bug ≡ Correctness

DynamicPlugins.addToModel can set p.ConfigMap to ExternalConfig.DynamicPlugins (loaded from a live
cluster ConfigMap) and then rename it; applying this renamed object via server-side apply can fail
because the object still carries cluster-populated metadata (e.g., resourceVersion/UID). This breaks
the spec-only dynamic plugins path when no default dynamic-plugins.yaml is present.
Agent Prompt
### Issue description
When `DynamicPluginsConfigMapName` is set and there is no default dynamic-plugins config object, the code reuses the *live* external ConfigMap object (`ExternalConfig.DynamicPlugins`) and then renames/owns it. That reused object includes `ObjectMeta` fields from the cluster (`resourceVersion`, `uid`, `managedFields`, etc.). Applying it under a new name using server-side apply can fail on create.

### Issue Context
- `preprocessSpec()` loads the external ConfigMap via `client.Get`, populating full metadata.
- `DynamicPlugins.addToModel()` assigns `p.ConfigMap = specPlugins` and then `SetName(...)`.
- `applyPayload()` uses SSA apply, which will create the object if it doesn't exist.

### Fix Focus Areas
- pkg/model/dynamic-plugins.go[85-170]
- internal/controller/spec_preprocessor.go[147-154]
- internal/controller/backstage_controller.go[159-176]

### Expected fix
Choose one of:
1) **Preferred:** Create a fresh `corev1.ConfigMap{}` and copy only `Data`/`BinaryData` (and any required annotations) from the external ConfigMap; do not reuse the external object's `ObjectMeta`.
2) If reuse is required, explicitly clear create-blocking fields before apply (e.g., `ResourceVersion`, `UID`, `ManagedFields`, `OwnerReferences`, possibly `CreationTimestamp`).

Add/adjust tests to simulate external ConfigMaps with populated metadata (resourceVersion/uid) and ensure reconciliation still succeeds.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants