Skip to content

feat: Enable Operator to consume catalog index with environment configuration#2000

Merged
rm3l merged 33 commits into
redhat-developer:mainfrom
Fortune-Ndlovu:RHIDP-9762-consume-catalog-index
Dec 23, 2025
Merged

feat: Enable Operator to consume catalog index with environment configuration#2000
rm3l merged 33 commits into
redhat-developer:mainfrom
Fortune-Ndlovu:RHIDP-9762-consume-catalog-index

Conversation

@Fortune-Ndlovu

Copy link
Copy Markdown
Member

Description

This PR enables the RHDH operator to consume the plugin catalog index OCI artifact by setting the CATALOG_INDEX_IMAGE environment variable in the install-dynamic-plugins init container.

The default deployment template now includes CATALOG_INDEX_IMAGE set to quay.io/rhdh/plugin-catalog-index:1.9. Users can override this value via the Backstage CR's spec.application.extraEnvs field to use a different version, a mirrored image.

For air-gapped environments, RELATED_IMAGE_catalog_index has been added to the operator deployment patch, which automatically propagates to the CSV spec.relatedImages. This ensures OLM will discover and mirror the catalog index image.

Documentation has been added to docs/dynamic-plugins.md covering the catalog index configuration.

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

Fixes:

PR acceptance criteria

  • Tests
  • Documentation

How to test changes / Special notes to the reviewer

Signed-off-by: Fortune Ndlovu <fndlovu@redhat.com>
@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Dec 10, 2025

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit 336a136)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

RHIDP-9762 - Partially compliant

Compliant requirements:

  • Set CATALOG_INDEX_IMAGE for the install-dynamic-plugins init container (via operator env RELATED_IMAGE_catalog_index + default-config baseline).
  • Update config/profile/rhdh/patches/deployment-patch.yaml for CSV/bundle propagation.
  • Test CATALOG_INDEX_IMAGE is set correctly.
  • Test user override via extraEnvs works.
  • Test precedence/backwards compatibility scenarios (default-config vs operator env var vs user override).

Non-compliant requirements:

  • Ensure all existing operator tests pass.

Requires further human verification:

  • Ensure all existing operator tests pass (run full CI / make test and any operator-sdk/OLM validations).

RHIDP-9763 - Partially compliant

Compliant requirements:

  • Document the catalog index configuration.

Non-compliant requirements:

Requires further human verification:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🔒 No security concerns identified
⚡ Recommended focus areas for review

EnvVar Replacement

setOrAppendEnvVar replaces an existing env var with a new corev1.EnvVar{Name, Value}. If the existing entry used ValueFrom (or had other fields like Type), this replacement will drop them. Consider preserving ValueFrom semantics or only overwriting Value when the existing env var is Value-based, to avoid unexpected behavior when users supply valueFrom-style envs in patches/extra env configuration.

			b.setOrAppendEnvVar(container, env.Name, env.Value)
		}
	}
	return nil
}

// setOrAppendEnvVar sets an env var on a container, replacing if it exists or appending if not
func (b *BackstageDeployment) setOrAppendEnvVar(container *corev1.Container, name, value string) {
	for i, existingEnv := range container.Env {
		if existingEnv.Name == name {
			container.Env[i] = corev1.EnvVar{Name: name, Value: value}
			return
		}
	}
	container.Env = append(container.Env, corev1.EnvVar{Name: name, Value: value})
}
Brittle Test

The helper initContainer returns &v from a for _, v := range ... loop, which yields a pointer to the loop variable copy rather than the slice element; this can make assertions unreliable and mask real regressions. Additionally, several tests assert Len(ic.Env, 2) and fixed ordering (ic.Env[0], ic.Env[1]), which may be brittle if other env vars are added or ordering changes. Prefer locating envs by name and avoid depending on exact length/order unless strictly required.

func initContainer(model *BackstageModel) *corev1.Container {
	for _, v := range model.backstageDeployment.podSpec().InitContainers {
		if v.Name == dynamicPluginInitContainerName {
			return &v
		}
	}
	return nil
}


// TestCatalogIndexImageFromDefaultConfig verifies that the operator sets CATALOG_INDEX_IMAGE
// on the install-dynamic-plugins init container from the default config by default
func TestCatalogIndexImageFromDefaultConfig(t *testing.T) {
	bs := testDynamicPluginsBackstage.DeepCopy()

	testObj := createBackstageTest(*bs).withDefaultConfig(true).
		addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml").
		addToDefaultConfig("deployment.yaml", "janus-deployment.yaml")

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

	ic := initContainer(model)
	assert.NotNil(t, ic)

	assert.Len(t, ic.Env, 2)
	assert.Equal(t, "NPM_CONFIG_USERCONFIG", ic.Env[0].Name)
	assert.Equal(t, "CATALOG_INDEX_IMAGE", ic.Env[1].Name)
	assert.Equal(t, "quay.io/rhdh/plugin-catalog-index:1.9", ic.Env[1].Value, "CATALOG_INDEX_IMAGE should be set from the default config")
}

// TestCatalogIndexImageOverridesDefaultConfig verifies that RELATED_IMAGE_catalog_index
// overrides the CATALOG_INDEX_IMAGE value that comes from the default config.
// This is the critical test case: the default-config deployment.yaml has CATALOG_INDEX_IMAGE
// set to one value, but RELATED_IMAGE_catalog_index should override it.
func TestCatalogIndexImageOverridesDefaultConfig(t *testing.T) {
	bs := testDynamicPluginsBackstage.DeepCopy()

	// janus-deployment.yaml has CATALOG_INDEX_IMAGE set (like the real default-config)
	testObj := createBackstageTest(*bs).withDefaultConfig(true).
		addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml").
		addToDefaultConfig("deployment.yaml", "janus-deployment.yaml")

	// Set RELATED_IMAGE_catalog_index to a DIFFERENT value - this should override the default config
	t.Setenv(CatalogIndexImageEnvVar, "quay.io/fake-reg/img:1.2.3")

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

	ic := initContainer(model)
	assert.NotNil(t, ic)

	assert.Len(t, ic.Env, 2)
	assert.Equal(t, "NPM_CONFIG_USERCONFIG", ic.Env[0].Name)
	assert.Equal(t, "CATALOG_INDEX_IMAGE", ic.Env[1].Name)
	assert.Equal(t, "quay.io/fake-reg/img:1.2.3", ic.Env[1].Value, "RELATED_IMAGE_catalog_index should override the default config value")
}

// TestCatalogIndexImageUserPatchTakesPrecedence verifies that user-specified deployment patch
// takes precedence over the operator's RELATED_IMAGE_catalog_index env var
func TestCatalogIndexImageUserPatchTakesPrecedence(t *testing.T) {
	bs := testDynamicPluginsBackstage.DeepCopy()

	// User specifies CATALOG_INDEX_IMAGE via deployment patch
	bs.Spec.Deployment = &bsv1.BackstageDeployment{}
	bs.Spec.Deployment.Patch = &apiextensionsv1.JSON{
		Raw: []byte(`
spec:
  template:
    spec:
      initContainers:
        - name: install-dynamic-plugins
          env:
            - name: CATALOG_INDEX_IMAGE
              value: "quay.io/user-specified/image:2.0"
`),
	}

	testObj := createBackstageTest(*bs).withDefaultConfig(true).
		addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml").
		addToDefaultConfig("deployment.yaml", "janus-deployment.yaml")

	// Set RELATED_IMAGE_catalog_index - but user's patch should take precedence
	t.Setenv(CatalogIndexImageEnvVar, "quay.io/rhdh/plugin-catalog-index:related-image")

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

	ic := initContainer(model)
	assert.NotNil(t, ic)
	assert.Len(t, ic.Env, 2)
	assert.Equal(t, "NPM_CONFIG_USERCONFIG", ic.Env[0].Name)
	assert.Equal(t, "CATALOG_INDEX_IMAGE", ic.Env[1].Name)
	assert.Equal(t, "quay.io/user-specified/image:2.0", ic.Env[1].Value, "user's deployment patch should override RELATED_IMAGE_catalog_index")
}

// TestCatalogIndexImageExtraEnvsOverride verifies that user-specified extraEnvs
// takes precedence over the operator's RELATED_IMAGE_catalog_index env var
func TestCatalogIndexImageExtraEnvsOverride(t *testing.T) {
	bs := testDynamicPluginsBackstage.DeepCopy()

	// User specifies a different catalog index image via extraEnvs
	bs.Spec.Application.ExtraEnvs = &bsv1.ExtraEnvs{
		Envs: []bsv1.Env{
			{Name: "CATALOG_INDEX_IMAGE", Value: "quay.io/rhdh/plugin-catalog-index:extra-env", Containers: []string{"install-dynamic-plugins"}},
		},
	}

	testObj := createBackstageTest(*bs).withDefaultConfig(true).
		addToDefaultConfig("dynamic-plugins.yaml", "raw-dynamic-plugins.yaml").
		addToDefaultConfig("deployment.yaml", "janus-deployment.yaml")

	// Set the RELATED_IMAGE_catalog_index env var (simulating operator environment)
	// This should NOT override the user's extraEnvs value
	t.Setenv(CatalogIndexImageEnvVar, "quay.io/rhdh/plugin-catalog-index:related-image")

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

	ic := initContainer(model)
	assert.NotNil(t, ic)
	assert.Len(t, ic.Env, 2)
	assert.Equal(t, "NPM_CONFIG_USERCONFIG", ic.Env[0].Name)
	assert.Equal(t, "CATALOG_INDEX_IMAGE", ic.Env[1].Name)
	assert.Equal(t, "quay.io/rhdh/plugin-catalog-index:extra-env", ic.Env[1].Value, "extraEnvs value should override the operator's RELATED_IMAGE_catalog_index")
}
📄 References
  1. redhat-developer/rhdh-operator/config/profile/rhdh/default-config/deployment.yaml [83-99]
  2. redhat-developer/rhdh-operator/config/crd/bases/rhdh.redhat.com_backstages.yaml [1028-1041]
  3. redhat-developer/rhdh-chart/charts/backstage/values.yaml [23-36]
  4. redhat-developer/rhdh-chart/charts/backstage/values.yaml [37-44]
  5. redhat-developer/rhdh-operator/dist/rhdh/install.yaml [531-544]
  6. redhat-developer/rhdh-operator/bundle/backstage.io/manifests/rhdh.redhat.com_backstages.yaml [1028-1041]
  7. redhat-developer/rhdh-operator/dist/backstage.io/install.yaml [531-544]
  8. redhat-developer/rhdh-operator/examples/rhdh-cr.yaml [45-68]

@rhdh-qodo-merge rhdh-qodo-merge Bot added the enhancement New feature or request label Dec 10, 2025
@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Dec 10, 2025

Copy link
Copy Markdown

PR Type

(Describe updated until commit fdc93be)

Enhancement


Description

  • Enable operator to consume catalog index OCI artifact via RELATED_IMAGE_catalog_index environment variable

  • Set CATALOG_INDEX_IMAGE on init container with proper precedence handling (operator env var, user patch, extraEnvs)

  • Implement setOrAppendEnvVar helper to prevent duplicate environment variables in containers

  • Add comprehensive test coverage for catalog index configuration scenarios and precedence rules

  • Update default deployment configurations and operator manifests with catalog index image reference


File Walkthrough

Relevant files
Enhancement
1 files
deployment.go
Add catalog index image environment variable handling       
+21/-5   
Tests
3 files
deployment_test.go
Add test for environment variable replacement behavior     
+58/-0   
dynamic-plugins_test.go
Add comprehensive catalog index image configuration tests
+157/-0 
janus-deployment.yaml
Add catalog index image to test deployment data                   
+3/-0     
Bug fix
1 files
dynamic-plugins.go
Fix init container reference in getInitContainer method   
+8/-2     
Formatting
3 files
zz_generated.deepcopy.go
Fix import formatting in generated code                                   
+1/-1     
zz_generated.deepcopy.go
Fix import formatting in generated code                                   
+1/-1     
zz_generated.deepcopy.go
Fix import formatting in generated code                                   
+1/-1     
Configuration changes
5 files
backstage-operator.clusterserviceversion.yaml
Add catalog index image to operator deployment and relatedImages
+5/-1     
deployment.yaml
Add default catalog index image environment variable         
+3/-0     
deployment-patch.yaml
Add catalog index image to operator environment variables
+2/-0     
rhdh-default-config_v1_configmap.yaml
Add default catalog index image environment variable         
+3/-0     
install.yaml
Add catalog index image to operator deployment configuration
+5/-0     
Miscellaneous
1 files
backstage-operator.clusterserviceversion.yaml
Update CSV creation timestamp                                                       
+1/-1     
Documentation
2 files
dynamic-plugins.md
Add catalog index configuration documentation and examples
+7/-0     
catalog-index.yaml
Add example for catalog index image configuration               
+13/-0   

@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Dec 10, 2025

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Organization
best practice
Clarify idempotent env handling

Strengthen the comment to explicitly state idempotent behavior and add a unit
test (already present) reference to guard against regressions.

pkg/model/deployment.go [287-304]

-// adds environment from source to the Backstage Container
-// If an env var with the same name already exists, it will be replaced (not duplicated)
+// adds environment from source to the Backstage Container.
+// Idempotent: if an env var with the same name already exists on a target container, it is replaced (not duplicated),
+// ensuring Kubernetes manifests remain valid on repeated reconciliations.
 func (b *BackstageDeployment) addExtraEnvs(extraEnvs *bsv1.ExtraEnvs) error {
 	if extraEnvs == nil {
-		return nil
+	 return nil
 	}
 	for _, env := range extraEnvs.Envs {
 		filter := containersFilter{names: env.Containers}
 		containers, err := filter.getContainers(b)
 		if err != nil {
 			return fmt.Errorf("can not get containers to add env %s: %w", env.Name, err)
 		}
 		for _, container := range containers {
 			b.setOrAppendEnvVar(container, env.Name, env.Value)
 		}
 	}
 	return nil
 }
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why:
Relevant best practice - When introducing optional features via CR fields, ensure idempotent update semantics to avoid invalid Kubernetes manifests.

Low
General
Fix typo in documentation example
Suggestion Impact:The commit updated the documentation example to remove the space in the image tag, matching the suggested correction.

code diff:

         - name: CATALOG_INDEX_IMAGE
-          value: "quay.io/rhdh/plugin-catalog-index: 1.9"
+          value: "quay.io/rhdh/plugin-catalog-index:1.9"

Remove the space in the image tag quay.io/rhdh/plugin-catalog-index: 1.9 within
the documentation example to ensure it is a valid image reference.

docs/dynamic-plugins.md [77-80]

 - name: CATALOG_INDEX_IMAGE
-  value: "quay.io/rhdh/plugin-catalog-index: 1.9"
+  value: "quay.io/rhdh/plugin-catalog-index:1.9"
   containers:
     - install-dynamic-plugins

[Suggestion processed]

Suggestion importance[1-10]: 5

__

Why: The suggestion correctly identifies a typo in a YAML example within the documentation which, if copied by a user, would likely cause an error.

Low
  • Update

Signed-off-by: Fortune Ndlovu <fndlovu@redhat.com>
@Fortune-Ndlovu Fortune-Ndlovu self-assigned this Dec 11, 2025
Signed-off-by: Fortune Ndlovu <fndlovu@redhat.com>
Signed-off-by: Fortune Ndlovu <fndlovu@redhat.com>
@nickboldt

Copy link
Copy Markdown
Member

Once merged we can look at merging https://gitlab.cee.redhat.com/rhidp/rhdh/-/merge_requests/403 and then validate everything works downstream

Comment thread pkg/model/deployment_test.go Outdated
renovate Bot added 8 commits December 19, 2025 12:02
… digest to d965e66 (redhat-developer#2003)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…veloper#2006)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…developer#2009)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…loper#2011)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…developer#2015)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…veloper#2018)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
… tag to v9.7-1766073541 (redhat-developer#2023)

Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
@Fortune-Ndlovu Fortune-Ndlovu force-pushed the RHIDP-9762-consume-catalog-index branch from 894e949 to 79d7c2d Compare December 19, 2025 12:02
@Fortune-Ndlovu

Copy link
Copy Markdown
Member Author

Thanks @rm3l, I have rebased my branch onto main, and it fixed the CI checks!

Comment thread pkg/model/deployment.go Outdated
Comment thread pkg/model/dynamic-plugins.go Outdated
Comment thread pkg/model/dynamic-plugins.go Outdated
Comment thread pkg/model/dynamic-plugins.go Outdated
Comment thread pkg/model/dynamic-plugins_test.go Outdated
Comment thread pkg/model/deployment_test.go Outdated
Comment thread pkg/model/deployment_test.go Outdated
Comment thread pkg/model/dynamic-plugins.go Outdated
Comment thread pkg/model/dynamic-plugins.go Outdated
@sonarqubecloud

Copy link
Copy Markdown

@rm3l

rm3l commented Dec 23, 2025

Copy link
Copy Markdown
Member

/review

@rhdh-qodo-merge

Copy link
Copy Markdown

Persistent review updated to latest commit 336a136

@rm3l rm3l left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

/lgtm

@openshift-ci

openshift-ci Bot commented Dec 23, 2025

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: rm3l

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants