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 bd2d60544..9e8b769c9 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 3ffa82502..11645437b 100644 --- a/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/backstage.io/manifests/backstage-operator.clusterserviceversion.yaml @@ -35,7 +35,7 @@ metadata: } } ] - createdAt: "2025-12-02T11:27:28Z" + createdAt: "2025-12-18T16:30:38Z" description: Backstage Operator operators.operatorframework.io/builder: operator-sdk-v1.37.0 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 ec61610c9..610cc193f 100644 --- a/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml +++ b/bundle/rhdh/manifests/backstage-operator.clusterserviceversion.yaml @@ -39,7 +39,7 @@ metadata: categories: Developer Tools certified: "true" containerImage: registry.redhat.io/rhdh/rhdh-rhel9-operator:1.9 - createdAt: "2025-12-08T08:32:34Z" + createdAt: "2025-12-18T16:30:39Z" 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 @@ -338,6 +338,8 @@ spec: value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next + - name: RELATED_IMAGE_catalog_index + value: quay.io/rhdh/plugin-catalog-index:1.9 image: quay.io/rhdh/rhdh-rhel9-operator:1.9 livenessProbe: httpGet: @@ -459,5 +461,7 @@ spec: name: postgresql - image: quay.io/rhdh-community/rhdh:next name: backstage + - image: quay.io/rhdh/plugin-catalog-index:1.9 + name: catalog-index replaces: rhdh-operator.v1.8.0 version: 1.9.0 diff --git a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml index f184b8b88..94e9c064a 100644 --- a/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml +++ b/bundle/rhdh/manifests/rhdh-default-config_v1_configmap.yaml @@ -236,6 +236,9 @@ data: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "30000000" + # 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:1.9" volumeMounts: - mountPath: /dynamic-plugins-root name: dynamic-plugins-root diff --git a/config/profile/rhdh/default-config/deployment.yaml b/config/profile/rhdh/default-config/deployment.yaml index 74ba450fd..b8f6f8f13 100644 --- a/config/profile/rhdh/default-config/deployment.yaml +++ b/config/profile/rhdh/default-config/deployment.yaml @@ -60,6 +60,9 @@ spec: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "30000000" + # 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:1.9" volumeMounts: - mountPath: /dynamic-plugins-root name: dynamic-plugins-root diff --git a/config/profile/rhdh/patches/deployment-patch.yaml b/config/profile/rhdh/patches/deployment-patch.yaml index 62daa5a21..788c85337 100644 --- a/config/profile/rhdh/patches/deployment-patch.yaml +++ b/config/profile/rhdh/patches/deployment-patch.yaml @@ -28,4 +28,6 @@ spec: value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next + - name: RELATED_IMAGE_catalog_index + value: quay.io/rhdh/plugin-catalog-index:1.9 diff --git a/dist/rhdh/install.yaml b/dist/rhdh/install.yaml index 5303c4666..ea97ba919 100644 --- a/dist/rhdh/install.yaml +++ b/dist/rhdh/install.yaml @@ -2088,6 +2088,9 @@ data: value: /opt/app-root/src/.npmrc.dynamic-plugins/.npmrc - name: MAX_ENTRY_SIZE value: "30000000" + # 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:1.9" volumeMounts: - mountPath: /dynamic-plugins-root name: dynamic-plugins-root @@ -2948,6 +2951,8 @@ spec: value: quay.io/fedora/postgresql-15:latest - name: RELATED_IMAGE_backstage value: quay.io/rhdh-community/rhdh:next + - name: RELATED_IMAGE_catalog_index + value: quay.io/rhdh/plugin-catalog-index:1.9 image: quay.io/rhdh/rhdh-rhel9-operator:1.9 livenessProbe: httpGet: diff --git a/docs/dynamic-plugins.md b/docs/dynamic-plugins.md index 1d7cb724c..144cce3c3 100644 --- a/docs/dynamic-plugins.md +++ b/docs/dynamic-plugins.md @@ -43,6 +43,13 @@ spec: TODO: Dynamic plugins can be configured to use container registries for authentication and image pulling. This section should cover the configuration options available for container registry integration with dynamic plugins. +## Catalog Index Configuration + +The operator supports loading default plugin configurations from an OCI container image (catalog index). For general information about how the catalog index works, see [Using a Catalog Index Image for Default Plugin Configurations](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#using-a-catalog-index-image-for-default-plugin-configurations). + +By default, the `rhdh` profile of operator [injects](../config/profile/rhdh/patches/deployment-patch.yaml#L31-L32) the `CATALOG_INDEX_IMAGE` environment variable in the RHDH `install-dynamic-plugins` init containers. +To use a different catalog index image, such as a newer version or a mirrored image, use the `extraEnvs` field in your Backstage CR. See [examples/catalog-index.yaml](../examples/catalog-index.yaml) for a complete example. + ## Dynamic plugins dependency management ### Overview diff --git a/examples/catalog-index.yaml b/examples/catalog-index.yaml new file mode 100644 index 000000000..284405b28 --- /dev/null +++ b/examples/catalog-index.yaml @@ -0,0 +1,13 @@ +apiVersion: rhdh.redhat.com/v1alpha5 +kind: Backstage +metadata: + name: my-backstage +spec: + application: + extraEnvs: + envs: + - name: CATALOG_INDEX_IMAGE + value: "quay.io/rhdh/plugin-catalog-index:1.9" + containers: + - install-dynamic-plugins + diff --git a/pkg/model/deployment.go b/pkg/model/deployment.go index 3fd3935da..e4e6d747f 100644 --- a/pkg/model/deployment.go +++ b/pkg/model/deployment.go @@ -29,6 +29,7 @@ const ( ) const BackstageImageEnvVar = "RELATED_IMAGE_backstage" +const CatalogIndexImageEnvVar = "RELATED_IMAGE_catalog_index" const DefaultMountDir = "/opt/app-root/src" const ExtConfigHashAnnotation = "rhdh.redhat.com/ext-config-hash" @@ -111,6 +112,13 @@ func (b *BackstageDeployment) addToModel(model *BackstageModel, backstage bsv1.B b.setImage(ptr.To(os.Getenv(BackstageImageEnvVar))) } + // Set CATALOG_INDEX_IMAGE from operator env var BEFORE extraEnvs are applied, so user-specified extraEnvs can still override this value + if catalogIndexImage := os.Getenv(CatalogIndexImageEnvVar); catalogIndexImage != "" { + if i, _ := DynamicPluginsInitContainer(b.podSpec().InitContainers); i >= 0 { + b.setOrAppendEnvVar(&b.podSpec().InitContainers[i], "CATALOG_INDEX_IMAGE", catalogIndexImage) + } + } + if err := b.setDeployment(backstage); err != nil { return false, err } @@ -285,6 +293,7 @@ func (b *BackstageDeployment) setImage(image *string) { } // adds environment from source to the Backstage Container +// If an env var with the same name already exists, it will be replaced (not duplicated) func (b *BackstageDeployment) addExtraEnvs(extraEnvs *bsv1.ExtraEnvs) error { if extraEnvs == nil { return nil @@ -296,16 +305,23 @@ func (b *BackstageDeployment) addExtraEnvs(extraEnvs *bsv1.ExtraEnvs) error { return fmt.Errorf("can not get containers to add env %s: %w", env.Name, err) } for _, container := range containers { - container.Env = - append(container.Env, corev1.EnvVar{ - Name: env.Name, - Value: env.Value, - }) + 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}) +} + // MountFilesFrom adds Volume to specified podSpec and related VolumeMounts to specified belonging to this podSpec container // from ConfigMap or Secret volume source // containers - array of containers to add VolumeMount(s) to diff --git a/pkg/model/dynamic-plugins_test.go b/pkg/model/dynamic-plugins_test.go index 2364f42f1..22ec4f8c2 100644 --- a/pkg/model/dynamic-plugins_test.go +++ b/pkg/model/dynamic-plugins_test.go @@ -15,6 +15,7 @@ import ( bsv1 "github.com/redhat-developer/rhdh-operator/api/v1alpha5" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/stretchr/testify/assert" @@ -247,6 +248,128 @@ func initContainer(model *BackstageModel) *corev1.Container { 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") +} + func TestUnmarshalDynaPluginsConfig(t *testing.T) { yamlData := ` plugins: diff --git a/pkg/model/testdata/janus-deployment.yaml b/pkg/model/testdata/janus-deployment.yaml index ae21b50cc..59f9b792e 100644 --- a/pkg/model/testdata/janus-deployment.yaml +++ b/pkg/model/testdata/janus-deployment.yaml @@ -40,6 +40,9 @@ spec: env: - name: NPM_CONFIG_USERCONFIG value: /opt/app-root/src/.npmrc.dynamic-plugins + # 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:1.9" image: 'quay.io/rhdh/rhdh-hub-rhel9:next' imagePullPolicy: IfNotPresent name: install-dynamic-plugins