From ba41badf63073be7b63210a855d7413928a1fabd Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Tue, 27 Jan 2026 12:51:14 +0530 Subject: [PATCH 1/2] feat: add plugin metadata utility for dynamic plugins configuration --- docs/.vitepress/config.ts | 5 + docs/api/deployment/rhdh-deployment.md | 7 +- docs/api/index.md | 1 + docs/api/utils/plugin-metadata.md | 273 +++++++++++++ docs/guide/configuration/config-files.md | 81 ++++ .../configuration/environment-variables.md | 12 + docs/guide/configuration/index.md | 4 + docs/guide/deployment/index.md | 9 +- docs/guide/deployment/rhdh-deployment.md | 11 +- docs/guide/index.md | 2 +- docs/guide/quick-start.md | 4 + docs/guide/utilities/index.md | 1 + docs/guide/utilities/plugin-metadata.md | 141 +++++++ docs/tutorials/plugin-testing.md | 4 + src/deployment/rhdh/deployment.ts | 76 +++- src/utils/merge-yamls.ts | 2 +- src/utils/plugin-metadata.ts | 376 ++++++++++++++++++ 17 files changed, 981 insertions(+), 28 deletions(-) create mode 100644 docs/api/utils/plugin-metadata.md create mode 100644 docs/guide/utilities/plugin-metadata.md create mode 100644 src/utils/plugin-metadata.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 540e206..aafb8d8 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -153,6 +153,10 @@ export default defineConfig({ text: "Environment Substitution", link: "/guide/utilities/environment-substitution", }, + { + text: "Plugin Metadata", + link: "/guide/utilities/plugin-metadata", + }, ], }, { @@ -244,6 +248,7 @@ export default defineConfig({ { text: "Bash ($)", link: "/api/utils/bash" }, { text: "YAML Merging", link: "/api/utils/merge-yamls" }, { text: "envsubst", link: "/api/utils/common" }, + { text: "Plugin Metadata", link: "/api/utils/plugin-metadata" }, ], }, { diff --git a/docs/api/deployment/rhdh-deployment.md b/docs/api/deployment/rhdh-deployment.md index c9fb738..3f3fe1c 100644 --- a/docs/api/deployment/rhdh-deployment.md +++ b/docs/api/deployment/rhdh-deployment.md @@ -75,9 +75,10 @@ async deploy(): Promise Deploy RHDH to the cluster. This: 1. Merges configuration files -2. Applies ConfigMaps and Secrets -3. Installs RHDH via Helm or Operator -4. Waits for deployment to be ready +2. [Injects plugin metadata](/guide/configuration/config-files#plugin-metadata-injection) into dynamic plugins config +3. Applies ConfigMaps and Secrets +4. Installs RHDH via Helm or Operator +5. Waits for deployment to be ready ```typescript await rhdh.deploy(); diff --git a/docs/api/index.md b/docs/api/index.md index 522f42a..f021689 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -50,6 +50,7 @@ Complete API documentation for all exports from `rhdh-e2e-test-utils`. - [Bash ($)](/api/utils/bash) - Shell command execution - [YAML Merging](/api/utils/merge-yamls) - YAML utilities - [envsubst](/api/utils/common) - Environment substitution +- [Plugin Metadata](/api/utils/plugin-metadata) - Plugin metadata injection ### ESLint diff --git a/docs/api/utils/plugin-metadata.md b/docs/api/utils/plugin-metadata.md new file mode 100644 index 0000000..1536ef8 --- /dev/null +++ b/docs/api/utils/plugin-metadata.md @@ -0,0 +1,273 @@ +# Plugin Metadata + +Utilities for loading and injecting plugin metadata from Package CRD files into dynamic plugins configuration. + +## Import + +```typescript +import { + shouldInjectPluginMetadata, + extractPluginName, + getMetadataDirectory, + parseAllMetadataFiles, + injectMetadataConfig, + generateDynamicPluginsConfigFromMetadata, + loadAndInjectPluginMetadata, +} from "rhdh-e2e-test-utils/utils"; +``` + +## Functions + +### shouldInjectPluginMetadata() + +Checks if plugin metadata handling should be enabled. + +```typescript +function shouldInjectPluginMetadata(): boolean +``` + +**Returns:** `true` if metadata handling is enabled, `false` otherwise. + +**Behavior:** +- Returns `false` if `RHDH_SKIP_PLUGIN_METADATA_INJECTION` environment variable is set +- Returns `false` if `JOB_NAME` contains `periodic-` (nightly/periodic builds) +- Returns `true` otherwise (default for local dev and PR builds) + +**Example:** + +```typescript +if (shouldInjectPluginMetadata()) { + // Load and inject metadata +} +``` + +--- + +### extractPluginName() + +Extracts the plugin name from a package path or OCI reference. + +```typescript +function extractPluginName(packageRef: string): string +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `packageRef` | `string` | The package reference string | + +**Returns:** The extracted plugin name. + +**Supported Formats:** + +| Format | Example | Extracted Name | +|--------|---------|----------------| +| Wrapper path | `./dynamic-plugins/dist/my-plugin` | `my-plugin` | +| OCI with tag | `oci://quay.io/rhdh/my-plugin:1.0.0` | `my-plugin` | +| OCI with digest | `oci://quay.io/rhdh/my-plugin@sha256:abc...` | `my-plugin` | +| OCI with alias | `oci://quay.io/rhdh/my-plugin@sha256:abc!alias` | `my-plugin` | +| GHCR | `ghcr.io/org/repo/my-plugin:tag` | `my-plugin` | + +**Example:** + +```typescript +const name = extractPluginName("oci://quay.io/rhdh/backstage-community-plugin-tech-radar:1.0.0"); +// Returns: "backstage-community-plugin-tech-radar" +``` + +--- + +### getMetadataDirectory() + +Gets the metadata directory path. + +```typescript +function getMetadataDirectory(metadataPath?: string): string | null +``` + +**Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `metadataPath` | `string` | `"../metadata"` | Path to metadata directory | + +**Returns:** The resolved metadata directory path, or `null` if it doesn't exist. + +**Example:** + +```typescript +const metadataDir = getMetadataDirectory(); +if (metadataDir) { + console.log(`Found metadata at: ${metadataDir}`); +} +``` + +--- + +### parseAllMetadataFiles() + +Parses all metadata files in a directory and builds a map of plugin name to config. + +```typescript +async function parseAllMetadataFiles( + metadataDir: string +): Promise> +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `metadataDir` | `string` | Path to the metadata directory | + +**Returns:** Map of plugin name to [`PluginMetadata`](#pluginmetadata). + +**Example:** + +```typescript +const metadataDir = getMetadataDirectory(); +if (metadataDir) { + const metadataMap = await parseAllMetadataFiles(metadataDir); + console.log(`Found ${metadataMap.size} plugins`); +} +``` + +--- + +### injectMetadataConfig() + +Injects plugin configurations from metadata into a dynamic plugins config. + +```typescript +function injectMetadataConfig( + dynamicPluginsConfig: DynamicPluginsConfig, + metadataMap: Map +): DynamicPluginsConfig +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `dynamicPluginsConfig` | [`DynamicPluginsConfig`](#dynamicpluginsconfig) | The config to augment | +| `metadataMap` | `Map` | Map of plugin metadata | + +**Returns:** Augmented configuration with injected pluginConfigs. + +**Merge Behavior:** Metadata config serves as the base, user-provided pluginConfig overrides it. + +--- + +### generateDynamicPluginsConfigFromMetadata() + +Generates a complete dynamic-plugins configuration from metadata files. + +```typescript +async function generateDynamicPluginsConfigFromMetadata( + metadataPath?: string +): Promise +``` + +**Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `metadataPath` | `string` | `"../metadata"` | Path to metadata directory | + +**Returns:** Complete dynamic plugins configuration with all plugins enabled. + +**Behavior:** +- Returns `{ plugins: [] }` if [`shouldInjectPluginMetadata()`](#shouldinjectpluginmetadata) returns `false` +- Throws error if metadata directory not found +- Throws error if no valid metadata files found +- All generated plugins have `disabled: false` + +**Example:** + +```typescript +const config = await generateDynamicPluginsConfigFromMetadata(); +console.log(`Generated config with ${config.plugins?.length} plugins`); +``` + +--- + +### loadAndInjectPluginMetadata() + +Main function to load and inject plugin metadata for PR builds. + +```typescript +async function loadAndInjectPluginMetadata( + dynamicPluginsConfig: DynamicPluginsConfig, + metadataPath?: string +): Promise +``` + +**Parameters:** +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `dynamicPluginsConfig` | [`DynamicPluginsConfig`](#dynamicpluginsconfig) | - | The config to augment | +| `metadataPath` | `string` | `"../metadata"` | Path to metadata directory | + +**Returns:** Augmented configuration with metadata (for PR) or unchanged (for nightly). + +**Behavior:** +- Returns config unchanged if [`shouldInjectPluginMetadata()`](#shouldinjectpluginmetadata) returns `false` +- Throws error if metadata directory not found +- Throws error if no valid metadata files found +- Only injects metadata for plugins already in the config + +**Example:** + +```typescript +const config = { plugins: [{ package: "./dynamic-plugins/dist/my-plugin", disabled: false }] }; +const augmented = await loadAndInjectPluginMetadata(config); +``` + +## Types + +### PluginMetadata + +```typescript +interface PluginMetadata { + /** The dynamic artifact path (e.g., ./dynamic-plugins/dist/plugin-name) */ + packagePath: string; + /** The plugin configuration from appConfigExamples[0].content */ + pluginConfig: Record; + /** The package name (e.g., @backstage-community/plugin-tech-radar) */ + packageName: string; + /** Source metadata file path */ + sourceFile: string; +} +``` + +### PluginEntry + +```typescript +interface PluginEntry { + package: string; + disabled?: boolean; + pluginConfig?: Record; + [key: string]: unknown; +} +``` + +### DynamicPluginsConfig + +```typescript +interface DynamicPluginsConfig { + plugins?: PluginEntry[]; + includes?: string[]; + [key: string]: unknown; +} +``` + +## Constants + +### DEFAULT_METADATA_PATH + +```typescript +const DEFAULT_METADATA_PATH = "../metadata"; +``` + +Default metadata directory path relative to the e2e-tests directory. + +## See Also + +- [Configuration Files](/guide/configuration/config-files#plugin-metadata-injection) - How metadata injection works during deployment +- [Environment Variables](/guide/configuration/environment-variables#plugin-metadata-variables) - Variables that control metadata handling diff --git a/docs/guide/configuration/config-files.md b/docs/guide/configuration/config-files.md index 9f32678..b20295f 100644 --- a/docs/guide/configuration/config-files.md +++ b/docs/guide/configuration/config-files.md @@ -137,6 +137,87 @@ When you deploy RHDH, configurations are merged: Later files override earlier files. You only need to specify what's different from defaults. +## Plugin Metadata Injection + +During deployment, the package automatically handles plugin configurations from metadata files. The behavior depends on whether your [`dynamic-plugins.yaml`](#dynamic-plugins-yaml) file exists: + +``` +┌─────────────────────────────────────┐ +│ dynamic-plugins.yaml exists? │ +└──────────────┬──────────────────────┘ + │ + ┌───────┴───────┐ + │ │ + YES NO + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────────────┐ +│ Inject into │ │ Auto-generate from │ +│ existing │ │ ALL metadata files │ +│ plugins only │ │ (all enabled) │ +└──────────────┘ └──────────────────────┘ +``` + +``` +workspaces// +├── metadata/ # Plugin metadata files +│ ├── plugin-frontend.yaml # Contains spec.appConfigExamples +│ └── plugin-backend.yaml +├── e2e-tests/ +│ └── tests/config/ +│ └── dynamic-plugins.yaml # Your plugin config (optional) +└── plugins/ # Plugin source code +``` + +### Auto-Generation (No Config File) + +If your `dynamic-plugins.yaml` file doesn't exist, the package **auto-generates** a complete configuration: + +1. Iterates through all metadata files in `../metadata/` +2. Creates plugin entries with `disabled: false` (enabled) +3. Uses `spec.appConfigExamples[0].content` as the plugin config + +This is useful when you want to test all plugins with their default configurations without writing a `dynamic-plugins.yaml`. + +### Injection (Config File Exists) + +If your `dynamic-plugins.yaml` file exists, the package **injects** metadata only for plugins listed in your config: + +1. Looks for matching metadata files in `../metadata/` +2. Merges: **metadata (base) + your config (override)** +3. Plugins not in your config are **not** added automatically + +Your `pluginConfig` in `dynamic-plugins.yaml` overrides values from metadata. + +### When Injection is Enabled + +Plugin metadata injection is **enabled by default** for: +- Local development +- PR builds in CI + +Injection is **disabled** when: +- [`RHDH_SKIP_PLUGIN_METADATA_INJECTION`](/guide/configuration/environment-variables#plugin-metadata-variables) environment variable is set +- `JOB_NAME` contains `periodic-` (nightly/periodic CI builds) + +::: warning +When injection is enabled, deployment will fail if: +- The `metadata/` directory doesn't exist +- No valid metadata files are found in the directory +::: + +### Package Reference Matching + +The package automatically matches plugins across different reference formats: + +| Format | Example | +|--------|---------| +| Wrapper path | `./dynamic-plugins/dist/my-plugin` | +| OCI with tag | `oci://quay.io/rhdh/my-plugin:1.0.0` | +| OCI with digest | `oci://quay.io/rhdh/my-plugin@sha256:abc...` | +| GHCR | `ghcr.io/org/repo/my-plugin:tag` | + +All formats extract the plugin name (`my-plugin`) for matching against metadata. + ## Environment Variable Substitution Use these syntaxes in YAML files: diff --git a/docs/guide/configuration/environment-variables.md b/docs/guide/configuration/environment-variables.md index 17c488a..858945a 100644 --- a/docs/guide/configuration/environment-variables.md +++ b/docs/guide/configuration/environment-variables.md @@ -25,6 +25,18 @@ These are set automatically during deployment: | `CI` | Enables auto-cleanup | - | | `CHART_URL` | Custom Helm chart URL | `oci://quay.io/rhdh/chart` | | `SKIP_KEYCLOAK_DEPLOYMENT` | Skip Keycloak auto-deploy | `false` | +| `RHDH_SKIP_PLUGIN_METADATA_INJECTION` | Disable plugin metadata injection | - | + +## Plugin Metadata Variables + +These control automatic plugin configuration injection from metadata files: + +| Variable | Description | Effect | +|----------|-------------|--------| +| `RHDH_SKIP_PLUGIN_METADATA_INJECTION` | When set (any value), disables metadata injection | Opt-out | +| `JOB_NAME` | CI job name (set by OpenShift CI/Prow) | If contains `periodic-`, injection is disabled | + +See [Plugin Metadata Injection](/guide/configuration/config-files#plugin-metadata-injection) for details. ## Keycloak Variables diff --git a/docs/guide/configuration/index.md b/docs/guide/configuration/index.md index 2479c82..a4d853b 100644 --- a/docs/guide/configuration/index.md +++ b/docs/guide/configuration/index.md @@ -67,3 +67,7 @@ RHDH configurations are merged in layers: 3. **Project configs** - Your custom configurations This allows you to override only what you need while using sensible defaults. + +## Plugin Metadata Injection + +For PR builds, the package can automatically inject plugin configurations from metadata files. See [Plugin Metadata Injection](/guide/configuration/config-files#plugin-metadata-injection) for details. diff --git a/docs/guide/deployment/index.md b/docs/guide/deployment/index.md index 563d272..4c57940 100644 --- a/docs/guide/deployment/index.md +++ b/docs/guide/deployment/index.md @@ -113,10 +113,11 @@ await rhdh.deploy(); This: 1. Creates the namespace -2. Applies ConfigMaps -3. Applies Secrets (with env substitution) -4. Installs RHDH (Helm or Operator) -5. Waits for deployment to be ready +2. [Injects plugin metadata](/guide/configuration/config-files#plugin-metadata-injection) into dynamic plugins +3. Applies ConfigMaps +4. Applies Secrets (with env substitution) +5. Installs RHDH (Helm or Operator) +6. Waits for deployment to be ready ### 3. Access diff --git a/docs/guide/deployment/rhdh-deployment.md b/docs/guide/deployment/rhdh-deployment.md index 1cb1606..61a5b2c 100644 --- a/docs/guide/deployment/rhdh-deployment.md +++ b/docs/guide/deployment/rhdh-deployment.md @@ -95,11 +95,12 @@ await deployment.deploy(); This method: 1. Merges configuration files (common → auth → project) -2. Applies ConfigMaps (app-config, dynamic-plugins) -3. Applies Secrets (with environment variable substitution) -4. Installs RHDH via Helm or Operator -5. Waits for the deployment to be ready -6. Sets `RHDH_BASE_URL` environment variable +2. [Injects plugin metadata](/guide/configuration/config-files#plugin-metadata-injection) into dynamic plugins config +3. Applies ConfigMaps (app-config, dynamic-plugins) +4. Applies Secrets (with environment variable substitution) +5. Installs RHDH via Helm or Operator +6. Waits for the deployment to be ready +7. Sets `RHDH_BASE_URL` environment variable ### `waitUntilReady(timeout?)` diff --git a/docs/guide/index.md b/docs/guide/index.md index ebbaca4..9e001bc 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -22,7 +22,7 @@ The package simplifies end-to-end testing for RHDH plugins by providing: | Deploy Keycloak | For authentication testing with automatic realm, client, and user configuration | | Modular authentication | Configuration for guest and Keycloak providers | | Automatic namespace | Creation and cleanup | -| Dynamic plugin | Configuration support | +| Dynamic plugin | Configuration with [automatic metadata injection](/guide/configuration/config-files#plugin-metadata-injection) | | UI, API, and common helpers | For test interactions | | Kubernetes client helper | For OpenShift resources | | Pre-configured Playwright | Settings optimized for RHDH testing | diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index c633397..47fb066 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -75,6 +75,10 @@ stringData: GITHUB_TOKEN: $GITHUB_TOKEN ``` +::: tip Skip dynamic-plugins.yaml +If your workspace has a `metadata/` directory with Package CRD files, you can skip creating `dynamic-plugins.yaml`. The package will automatically generate configuration from metadata files. See [Plugin Metadata Injection](/guide/configuration/config-files#plugin-metadata-injection). +::: + ## Step 4: Create Environment File Create `.env`: diff --git a/docs/guide/utilities/index.md b/docs/guide/utilities/index.md index f7a2ea5..3ccb496 100644 --- a/docs/guide/utilities/index.md +++ b/docs/guide/utilities/index.md @@ -10,6 +10,7 @@ The package provides utility functions for common operations in E2E testing. | [Bash ($)](/guide/utilities/bash-utilities) | Shell command execution | | [YAML Merging](/guide/utilities/yaml-merging) | Merge YAML files | | [envsubst](/guide/utilities/environment-substitution) | Environment variable substitution | +| [Plugin Metadata](/guide/utilities/plugin-metadata) | Plugin metadata injection | ## Importing Utilities diff --git a/docs/guide/utilities/plugin-metadata.md b/docs/guide/utilities/plugin-metadata.md new file mode 100644 index 0000000..cbaa652 --- /dev/null +++ b/docs/guide/utilities/plugin-metadata.md @@ -0,0 +1,141 @@ +# Plugin Metadata + +The plugin metadata utilities handle loading and injecting plugin configurations from Package CRD metadata files. This enables automatic configuration of dynamic plugins during deployment. + +## How It Works + +Plugin metadata is stored in `metadata/*.yaml` files alongside your plugin source code. These files follow the Package CRD format and contain `spec.appConfigExamples` with the plugin's default configuration. + +``` +workspaces// +├── metadata/ # Plugin metadata files +│ ├── plugin-frontend.yaml # Frontend plugin metadata +│ └── plugin-backend.yaml # Backend plugin metadata +├── e2e-tests/ # Your test project +│ └── tests/config/ +│ └── dynamic-plugins.yaml # Your plugin config (optional) +└── plugins/ # Plugin source code +``` + +During deployment, the package reads these metadata files and: +- **Auto-generates** a complete config if `dynamic-plugins.yaml` doesn't exist +- **Injects** metadata into existing plugins if `dynamic-plugins.yaml` exists + +## When Metadata Handling is Enabled + +Metadata handling is **enabled by default** for: +- Local development +- PR builds in CI + +Metadata handling is **disabled** when: +- `RHDH_SKIP_PLUGIN_METADATA_INJECTION` is set +- `JOB_NAME` contains `periodic-` (nightly builds) + +## Basic Usage + +### Check If Enabled + +```typescript +import { shouldInjectPluginMetadata } from "rhdh-e2e-test-utils/utils"; + +if (shouldInjectPluginMetadata()) { + console.log("Metadata handling is enabled"); +} +``` + +### Auto-Generate Configuration + +When your `dynamic-plugins.yaml` doesn't exist, generate a complete config from all metadata files: + +```typescript +import { generateDynamicPluginsConfigFromMetadata } from "rhdh-e2e-test-utils/utils"; + +const config = await generateDynamicPluginsConfigFromMetadata(); +// All plugins from metadata/*.yaml are enabled by default +``` + +### Inject Into Existing Configuration + +When you have a `dynamic-plugins.yaml`, inject metadata for listed plugins: + +```typescript +import { loadAndInjectPluginMetadata } from "rhdh-e2e-test-utils/utils"; + +const existingConfig = { + plugins: [ + { + package: "./dynamic-plugins/dist/my-plugin", + disabled: false, + pluginConfig: { + // Your overrides here + }, + }, + ], +}; + +const augmented = await loadAndInjectPluginMetadata(existingConfig); +// Metadata is merged as base, your pluginConfig overrides it +``` + +## Extract Plugin Name + +The utilities support various package reference formats: + +```typescript +import { extractPluginName } from "rhdh-e2e-test-utils/utils"; + +// All of these extract "my-plugin" +extractPluginName("./dynamic-plugins/dist/my-plugin"); +extractPluginName("oci://quay.io/rhdh/my-plugin:1.0.0"); +extractPluginName("oci://quay.io/rhdh/my-plugin@sha256:abc123"); +extractPluginName("ghcr.io/org/repo/my-plugin:tag"); +``` + +## Parse Metadata Files + +For custom handling, you can parse metadata files directly: + +```typescript +import { + getMetadataDirectory, + parseAllMetadataFiles, +} from "rhdh-e2e-test-utils/utils"; + +const metadataDir = getMetadataDirectory(); +if (metadataDir) { + const metadataMap = await parseAllMetadataFiles(metadataDir); + + for (const [pluginName, metadata] of metadataMap) { + console.log(`Plugin: ${pluginName}`); + console.log(` Package: ${metadata.packagePath}`); + console.log(` Config:`, metadata.pluginConfig); + } +} +``` + +## Deployment Integration + +The `RHDHDeployment` class automatically uses these utilities during `deploy()`: + +1. If `dynamic-plugins.yaml` exists: + - Merges package defaults + auth config + your config + - Injects metadata for plugins in your config + +2. If `dynamic-plugins.yaml` doesn't exist: + - Auto-generates from all metadata files + - All plugins enabled with default configurations + +See [Configuration Files](/guide/configuration/config-files#plugin-metadata-injection) for detailed behavior. + +## Environment Variables + +| Variable | Effect | +|----------|--------| +| `RHDH_SKIP_PLUGIN_METADATA_INJECTION` | Disables all metadata handling | +| `JOB_NAME` | If contains `periodic-`, disables metadata handling | + +See [Environment Variables](/guide/configuration/environment-variables#plugin-metadata-variables) for details. + +## API Reference + +For complete API documentation, see [Plugin Metadata API](/api/utils/plugin-metadata). diff --git a/docs/tutorials/plugin-testing.md b/docs/tutorials/plugin-testing.md index 75aede0..da8b2fa 100644 --- a/docs/tutorials/plugin-testing.md +++ b/docs/tutorials/plugin-testing.md @@ -83,6 +83,10 @@ myPlugin: enabled: true ``` +::: tip Automatic Plugin Configuration +If your workspace has a `metadata/` directory with Package CRD files, you can skip creating `dynamic-plugins.yaml`. The package will automatically generate configuration from metadata files during PR builds. See [Plugin Metadata Injection](/guide/configuration/config-files#plugin-metadata-injection) for details. +::: + ## Step 4: Write Tests **tests/specs/my-plugin.spec.ts:** diff --git a/src/deployment/rhdh/deployment.ts b/src/deployment/rhdh/deployment.ts index 207a496..68638af 100644 --- a/src/deployment/rhdh/deployment.ts +++ b/src/deployment/rhdh/deployment.ts @@ -2,7 +2,11 @@ import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; import { $ } from "../../utils/bash.js"; import yaml from "js-yaml"; import { test } from "@playwright/test"; -import { mergeYamlFilesIfExists } from "../../utils/merge-yamls.js"; +import { mergeYamlFilesIfExists, deepMerge } from "../../utils/merge-yamls.js"; +import { + loadAndInjectPluginMetadata, + generateDynamicPluginsConfigFromMetadata, +} from "../../utils/plugin-metadata.js"; import { envsubst } from "../../utils/common.js"; import fs from "fs-extra"; import boxen from "boxen"; @@ -85,16 +89,52 @@ export class RHDHDeployment { ); } - private async _applyDynamicPlugins(): Promise { + /** + * Builds the merged dynamic plugins configuration. + * Merges: package defaults + auth config + user config + metadata (for PR builds). + */ + private async _buildDynamicPluginsConfig(): Promise> { + const userConfigPath = this.deploymentConfig.dynamicPlugins; + const userConfigExists = userConfigPath && fs.existsSync(userConfigPath); const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; - const dynamicPluginsYaml = await mergeYamlFilesIfExists( + + // If user's dynamic-plugins config doesn't exist, auto-generate from metadata + if (!userConfigExists) { + this._log( + `Dynamic plugins config not found at '${userConfigPath}', auto-generating from metadata...`, + ); + const metadataConfig = await generateDynamicPluginsConfigFromMetadata(); + + // Merge with package defaults and auth config + const authPlugins = await mergeYamlFilesIfExists( + [DEFAULT_CONFIG_PATHS.dynamicPlugins, authConfig.dynamicPlugins], + { arrayMergeStrategy: { byKey: "package" } }, + ); + return deepMerge(metadataConfig, authPlugins, { + arrayMergeStrategy: { byKey: "package" }, + }); + } + + // User config exists - merge provided configs and inject metadata for listed plugins only + let dynamicPluginsConfig = await mergeYamlFilesIfExists( [ DEFAULT_CONFIG_PATHS.dynamicPlugins, authConfig.dynamicPlugins, - this.deploymentConfig.dynamicPlugins, + userConfigPath, ], { arrayMergeStrategy: { byKey: "package" } }, ); + + // Inject plugin metadata configuration for plugins in the config + dynamicPluginsConfig = + await loadAndInjectPluginMetadata(dynamicPluginsConfig); + + return dynamicPluginsConfig; + } + + private async _applyDynamicPlugins(): Promise { + const dynamicPluginsYaml = await this._buildDynamicPluginsConfig(); + this._logBoxen("Dynamic Plugins", dynamicPluginsYaml); await this.k8sClient.applyConfigMapFromObject( "dynamic-plugins", @@ -116,18 +156,10 @@ export class RHDHDeployment { this._logBoxen("Value File", valueFileObject); // Merge dynamic plugins into the values file (including auth-specific plugins) - const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; if (!valueFileObject.global) { valueFileObject.global = {}; } - valueFileObject.global.dynamic = await mergeYamlFilesIfExists( - [ - DEFAULT_CONFIG_PATHS.dynamicPlugins, - authConfig.dynamicPlugins, - this.deploymentConfig.dynamicPlugins, - ], - { arrayMergeStrategy: { byKey: "package" } }, - ); + valueFileObject.global.dynamic = await this._buildDynamicPluginsConfig(); this._logBoxen("Dynamic Plugins", valueFileObject.global.dynamic); @@ -319,6 +351,22 @@ export class RHDHDeployment { } private _logBoxen(title: string, data: unknown): void { - console.log(boxen(yaml.dump(data), { title, padding: 1 })); + console.log( + boxen(yaml.dump(data, { lineWidth: -1 }), { + title, + padding: 0, + width: 120, + borderStyle: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + top: "─", + bottom: "─", + left: "", + right: "", + }, + }), + ); } } diff --git a/src/utils/merge-yamls.ts b/src/utils/merge-yamls.ts index 22352f7..322f345 100644 --- a/src/utils/merge-yamls.ts +++ b/src/utils/merge-yamls.ts @@ -69,7 +69,7 @@ function mergeArraysByKey( * Deeply merges two YAML-compatible objects. * Array handling is controlled by the arrayMergeStrategy option. */ -function deepMerge( +export function deepMerge( target: Record, source: Record, options: MergeOptions = {}, diff --git a/src/utils/plugin-metadata.ts b/src/utils/plugin-metadata.ts new file mode 100644 index 0000000..cd9dfec --- /dev/null +++ b/src/utils/plugin-metadata.ts @@ -0,0 +1,376 @@ +import fs from "fs-extra"; +import path from "path"; +import yaml from "js-yaml"; +import { glob } from "zx"; +import { deepMerge } from "./merge-yamls.js"; + +/** + * Represents parsed plugin metadata from a Package CRD file. + */ +export interface PluginMetadata { + /** The dynamic artifact path (e.g., ./dynamic-plugins/dist/plugin-name) */ + packagePath: string; + /** The plugin configuration from appConfigExamples[0].content */ + pluginConfig: Record; + /** The package name (e.g., @backstage-community/plugin-tech-radar) */ + packageName: string; + /** Source metadata file path */ + sourceFile: string; +} + +/** + * Structure of a Package CRD metadata file. + */ +interface PackageCRD { + spec?: { + packageName?: string; + dynamicArtifact?: string; + appConfigExamples?: Array<{ + title?: string; + content?: Record; + }>; + }; +} + +/** + * Checks if plugin metadata handling should be enabled. + * This controls both auto-generation and injection of plugin metadata. + * + * Default: ENABLED (for local dev and PR builds) + * Disabled when: + * - RHDH_SKIP_PLUGIN_METADATA_INJECTION is set, OR + * - JOB_NAME contains "periodic-" (nightly/periodic builds) + */ +export function shouldInjectPluginMetadata(): boolean { + // Explicit opt-out + if (process.env.RHDH_SKIP_PLUGIN_METADATA_INJECTION) { + console.log( + "[PluginMetadata] Metadata handling disabled (RHDH_SKIP_PLUGIN_METADATA_INJECTION is set)", + ); + return false; + } + + // Periodic/nightly job + const jobName = process.env.JOB_NAME || ""; + if (jobName.includes("periodic-")) { + console.log( + "[PluginMetadata] Metadata handling disabled (periodic job detected)", + ); + return false; + } + + return true; +} + +/** + * Extracts the plugin name from a package path or OCI reference. + * + * Handles various formats: + * - Local path: ./dynamic-plugins/dist/backstage-community-plugin-tech-radar + * - OCI with integrity: oci://quay.io/rhdh/plugin@sha256:...!backstage-community-plugin-tech-radar + * - OCI without integrity: oci://quay.io/rhdh/backstage-community-plugin-tech-radar:tag + * + * @param packageRef The package reference string + * @returns The extracted plugin name + */ +export function extractPluginName(packageRef: string): string { + // Strip ! suffix if present (e.g., oci://...@sha256:...!alias) + const ref = packageRef.includes("!") ? packageRef.split("!")[0] : packageRef; + + // Regex to extract plugin name from various formats: + // Captures the last path segment (chars except / : @) before any :tag or @digest + const match = ref.match(/\/([^/:@]+)(?:[:@].*)?$/); + return match?.[1] || packageRef; +} + +/** + * Default metadata directory path relative to the e2e-tests directory. + * Follows the same pattern as user config paths (e.g., tests/config/dynamic-plugins.yaml). + */ +export const DEFAULT_METADATA_PATH = "../metadata"; + +/** + * Gets the metadata directory path. + * Uses the provided path or falls back to DEFAULT_METADATA_PATH. + * + * @param metadataPath Optional custom path to metadata directory + * @returns The metadata directory path, or null if it doesn't exist + */ +export function getMetadataDirectory( + metadataPath: string = DEFAULT_METADATA_PATH, +): string | null { + const resolvedPath = path.resolve(metadataPath); + + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + console.log(`[PluginMetadata] Using metadata directory: ${resolvedPath}`); + return resolvedPath; + } + + console.log(`[PluginMetadata] Metadata directory not found: ${resolvedPath}`); + return null; +} + +/** + * Parses a single metadata YAML file and extracts plugin configuration. + * + * @param filePath Path to the metadata YAML file + * @returns PluginMetadata if valid, null otherwise + */ +export async function parseMetadataFile( + filePath: string, +): Promise { + try { + const content = await fs.readFile(filePath, "utf8"); + const parsed = yaml.load(content) as PackageCRD; + + const packagePath = parsed?.spec?.dynamicArtifact; + const packageName = parsed?.spec?.packageName; + const pluginConfig = parsed?.spec?.appConfigExamples?.[0]?.content; + + if (!packagePath) { + console.log( + `[PluginMetadata] Skipping ${filePath}: no spec.dynamicArtifact`, + ); + return null; + } + + if (!pluginConfig) { + console.log( + `[PluginMetadata] Skipping ${filePath}: no spec.appConfigExamples[0].content`, + ); + return null; + } + + console.log(`[PluginMetadata] Loaded metadata for: ${packagePath}`); + + return { + packagePath, + pluginConfig, + packageName: packageName || "", + sourceFile: filePath, + }; + } catch (error) { + console.error(`[PluginMetadata] Error parsing ${filePath}:`, error); + return null; + } +} + +/** + * Parses all metadata files in a directory and builds a map of plugin name to config. + * The plugin name is extracted from the dynamicArtifact path for flexible matching. + * + * @param metadataDir Path to the metadata directory + * @returns Map of plugin name to plugin configuration + */ +export async function parseAllMetadataFiles( + metadataDir: string, +): Promise> { + const pattern = path.join(metadataDir, "*.yaml"); + const files = await glob(pattern); + + console.log( + `[PluginMetadata] Found ${files.length} metadata files in ${metadataDir}`, + ); + + const metadataMap = new Map(); + + for (const file of files) { + const metadata = await parseMetadataFile(file); + if (metadata) { + // Use extracted plugin name as key for flexible matching + const pluginName = extractPluginName(metadata.packagePath); + metadataMap.set(pluginName, metadata); + console.log( + `[PluginMetadata] Mapped plugin: ${pluginName} <- ${metadata.packagePath}`, + ); + } + } + + console.log( + `[PluginMetadata] Successfully parsed ${metadataMap.size} plugin metadata entries`, + ); + + return metadataMap; +} + +/** + * Plugin entry in dynamic-plugins.yaml + */ +export interface PluginEntry { + package: string; + disabled?: boolean; + pluginConfig?: Record; + [key: string]: unknown; +} + +/** + * Dynamic plugins configuration structure + */ +export interface DynamicPluginsConfig { + plugins?: PluginEntry[]; + includes?: string[]; + [key: string]: unknown; +} + +/** + * Injects plugin configurations from metadata into a dynamic plugins config. + * Metadata config serves as the base, user-provided pluginConfig overrides it. + * + * Matching is done by extracting the plugin name from both the package reference + * and the metadata's dynamicArtifact, allowing flexible matching across different + * package formats (local paths, OCI references, etc.). + * + * @param dynamicPluginsConfig The dynamic plugins configuration to augment + * @param metadataMap Map of plugin names to plugin metadata + * @returns The augmented configuration with injected pluginConfigs + */ +export function injectMetadataConfig( + dynamicPluginsConfig: DynamicPluginsConfig, + metadataMap: Map, +): DynamicPluginsConfig { + if (!dynamicPluginsConfig.plugins) { + return dynamicPluginsConfig; + } + + const augmentedPlugins = dynamicPluginsConfig.plugins.map((plugin) => { + // Extract plugin name from package reference for flexible matching + const pluginName = extractPluginName(plugin.package); + const metadata = metadataMap.get(pluginName); + + if (!metadata) { + // No metadata found for this plugin, keep as-is + console.log( + `[PluginMetadata] No metadata found for: ${pluginName} (from ${plugin.package})`, + ); + return plugin; + } + + console.log( + `[PluginMetadata] Injecting config for: ${pluginName} (from ${plugin.package})`, + ); + + // Merge: metadata config (base) + user config (override) + const mergedPluginConfig = deepMerge( + metadata.pluginConfig, + plugin.pluginConfig || {}, + ); + + return { + ...plugin, + pluginConfig: mergedPluginConfig, + }; + }); + + return { + ...dynamicPluginsConfig, + plugins: augmentedPlugins, + }; +} + +/** + * Generates a complete dynamic-plugins configuration from metadata files. + * Iterates through all metadata files and creates plugin entries with: + * - package: the dynamicArtifact path + * - disabled: false (enabled by default) + * - pluginConfig: from appConfigExamples[0].content + * + * @param metadataPath Optional custom path to metadata directory (default: ../metadata) + * @returns Complete dynamic plugins configuration + * @throws Error if metadata directory not found or no valid metadata files + */ +export async function generateDynamicPluginsConfigFromMetadata( + metadataPath: string = DEFAULT_METADATA_PATH, +): Promise { + // Skip if metadata handling is disabled + if (!shouldInjectPluginMetadata()) { + console.log( + "[PluginMetadata] Returning empty config (metadata handling disabled)", + ); + return { plugins: [] }; + } + + console.log( + "[PluginMetadata] No dynamic-plugins config provided, generating from metadata...", + ); + + // Get metadata directory + const metadataDir = getMetadataDirectory(metadataPath); + + if (!metadataDir) { + throw new Error( + `[PluginMetadata] Cannot generate dynamic-plugins config: metadata directory not found at: ${path.resolve(metadataPath)}`, + ); + } + + // Parse all metadata files + const metadataMap = await parseAllMetadataFiles(metadataDir); + + if (metadataMap.size === 0) { + throw new Error( + `[PluginMetadata] Cannot generate dynamic-plugins config: no valid metadata files found in ${metadataDir}`, + ); + } + + // Build plugin entries from metadata + const plugins: PluginEntry[] = []; + + for (const [pluginName, metadata] of metadataMap) { + console.log( + `[PluginMetadata] Adding plugin from metadata: ${pluginName} (${metadata.packagePath})`, + ); + + plugins.push({ + package: metadata.packagePath, + disabled: false, + pluginConfig: metadata.pluginConfig, + }); + } + + console.log( + `[PluginMetadata] Generated dynamic-plugins config with ${plugins.length} plugins`, + ); + + return { plugins }; +} + +/** + * Main function to load and inject plugin metadata for PR builds. + * For non-PR builds (nightly), returns the config unchanged. + * + * @param dynamicPluginsConfig The dynamic plugins configuration + * @param metadataPath Optional custom path to metadata directory (default: ../metadata) + * @returns Augmented configuration with metadata (for PR) or unchanged (for nightly) + * @throws Error if PR build but no metadata directory found + */ +export async function loadAndInjectPluginMetadata( + dynamicPluginsConfig: DynamicPluginsConfig, + metadataPath: string = DEFAULT_METADATA_PATH, +): Promise { + // Skip metadata injection if disabled + if (!shouldInjectPluginMetadata()) { + return dynamicPluginsConfig; + } + + console.log("[PluginMetadata] Loading plugin metadata..."); + + // Get metadata directory + const metadataDir = getMetadataDirectory(metadataPath); + + if (!metadataDir) { + throw new Error( + `[PluginMetadata] PR build requires metadata directory but not found at: ${path.resolve(metadataPath)}`, + ); + } + + // Parse all metadata files + const metadataMap = await parseAllMetadataFiles(metadataDir); + + if (metadataMap.size === 0) { + throw new Error( + `[PluginMetadata] PR build requires plugin metadata but no valid metadata files found in ${metadataDir}`, + ); + } + + // Inject metadata configs into the dynamic plugins config + return injectMetadataConfig(dynamicPluginsConfig, metadataMap); +} From a42f82f4a11928086a72263721016282e3500e02 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Tue, 27 Jan 2026 12:57:14 +0530 Subject: [PATCH 2/2] feat: add plugin metadata utility for dynamic plugins configuration --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2d6f4c3..16bfb30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rhdh-e2e-test-utils", - "version": "1.1.3", + "version": "1.1.4", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "type": "module",