diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 37883d435..3571c30f1 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -65,7 +65,9 @@ jobs: run: make lint - name: Test - run: make test + run: | + make local-dynamic-plugins + make test - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 @@ -92,6 +94,7 @@ jobs: # Otherwise, we might end up with a race condition where the tool binary is not yet downloaded, # but the `make test` command tries to use it. make manifests generate fmt vet install + make local-dynamic-plugins make run & MAKE_RUN_BG_PID=$! echo "MAKE_RUN_BG_PID=${MAKE_RUN_BG_PID}" | tee -a $GITHUB_ENV diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 34a3330b8..3bff469d9 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -56,7 +56,9 @@ jobs: - name: Test if: steps.changed-files.outputs.any_changed == 'true' - run: make test + run: | + make local-dynamic-plugins + make test - name: Upload coverage to Codecov if: steps.changed-files.outputs.any_changed == 'true' @@ -83,6 +85,7 @@ jobs: # Otherwise, we might end up with a race condition where the tool binary is not yet downloaded, # but the `make test` command tries to use it. make manifests generate fmt vet install + make local-dynamic-plugins make run & - name: Generic Integration test diff --git a/.gitignore b/.gitignore index ebe1dfba7..db8b70c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ bundle_tmp* testbin/* Dockerfile.cross __debug_bin* +tmp/* # Test binary, built with `go test -c` *.test @@ -47,4 +48,7 @@ rhdh-disconnected-install.Dockerfile .oc-mirror.log # from mirror-plugins.sh script -rhdh-plugin-mirroring-summary.txt \ No newline at end of file +rhdh-plugin-mirroring-summary.txt + +# local-test directories are user-specific local configs for testing +**/local-test/ \ No newline at end of file diff --git a/Makefile b/Makefile index 44bcbeaad..860a80096 100644 --- a/Makefile +++ b/Makefile @@ -165,14 +165,12 @@ fmt: goimports ## Format the code using goimports .PHONY: test test: manifests generate fmt vet setup-envtest $(LOCALBIN) ## Run tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path - mkdir -p $(LOCALBIN)/default-config && rm -fr $(LOCALBIN)/default-config/* && cp -r config/profile/$(PROFILE)/default-config/* $(LOCALBIN)/default-config - mkdir -p $(LOCALBIN)/plugin-deps && rm -fr $(LOCALBIN)/plugin-deps/* && cp -r config/profile/$(PROFILE)/plugin-deps/* $(LOCALBIN)/plugin-deps 2>/dev/null || : + @./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $(PKGS) -coverprofile cover.out .PHONY: integration-test integration-test: ginkgo manifests generate fmt vet envtest $(LOCALBIN) ## Run integration_tests. We need LOCALBIN=$(LOCALBIN) to get correct default-config path - mkdir -p $(LOCALBIN)/default-config && rm -fr $(LOCALBIN)/default-config/* && cp -r config/profile/$(PROFILE)/default-config/* $(LOCALBIN)/default-config - mkdir -p $(LOCALBIN)/plugin-deps && rm -fr $(LOCALBIN)/plugin-deps/* && cp -r config/profile/$(PROFILE)/plugin-deps/* $(LOCALBIN)/plugin-deps 2>/dev/null || : + @./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) LOCALBIN=$(LOCALBIN) KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" $(GINKGO) -v -r $(ARGS) integration_tests # After this time, Ginkgo will emit progress reports, so we can get visibility into long-running tests. @@ -232,10 +230,14 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet $(LOCALBIN) ## Run a controller from your host. - mkdir -p $(LOCALBIN)/default-config/ && rm -fr $(LOCALBIN)/default-config/* && cp -r config/profile/$(PROFILE)/default-config/* $(LOCALBIN)/default-config/ - mkdir -p $(LOCALBIN)/plugin-deps/ && rm -fr $(LOCALBIN)/plugin-deps/* && cp -r config/profile/$(PROFILE)/plugin-deps/* $(LOCALBIN)/plugin-deps/ 2>/dev/null || : + @./hack/copy-local-dynamic-plugins.sh $(PROFILE) $(LOCALBIN) go run -C $(LOCALBIN) ../cmd/main.go $(ARGS) +.PHONY: local-dynamic-plugins +local-dynamic-plugins: ## Generate local-test dynamic-plugins.yaml from catalog-index image for local testing + @echo "Generating local-test dynamic-plugins.yaml from catalog-index image..." + @./hack/create-local-dynamic-plugins.sh + # by default images expire from quay registry after 14 days # set a longer timeout (or set no label to keep images forever) LABEL ?= quay.expires-after=14d 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 0e0086d44..1ee510162 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/developer.md b/docs/developer.md index 910ca7476..8e327bd1f 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -62,10 +62,36 @@ make uninstall You can run your controller standalone (this will run in the foreground, so switch to a new terminal if you want to leave it running) This way you can see controllers log just in your terminal window which is quite convenient for debugging. +#### Local Dynamic Plugins Setup (rhdh profile) + +For the `rhdh` profile, default dynamic plugins configuration is provided by the catalog-index image (not stored in the repository). In production, this configuration is extracted at runtime. + +For local development and testing, we download the configuration once and reuse it to avoid repeated image pulls on every `make run` or `make test` invocation. + +```sh +make local-dynamic-plugins +``` + +This command extracts `dynamic-plugins.default.yaml` from the catalog-index image and creates `config/profile/rhdh/local-test/dynamic-plugins.yaml`. The `local-test` directory is git-ignored, allowing you to customize dynamic-plugins for local testing. + +**This only needs to be run once.** Re-run it when you want to refresh with newer catalog-index data. + +**Requirements:** +- `skopeo` must be installed (`brew install skopeo` on macOS, `dnf install skopeo` on Fedora/RHEL) + +**Optional:** Specify a different catalog-index image: +```sh +IMAGE=quay.io/rhdh/plugin-catalog-index:1.10 make local-dynamic-plugins +``` + +#### Running the controller + ```sh make [PROFILE=] [install] run ``` +**Note:** For the `rhdh` profile, `make run`, `make test`, and `make integration-test` require the `local-test` directory to exist. If it doesn't, the command will fail with instructions to run `make local-dynamic-plugins` first. + You can use it for manual and automated ([such as](../integration_tests/README.md) `USE_EXISTING_CLUSTER=true make integration-test`) tests efficiently, but, note, RBAC is not working with this kind of deployment. ### Deploy operator to the real cluster diff --git a/docs/dynamic-plugins.md b/docs/dynamic-plugins.md index 504dbac4b..5efdc278e 100644 --- a/docs/dynamic-plugins.md +++ b/docs/dynamic-plugins.md @@ -84,6 +84,20 @@ The extraction directory can be configured via the `CATALOG_ENTITIES_EXTRACT_DIR More details in [Catalog Entities Extraction](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#catalog-entities-extraction). +## Plugin URL References + +The operator supports special URL reference syntax in plugin package URLs, allowing users to reference versions or plugins from the default configuration. + +**Operator behavior:** +- The operator resolves all references during ConfigMap merge (before passing to the init container) +- If a reference cannot be resolved, the operator returns an error and the Backstage CR will not reconcile + +### Inherit Reference (`:{{inherit}}`) + +Allows inheriting version (tag or digest) from default plugins. Useful when overriding plugin settings without hardcoding versions. + +For syntax details and examples, see [OCI Package Version Inheritance](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#oci-package-version-inheritance). + ## Dynamic plugins dependency management ### Overview diff --git a/hack/copy-local-dynamic-plugins.sh b/hack/copy-local-dynamic-plugins.sh new file mode 100755 index 000000000..313463009 --- /dev/null +++ b/hack/copy-local-dynamic-plugins.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Copy default-config and plugin-deps to LOCALBIN for 'make run' +# +# For all profiles: copies default-config and plugin-deps +# For 'rhdh' profile only: overlays dynamic-plugins.yaml from local-test +# +# Usage: ./hack/copy-local-dynamic-plugins.sh + +set -euo pipefail + +PROFILE="${1:-rhdh}" +LOCALBIN="${2:-./bin}" + +DEFAULT_CONFIG_SRC="config/profile/${PROFILE}/default-config" +PLUGIN_DEPS_SRC="config/profile/${PROFILE}/plugin-deps" +DEFAULT_CONFIG_TARGET="${LOCALBIN}/default-config" +PLUGIN_DEPS_TARGET="${LOCALBIN}/plugin-deps" + +# Step 1: Copy default-config for all profiles +# This includes dynamic-plugins.yaml from default-config +mkdir -p "${DEFAULT_CONFIG_TARGET}" +rm -fr "${DEFAULT_CONFIG_TARGET:?}"/* +cp -r "${DEFAULT_CONFIG_SRC}"/* "${DEFAULT_CONFIG_TARGET}/" + +# Step 2: Copy plugin-deps if it exists +mkdir -p "${PLUGIN_DEPS_TARGET}" +rm -fr "${PLUGIN_DEPS_TARGET:?}"/* +if [[ -d "${PLUGIN_DEPS_SRC}" ]]; then + cp -r "${PLUGIN_DEPS_SRC}"/* "${PLUGIN_DEPS_TARGET}/" 2>/dev/null || : +fi + +# Step 3: For rhdh profile only, overlay dynamic-plugins.yaml from local-test +# (replaces the one copied from default-config) +if [[ "${PROFILE}" == "rhdh" ]]; then + LOCAL_TEST_DIR="config/profile/${PROFILE}/local-test" + DYNAMIC_PLUGINS_FILE="${LOCAL_TEST_DIR}/dynamic-plugins.yaml" + + if [[ ! -d "${LOCAL_TEST_DIR}" ]]; then + echo "Error: local-test directory not found at ${LOCAL_TEST_DIR}" >&2 + echo "" >&2 + echo "Run 'make local-dynamic-plugins' to generate it first." >&2 + echo "" >&2 + echo "This extracts dynamic-plugins.default.yaml from the catalog-index image" >&2 + echo "and creates a local configuration for testing." >&2 + exit 1 + fi + + if [[ ! -f "${DYNAMIC_PLUGINS_FILE}" ]]; then + echo "Error: dynamic-plugins.yaml not found at ${DYNAMIC_PLUGINS_FILE}" >&2 + echo "" >&2 + echo "Run 'make local-dynamic-plugins' to regenerate the local-test directory." >&2 + exit 1 + fi + + cp "${DYNAMIC_PLUGINS_FILE}" "${DEFAULT_CONFIG_TARGET}/" +fi diff --git a/hack/create-local-dynamic-plugins.sh b/hack/create-local-dynamic-plugins.sh new file mode 100755 index 000000000..f9a18a886 --- /dev/null +++ b/hack/create-local-dynamic-plugins.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Extract dynamic-plugins.default.yaml from catalog-index image +# and create local-test/default-dynamic-plugins.yaml for local testing +# +# The local-test directory is git-ignored, allowing developers to customize +# dynamic-plugins for local testing. Other config files come from default-config. +# +# Usage: Run from project root: +# ./hack/create-local-dynamic-plugins.sh +# IMAGE=quay.io/rhdh/plugin-catalog-index:1.10 ./hack/create-local-dynamic-plugins.sh + +set -euo pipefail + +# Save current directory (project root) +PROJECT_ROOT="$(pwd)" + +IMAGE="${IMAGE:-quay.io/rhdh/plugin-catalog-index:latest}" +OUTPUT_DIR="config/profile/rhdh/local-test" +OUTPUT_FILE="${OUTPUT_DIR}/dynamic-plugins.yaml" + +# Verify we're in project root +if [[ ! -f "go.mod" ]] || [[ ! -d "config/profile/rhdh" ]]; then + echo "Error: This script must be run from the project root directory" >&2 + echo "Usage: ./hack/create-local-dynamic-plugins.sh" >&2 + exit 1 +fi + +# Verify skopeo is available +if ! command -v skopeo &> /dev/null; then + echo "Error: skopeo command not found" >&2 + echo "Please install skopeo to use this script" >&2 + echo " macOS: brew install skopeo" >&2 + echo " Fedora/RHEL: dnf install skopeo" >&2 + exit 1 +fi + +# Convert paths to absolute +OUTPUT_DIR="${PROJECT_ROOT}/${OUTPUT_DIR}" +OUTPUT_FILE="${PROJECT_ROOT}/${OUTPUT_FILE}" + +echo "=== Creating local-test dynamic-plugins from catalog-index ===" +echo "Source image: ${IMAGE}" +echo "Output file: ${OUTPUT_FILE}" +echo "" + +# Create local-test directory +mkdir -p "${OUTPUT_DIR}" + +# Create temporary directory +TEMP_DIR=$(mktemp -d) +trap 'rm -rf ${TEMP_DIR}' EXIT + +echo "Extracting dynamic-plugins.default.yaml from image using skopeo..." +cd "${TEMP_DIR}" + +# Use skopeo to copy image to a local directory (linux/amd64) +skopeo copy --override-arch amd64 --override-os linux "docker://${IMAGE}" "dir:./image" + +# Find and extract the layer containing dynamic-plugins.default.yaml +for layer in image/*; do + [[ "$layer" == */manifest.json || "$layer" == */version ]] && continue + if tar -tf "$layer" 2>/dev/null | grep -q "dynamic-plugins.default.yaml"; then + tar -xf "$layer" dynamic-plugins.default.yaml 2>/dev/null || true + break + fi +done + +# Check if dynamic-plugins.default.yaml was extracted +if [[ ! -f "dynamic-plugins.default.yaml" ]]; then + echo "Error: dynamic-plugins.default.yaml not found in image" >&2 + echo "Trying alternative extraction..." >&2 + + # Alternative: extract all layers and search + mkdir -p rootfs + for layer in image/*; do + [[ "$layer" == */manifest.json || "$layer" == */version ]] && continue + tar -xf "$layer" -C rootfs 2>/dev/null || true + done + + if [[ -f "rootfs/dynamic-plugins.default.yaml" ]]; then + cp rootfs/dynamic-plugins.default.yaml ./ + else + echo "Error: Could not find dynamic-plugins.default.yaml in any layer" >&2 + exit 1 + fi +fi + +# Create ConfigMap with the extracted content +echo "Creating ConfigMap at ${OUTPUT_FILE}..." + +# Create ConfigMap header +cat > "${OUTPUT_FILE}" <<'EOF' +# WARNING: This file is auto-generated! +# +# This file is automatically extracted from the catalog-index image +# by ./hack/create-local-dynamic-plugins.sh +# This is for LOCAL TESTING ONLY and is git-ignored. +# +# You can edit this file for local testing purposes. +# To regenerate: make local-dynamic-plugins +# +apiVersion: v1 +kind: ConfigMap +metadata: + name: default-dynamic-plugins +data: + dynamic-plugins.yaml: | +EOF + +# Append the extracted content with proper indentation (4 spaces) +sed 's/^/ /' dynamic-plugins.default.yaml >> "${OUTPUT_FILE}" + +echo "" +echo "=== Local test config created ===" +echo "File: ${OUTPUT_FILE}" +echo "Size: $(du -h "${OUTPUT_FILE}" | cut -f1)" +echo "" +echo "This file is git-ignored. Edit as needed for local testing." +echo "Use 'make run' to test (default-config + local-test overlay)." diff --git a/pkg/model/dynamic-plugins-reference.go b/pkg/model/dynamic-plugins-reference.go new file mode 100644 index 000000000..284040474 --- /dev/null +++ b/pkg/model/dynamic-plugins-reference.go @@ -0,0 +1,133 @@ +package model + +import ( + "fmt" + "strings" +) + +const inheritSuffix = ":{{inherit}}" +const refPrefix = "ref://" + +// resolveReferences resolves all reference types in plugin package URLs. +// Currently supports: +// - {{inherit}}: inherits version/digest from base plugins +// - ref://: references another plugin by name (TODO) +func resolveReferences(plugins []DynaPlugin, basePlugins []DynaPlugin) ([]DynaPlugin, error) { + resolved := make([]DynaPlugin, len(plugins)) + copy(resolved, plugins) + + // Build lookup map for base plugins (used by inherit resolver) + baseURLMap := buildBaseURLMap(basePlugins) + + for i := range resolved { + plugin := &resolved[i] + var err error + + switch { + case strings.Contains(plugin.Package, inheritSuffix): + resolved[i].Package, err = resolveInheritReference(plugin.Package, baseURLMap) + case strings.HasPrefix(plugin.Package, refPrefix): + resolved[i].Package, err = resolveRefReference(plugin.Package, basePlugins) + default: + continue + } + + if err != nil { + return nil, err + } + } + + return resolved, nil +} + +// buildBaseURLMap creates a lookup map from base URL to full package URL. +func buildBaseURLMap(basePlugins []DynaPlugin) map[string]string { + baseURLMap := make(map[string]string) + for i := range basePlugins { + plugin := &basePlugins[i] + if plugin.Package == "" { + continue + } + baseURL := plugin.BaseURL() + if baseURL != "" { + baseURLMap[baseURL] = plugin.Package + } + } + return baseURLMap +} + +// resolveInheritReference resolves a single {{inherit}} reference. +// For example, oci://registry/plugin:{{inherit}} will be replaced with +// oci://registry/plugin@sha256:abc123 if found in baseURLMap. +func resolveInheritReference(packageURL string, baseURLMap map[string]string) (string, error) { + // Parse package to extract !plugin-path suffix if present + var pluginPath string + if idx := strings.LastIndex(packageURL, "!"); idx != -1 { + pluginPath = packageURL[idx:] // includes "!" + packageURL = packageURL[:idx] + } + + // Extract base URL (strip :{{inherit}}) + baseURL := strings.Replace(packageURL, inheritSuffix, "", 1) + + // Look up the full URL in basePlugins + fullURL, found := baseURLMap[baseURL] + if !found { + return "", fmt.Errorf("cannot resolve {{inherit}} reference: no matching plugin found for base URL %q in default plugins", baseURL) + } + + // If user specified !plugin-path, use it; otherwise use full default URL + if pluginPath != "" { + // Extract image part from default (without !plugin-path) + if idx := strings.LastIndex(fullURL, "!"); idx != -1 { + fullURL = fullURL[:idx] + } + return fullURL + pluginPath, nil + } + + return fullURL, nil +} + +// resolveRefReference resolves a ref:// reference by looking up plugin by name. +// For example, ref://my-plugin will be replaced with the full package URL +// of the plugin named "my-plugin" in basePlugins. +func resolveRefReference(packageURL string, basePlugins []DynaPlugin) (string, error) { + // TODO: implement ref:// resolution + // ref:// should look up the plugin by name in basePlugins + return "", fmt.Errorf("ref:// references are not yet implemented: %s", packageURL) +} + +// BaseURL extracts the base URL from a plugin package URL +// by removing the tag or digest suffix. +// For example: +// - oci://registry/plugin:tag -> oci://registry/plugin +// - oci://registry/plugin@sha256:abc -> oci://registry/plugin +// - ./local/path -> ./local/path (unchanged) +func (p *DynaPlugin) BaseURL() string { + packageURL := p.Package + + // Only process OCI URLs + if !strings.HasPrefix(packageURL, "oci://") { + return packageURL + } + + // Strip !plugin-path suffix first if present + if idx := strings.LastIndex(packageURL, "!"); idx != -1 { + packageURL = packageURL[:idx] + } + + // Handle OCI URLs with digest (@sha256:...) + if idx := strings.LastIndex(packageURL, "@"); idx != -1 { + return packageURL[:idx] + } + + // Handle OCI URLs with tag (:tag) + schemeEnd := len("oci://") + rest := packageURL[schemeEnd:] + if idx := strings.LastIndex(rest, ":"); idx != -1 { + return packageURL[:schemeEnd+idx] + } + + // No tag or digest found + return packageURL +} diff --git a/pkg/model/dynamic-plugins-reference_test.go b/pkg/model/dynamic-plugins-reference_test.go new file mode 100644 index 000000000..1ca9c1484 --- /dev/null +++ b/pkg/model/dynamic-plugins-reference_test.go @@ -0,0 +1,249 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBaseURL(t *testing.T) { + tests := []struct { + name string + package_ string + expected string + }{ + { + name: "OCI with digest", + package_: "oci://quay.io/rhdh/plugin@sha256:abc123", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI with tag", + package_: "oci://quay.io/rhdh/plugin:v1.0.0", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI with digest and plugin path", + package_: "oci://quay.io/rhdh/plugin@sha256:abc123!my-plugin", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI with tag and plugin path", + package_: "oci://quay.io/rhdh/plugin:v1.0.0!my-plugin", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI with inherit suffix", + package_: "oci://quay.io/rhdh/plugin:{{inherit}}", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI with inherit suffix and plugin path", + package_: "oci://quay.io/rhdh/plugin:{{inherit}}!my-plugin", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "OCI without tag or digest", + package_: "oci://quay.io/rhdh/plugin", + expected: "oci://quay.io/rhdh/plugin", + }, + { + name: "Local path", + package_: "./dynamic-plugins/dist/my-plugin", + expected: "./dynamic-plugins/dist/my-plugin", + }, + { + name: "NPM package", + package_: "@backstage/plugin-catalog", + expected: "@backstage/plugin-catalog", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := DynaPlugin{Package: tt.package_} + result := plugin.BaseURL() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestResolveInheritReference(t *testing.T) { + baseURLMap := map[string]string{ + "oci://quay.io/rhdh/plugin-a": "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a-path", + "oci://quay.io/rhdh/plugin-b": "oci://quay.io/rhdh/plugin-b@sha256:def456", + "oci://registry.access.redhat.com/rh": "oci://registry.access.redhat.com/rh@sha256:xyz789!rh-plugin", + } + + tests := []struct { + name string + packageURL string + expected string + expectError bool + }{ + { + name: "inherit without plugin path - uses full default", + packageURL: "oci://quay.io/rhdh/plugin-a:{{inherit}}", + expected: "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a-path", + }, + { + name: "inherit with plugin path - keeps user's plugin path", + packageURL: "oci://quay.io/rhdh/plugin-a:{{inherit}}!custom-path", + expected: "oci://quay.io/rhdh/plugin-a@sha256:abc123!custom-path", + }, + { + name: "inherit from base without plugin path", + packageURL: "oci://quay.io/rhdh/plugin-b:{{inherit}}", + expected: "oci://quay.io/rhdh/plugin-b@sha256:def456", + }, + { + name: "inherit from base without plugin path - user adds plugin path", + packageURL: "oci://quay.io/rhdh/plugin-b:{{inherit}}!my-plugin", + expected: "oci://quay.io/rhdh/plugin-b@sha256:def456!my-plugin", + }, + { + name: "inherit with no matching base - error", + packageURL: "oci://quay.io/rhdh/unknown:{{inherit}}", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := resolveInheritReference(tt.packageURL, baseURLMap) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot resolve {{inherit}} reference") + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestResolveReferences(t *testing.T) { + basePlugins := []DynaPlugin{ + {Package: "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a"}, + {Package: "oci://quay.io/rhdh/plugin-b@sha256:def456"}, + {Package: "./dynamic-plugins/dist/local-plugin"}, + } + + tests := []struct { + name string + plugins []DynaPlugin + expected []string + expectError bool + }{ + { + name: "resolve single inherit reference", + plugins: []DynaPlugin{ + {Package: "oci://quay.io/rhdh/plugin-a:{{inherit}}"}, + }, + expected: []string{"oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a"}, + }, + { + name: "resolve inherit with custom plugin path", + plugins: []DynaPlugin{ + {Package: "oci://quay.io/rhdh/plugin-a:{{inherit}}!custom"}, + }, + expected: []string{"oci://quay.io/rhdh/plugin-a@sha256:abc123!custom"}, + }, + { + name: "mixed - some inherit, some regular", + plugins: []DynaPlugin{ + {Package: "oci://quay.io/rhdh/plugin-a:{{inherit}}"}, + {Package: "oci://quay.io/other/plugin@sha256:fixed"}, + {Package: "./local/path"}, + }, + expected: []string{ + "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a", + "oci://quay.io/other/plugin@sha256:fixed", + "./local/path", + }, + }, + { + name: "no references to resolve", + plugins: []DynaPlugin{ + {Package: "oci://quay.io/plugin@sha256:123"}, + {Package: "./local"}, + }, + expected: []string{ + "oci://quay.io/plugin@sha256:123", + "./local", + }, + }, + { + name: "inherit reference not found - error", + plugins: []DynaPlugin{ + {Package: "oci://unknown/plugin:{{inherit}}"}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := resolveReferences(tt.plugins, basePlugins) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, len(tt.expected), len(result)) + for i, pkg := range tt.expected { + assert.Equal(t, pkg, result[i].Package) + } + } + }) + } +} + +func TestMergePluginsDataWithInherit(t *testing.T) { + // Default config with versioned plugins + defaultData := ` +plugins: + - package: "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a" + disabled: false + pluginConfig: + key1: "value1" + - package: "oci://quay.io/rhdh/plugin-b@sha256:def456" + disabled: true +` + + // User config using inherit + userData := ` +plugins: + - package: "oci://quay.io/rhdh/plugin-a:{{inherit}}" + pluginConfig: + key1: "overridden" + - package: "oci://quay.io/rhdh/plugin-b:{{inherit}}!custom-path" +` + + mergedData, err := MergePluginsData(defaultData, userData) + assert.NoError(t, err) + assert.NotEmpty(t, mergedData) + + // Verify the inherit was resolved + assert.Contains(t, mergedData, "oci://quay.io/rhdh/plugin-a@sha256:abc123!plugin-a") + assert.Contains(t, mergedData, "oci://quay.io/rhdh/plugin-b@sha256:def456!custom-path") + assert.NotContains(t, mergedData, "{{inherit}}") +} + +func TestMergePluginsDataWithInheritError(t *testing.T) { + defaultData := ` +plugins: + - package: "oci://quay.io/rhdh/plugin-a@sha256:abc123" +` + + // User config referencing non-existent plugin + userData := ` +plugins: + - package: "oci://quay.io/rhdh/unknown-plugin:{{inherit}}" +` + + _, err := MergePluginsData(defaultData, userData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot resolve {{inherit}} reference") +} diff --git a/pkg/model/dynamic-plugins.go b/pkg/model/dynamic-plugins.go index bce1bef74..4e8f8db4d 100644 --- a/pkg/model/dynamic-plugins.go +++ b/pkg/model/dynamic-plugins.go @@ -141,7 +141,7 @@ func (p *DynamicPlugins) addToModel(model *BackstageModel, backstage api.Backsta // TODO // extract pluginConfigs // merge with app-config (deep merge) -func (p *DynamicPlugins) updateAndValidate(backstage api.Backstage, scheme *runtime.Scheme) error { +func (p *DynamicPlugins) updateAndValidate(backstage api.Backstage, _ *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 == "") { @@ -261,6 +261,13 @@ func MergePluginsData(firstData, secondData string) (string, error) { return "", fmt.Errorf("failed to unmarshal second ConfigMap data: %w", err) } + // Resolve references ({{inherit}}, ref://, etc.) in secondPluginsConfig using firstPluginsConfig as base + resolvedPlugins, err := resolveReferences(secondPluginsConfig.Plugins, firstPluginsConfig.Plugins) + if err != nil { + return "", err + } + secondPluginsConfig.Plugins = resolvedPlugins + // Merge Plugins by package field pluginMap := make(map[string]DynaPlugin) for _, plugin := range firstPluginsConfig.Plugins {