Skip to content

Commit d40c0b2

Browse files
authored
fix(deployment): de-duplicate plugin config (#51)
Merge metadata + auth config by normalized plugin name so the same plugin (OCI vs local path) is not listed twice. Add unit tests, docs, and ESLint override for node:test describe/it. Assisted-By: Cursor Desktop rh-pre-commit.version: 2.3.2 rh-pre-commit.check-secrets: ENABLED
1 parent e2c305f commit d40c0b2

File tree

14 files changed

+326
-30
lines changed

14 files changed

+326
-30
lines changed

.github/workflows/pr-build-and-check.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ jobs:
2929

3030
- name: Build
3131
run: yarn build
32+
33+
- name: Run Tests
34+
run: yarn test

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ Documentation is available online:
44

55
- Package documentation: https://redhat-developer.github.io/rhdh-e2e-test-utils/
66
- Overlay testing documentation: https://redhat-developer.github.io/rhdh-e2e-test-utils/overlay/
7+
8+
## Testing
9+
10+
Unit tests use Node’s built-in test runner (`node:test`) and are discovered by pattern.
11+
12+
- **Run tests:** `yarn test` (builds then runs all tests under `dist/` matching `**/*.test.js`).
13+
- **Add or change tests:** Add a `*.test.ts` file next to the code under `src/` (e.g. `src/utils/foo.test.ts`). It is compiled to `dist/` and picked up automatically; no need to update the test script.

docs/api/utils/plugin-metadata.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Utilities for loading and injecting plugin metadata from Package CRD files into
88
import {
99
shouldInjectPluginMetadata,
1010
extractPluginName,
11+
getNormalizedPluginMergeKey,
1112
getMetadataDirectory,
1213
parseAllMetadataFiles,
1314
injectMetadataConfig,
@@ -77,6 +78,38 @@ const name = extractPluginName("oci://quay.io/rhdh/backstage-community-plugin-te
7778

7879
---
7980

81+
### getNormalizedPluginMergeKey()
82+
83+
Returns a stable merge key for a plugin entry so that OCI and local path for the same logical plugin match when merging dynamic-plugins configs. Strips a trailing `-dynamic` suffix so that e.g. `backstage-community-plugin-catalog-backend-module-keycloak-dynamic` (local) and `backstage-community-plugin-catalog-backend-module-keycloak` (from OCI) map to the same key.
84+
85+
```typescript
86+
function getNormalizedPluginMergeKey(entry: { package?: string }): string
87+
```
88+
89+
**Parameters:**
90+
| Parameter | Type | Description |
91+
|-----------|------|-------------|
92+
| `entry` | `{ package?: string }` | Plugin entry with optional package reference |
93+
94+
**Returns:** Normalized key for merge deduplication, or empty string if `package` is missing.
95+
96+
**Example:**
97+
98+
```typescript
99+
// OCI and local path for the same plugin yield the same key
100+
getNormalizedPluginMergeKey({
101+
package: "oci://ghcr.io/org/repo/backstage-community-plugin-catalog-backend-module-keycloak:tag!alias",
102+
});
103+
// Returns: "backstage-community-plugin-catalog-backend-module-keycloak"
104+
105+
getNormalizedPluginMergeKey({
106+
package: "./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic",
107+
});
108+
// Returns: "backstage-community-plugin-catalog-backend-module-keycloak"
109+
```
110+
111+
---
112+
80113
### getMetadataDirectory()
81114

82115
Gets the metadata directory path.

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.1.16] - Current
6+
7+
### Fixed
8+
9+
- **Duplicate plugin when no user `dynamic-plugins.yaml` (Keycloak auth, PR build)**: When the workspace had no `dynamic-plugins.yaml`, auto-generated config (with OCI URL) was merged with auth config (with local path). Because merge used exact `package` string match, the same plugin appeared twice and the backend failed with `ExtensionPoint with ID 'keycloak.transformer' is already registered`. The merge now uses a normalized plugin key so OCI and local path for the same logical plugin are deduplicated; the metadata-derived entry (e.g. OCI URL) wins.
10+
511
## [1.1.15] - Current
612

713
### Added

docs/guide/configuration/config-files.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ If your `dynamic-plugins.yaml` file doesn't exist, the package **auto-generates*
193193
1. Iterates through all metadata files in `../metadata/`
194194
2. Creates plugin entries with `disabled: false` (enabled)
195195
3. Uses `spec.appConfigExamples[0].content` as the plugin config
196+
4. Merges the result with package defaults and auth-specific plugins (e.g. Keycloak)
197+
198+
Entries for the **same logical plugin** (e.g. keycloak from metadata and from auth) are **deduplicated**: the plugin appears once in the final config. When both metadata and auth list the same plugin, the metadata-derived entry wins (e.g. the OCI URL on PR builds is kept instead of the auth default local path).
196199

197200
This is useful when you want to test all plugins with their default configurations without writing a `dynamic-plugins.yaml`.
198201

docs/guide/utilities/plugin-metadata.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ The `RHDHDeployment` class automatically uses these utilities during `deploy()`:
123123

124124
2. If `dynamic-plugins.yaml` doesn't exist:
125125
- Auto-generates from all metadata files
126+
- Merges with package defaults and auth-specific plugins (e.g. Keycloak)
127+
- Deduplicates by normalized plugin name so the same logical plugin appears once (metadata/OCI wins)
126128
- All plugins enabled with default configurations
127129

128130
See [Configuration Files](/guide/configuration/config-files#plugin-metadata-injection) for detailed behavior.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@red-hat-developer-hub/e2e-test-utils",
3-
"version": "1.1.15",
3+
"version": "1.1.16",
44
"description": "Test utilities for RHDH E2E tests",
55
"license": "Apache-2.0",
66
"repository": {
@@ -66,6 +66,7 @@
6666
"prepublishOnly": "yarn build",
6767
"prettier:check": "prettier --check . '!dist' '!README.md' '!docs' '!.github/workflows/deploy-docs.yml'",
6868
"prettier:fix": "prettier --write . '!dist' '!README.md' '!docs' '!.github/workflows/deploy-docs.yml'",
69+
"test": "node --test \"dist/**/*.test.js\"",
6970
"typecheck": "tsc --noEmit"
7071
},
7172
"keywords": [
@@ -75,7 +76,7 @@
7576
"test-utils"
7677
],
7778
"engines": {
78-
"node": ">=22",
79+
"node": ">=22.18.0",
7980
"yarn": ">=3"
8081
},
8182
"peerDependencies": {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it } from "node:test";
2+
import assert from "node:assert";
3+
import { deepMerge } from "../../utils/merge-yamls.js";
4+
import { getNormalizedPluginMergeKey } from "../../utils/plugin-metadata.js";
5+
6+
/**
7+
* Tests the merge behavior used when user dynamic-plugins config does not exist:
8+
* auth config (e.g. keycloak) is merged with metadata config using normalized plugin key.
9+
* Result must have exactly one entry per logical plugin; metadata (source) wins so OCI URL is kept.
10+
*/
11+
describe("dynamic-plugins merge (no user config path)", () => {
12+
it("yields one keycloak plugin with OCI package when auth has local path and metadata has OCI", () => {
13+
const authPlugins: Record<string, unknown> = {
14+
plugins: [
15+
{
16+
package:
17+
"./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic",
18+
disabled: false,
19+
pluginConfig: {},
20+
},
21+
],
22+
includes: ["dynamic-plugins.default.yaml"],
23+
};
24+
const metadataConfig: Record<string, unknown> = {
25+
plugins: [
26+
{
27+
package:
28+
"oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-community-plugin-catalog-backend-module-keycloak:pr_1980__3.16.0!backstage-community-plugin-catalog-backend-module-keycloak",
29+
disabled: false,
30+
pluginConfig: { catalog: { providers: { keycloakOrg: {} } } },
31+
},
32+
],
33+
};
34+
const merged = deepMerge(authPlugins, metadataConfig, {
35+
arrayMergeStrategy: {
36+
byKey: "package",
37+
normalizeKey: (item) =>
38+
getNormalizedPluginMergeKey(item as Record<string, unknown>),
39+
},
40+
});
41+
const plugins = merged.plugins as Array<{ package?: string }>;
42+
assert.strictEqual(
43+
plugins.length,
44+
1,
45+
"merged config must have exactly one keycloak plugin",
46+
);
47+
assert.ok(
48+
plugins[0].package?.startsWith("oci://"),
49+
"metadata (OCI) must win over auth local path",
50+
);
51+
});
52+
});

src/deployment/rhdh/deployment.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mergeYamlFilesIfExists, deepMerge } from "../../utils/merge-yamls.js";
66
import {
77
loadAndInjectPluginMetadata,
88
generateDynamicPluginsConfigFromMetadata,
9+
getNormalizedPluginMergeKey,
910
} from "../../utils/plugin-metadata.js";
1011
import { envsubst } from "../../utils/common.js";
1112
import { runOnce } from "../../playwright/run-once.js";
@@ -128,13 +129,19 @@ export class RHDHDeployment {
128129
);
129130
const metadataConfig = await generateDynamicPluginsConfigFromMetadata();
130131

131-
// Merge with package defaults and auth config
132+
// Merge with package defaults and auth config. Use normalized plugin key so
133+
// the same logical plugin (e.g. keycloak from metadata OCI + auth local path)
134+
// is deduplicated; metadata (source) wins so OCI URL is kept on PR builds.
132135
const authPlugins = await mergeYamlFilesIfExists(
133136
[DEFAULT_CONFIG_PATHS.dynamicPlugins, authConfig.dynamicPlugins],
134137
{ arrayMergeStrategy: { byKey: "package" } },
135138
);
136-
return deepMerge(metadataConfig, authPlugins, {
137-
arrayMergeStrategy: { byKey: "package" },
139+
return deepMerge(authPlugins, metadataConfig, {
140+
arrayMergeStrategy: {
141+
byKey: "package",
142+
normalizeKey: (item) =>
143+
getNormalizedPluginMergeKey(item as Record<string, unknown>),
144+
},
138145
});
139146
}
140147

src/eslint/base.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,5 +214,12 @@ export function createEslintConfig(tsconfigRootDir: string): Linter.Config[] {
214214
"check-file/filename-naming-convention": "off",
215215
},
216216
},
217+
// Node test runner (*.test.ts) - describe/it return promises the runner handles
218+
{
219+
files: ["**/*.test.ts"],
220+
rules: {
221+
"@typescript-eslint/no-floating-promises": "off",
222+
},
223+
},
217224
] as Linter.Config[];
218225
}

0 commit comments

Comments
 (0)