Skip to content

feat(install-dynamic-plugins): port from Python to TypeScript/Node.js#4574

Open
gustavolira wants to merge 10 commits intoredhat-developer:mainfrom
gustavolira:feat/install-dynamic-plugins-ts
Open

feat(install-dynamic-plugins): port from Python to TypeScript/Node.js#4574
gustavolira wants to merge 10 commits intoredhat-developer:mainfrom
gustavolira:feat/install-dynamic-plugins-ts

Conversation

@gustavolira
Copy link
Copy Markdown
Member

Summary

Replaces the Python-based install-dynamic-plugins.py init-container script with a TypeScript/Node.js implementation, incorporating all improvements from the install-dynamic-plugins-fast.py POC (#4523): parallel OCI downloads, shared image cache, streaming SHA verification.

Motivation: The Python script was a longstanding duplication target for export overlays (see rhdh-plugin-export-overlays#2231). A Node.js implementation removes the Python dependency from the init-container runtime, enables reuse across RHDH core and overlays, and adopts the faster parallel architecture natively.

What changed

  • New package scripts/install-dynamic-plugins/ — 18 TypeScript modules + 9 Jest test files (105 tests)
  • Bundled output: single dist/install-dynamic-plugins.cjs (~412 KB), committed and verified fresh in CI (same pattern as .yarn/releases/yarn-*.cjs)
  • Containerfile now copies the .cjs bundle instead of .py; wrapper shell script execs node instead of python
  • CI workflow runs npm run tsc && npm test + bundle freshness check (replaces pytest)
  • Deleted: install-dynamic-plugins.py (1288 LOC), test_install-dynamic-plugins.py (3065 LOC), pytest.ini

Key design decisions

Runtime contract is unchanged

Same dynamic-plugins.yaml input schema, same app-config.dynamic-plugins.yaml output, same dynamic-plugin-config.hash / dynamic-plugin-image.hash files, same lock-file behaviour, same {{inherit}} semantics and OCI path auto-detection.

Resource-conscious concurrency

  • availableParallelism() respects cgroup CPU limits (init containers often get 0.5 CPU)
  • Default workers: max(1, min(floor(cpus/2), 6)) — cap avoids exhausting registry/network
  • NPM installs stay sequential (npm registry throttles parallel fetches)
  • Override via DYNAMIC_PLUGINS_WORKERS=<n>

Memory: streaming everywhere

  • node-tar streams extraction — no full-archive read into RAM
  • node:crypto pipeline for SHA integrity — chunks through the hash
  • Typical 10-plugin run: 20–80 MB peak RSS (well below 512 Mi init-container limit)

Security parity with Python

Check File
Path traversal in plugin path (.., absolute) tar-extract.ts
Per-entry size cap (zip bomb, MAX_ENTRY_SIZE) tar-extract.ts, catalog-index.ts
Sym/hardlink target must stay inside destination tar-extract.ts
Reject device files / FIFOs / unknown entry types tar-extract.ts
package/ prefix enforced for NPM tarballs tar-extract.ts
SRI integrity (sha256 / sha384 / sha512) integrity.ts
Registry fallback registry.access.redhat.com/rhdhquay.io/rhdh image-resolver.ts

Test plan

  • npm run tsc passes (strict mode, noUncheckedIndexedAccess)
  • npm test — 105 Jest tests pass (npm-key, oci-key, integrity, tar-extract, merger, concurrency, lock-file, image-resolver, plugin-hash)
  • npm run build produces fresh dist/install-dynamic-plugins.cjs
  • Container image builds successfully with new .cjs (CI will verify)
  • Init-container flow validated on cluster (end-to-end catalog install + RHDH startup)
  • Resource profile check: wall-clock ≤ fast.py baseline (~2:42 for full catalog)

Compatibility

  • Python 3.11 is still installed in the runtime image for techdocs/mkdocs
  • skopeo is still installed for OCI inspection
  • No new system dependencies required (Node.js 22 already runs the Backstage backend)
  • install-dynamic-plugins.sh wrapper contract unchanged (./install-dynamic-plugins.sh /dynamic-plugins-root)

Related

@openshift-ci openshift-ci bot requested review from hopehadfield and kadel April 13, 2026 14:01
@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Apr 13, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. OCI prefix-collision extraction🐞
Description
extractOciPlugin filters tar entries using filePath.startsWith(pluginPath), which is not
boundary-safe and will also match sibling directories with the same prefix (e.g., extracting
plugin-one also extracts plugin-one-evil/...), installing unintended files.
Code

scripts/install-dynamic-plugins/src/tar-extract.ts[R41-45]

+    filter: (filePath, entry) => {
+      if (pending) return false;
+      const stat = entry as tar.ReadEntry;
+      if (!filePath.startsWith(pluginPath)) return false;
+
Evidence
The filter condition is a raw string prefix check against pluginPath, so it accepts any tar entry
whose path begins with that prefix even if it is not within the intended directory. The existing
test asserts only requested subdirectory extraction, but it doesn’t cover the prefix-collision case
(plugin-one vs plugin-one-evil).

scripts/install-dynamic-plugins/src/tar-extract.ts[41-46]
scripts/install-dynamic-plugins/tests/tar-extract.test.ts[34-47]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`extractOciPlugin()` uses `filePath.startsWith(pluginPath)` which allows prefix-collision directories (e.g., `plugin-one-evil/...`) to be extracted when requesting `plugin-one`.

### Issue Context
This function is intended to extract *only* the requested plugin subdirectory from an OCI layer.

### Fix Focus Areas
- scripts/install-dynamic-plugins/src/tar-extract.ts[41-46]
- scripts/install-dynamic-plugins/__tests__/tar-extract.test.ts[34-47]

### Suggested fix
- Replace the condition with a boundary-safe check, e.g.:
 - accept when `filePath === pluginPath` OR `filePath.startsWith(pluginPathWithSlash)` where `pluginPathWithSlash` is `pluginPath` normalized to end with `/`.
 - Consider using `path.posix` since tar entry paths are POSIX-style.
- Add a Jest test that creates both `plugin-one/` and `plugin-one-evil/` in the tarball and verifies that extracting `plugin-one` does *not* extract `plugin-one-evil`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. NaN disables entry-size cap🐞
Description
MAX_ENTRY_SIZE is parsed with Number(...) without validation; if the env var is non-numeric it
becomes NaN, making every stat.size > MAX_ENTRY_SIZE check evaluate to false and disabling
oversized-entry/zip-bomb protections during tar extraction.
Code

scripts/install-dynamic-plugins/src/types.ts[R39-40]

+export const MAX_ENTRY_SIZE = Number(process.env.MAX_ENTRY_SIZE ?? 20_000_000);
+export const RECOGNIZED_ALGORITHMS = ['sha512', 'sha384', 'sha256'] as const;
Evidence
MAX_ENTRY_SIZE is computed via Number(process.env.MAX_ENTRY_SIZE ?? 20_000_000), which yields
NaN for invalid values; extraction code relies on stat.size > MAX_ENTRY_SIZE (which will never
be true when MAX_ENTRY_SIZE is NaN), so the intended size guard is effectively disabled.

scripts/install-dynamic-plugins/src/types.ts[39-40]
scripts/install-dynamic-plugins/src/tar-extract.ts[43-49]
scripts/install-dynamic-plugins/src/catalog-index.ts[62-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`MAX_ENTRY_SIZE` can become `NaN` when `process.env.MAX_ENTRY_SIZE` is set to a non-numeric value, which disables the `stat.size > MAX_ENTRY_SIZE` safety checks.

### Issue Context
This value is used as a security boundary (zip bomb / oversized tar entry protection) across tar extraction code paths.

### Fix Focus Areas
- scripts/install-dynamic-plugins/src/types.ts[39-40]
- scripts/install-dynamic-plugins/src/tar-extract.ts[43-49]
- scripts/install-dynamic-plugins/src/catalog-index.ts[62-66]

### Suggested fix
- Parse with `Number.parseInt(..., 10)`.
- If the parsed value is not finite or is < 1, fall back to the default (20_000_000) and optionally log a warning.
- (Optional) Clamp to a sane upper bound to avoid accidentally setting an enormous limit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread scripts/install-dynamic-plugins/src/merger.ts Fixed
@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

Port install-dynamic-plugins from Python to TypeScript/Node.js with performance improvements

✨ Enhancement

Grey Divider

Walkthroughs

Description
• **Complete rewrite**: Replaces Python-based install-dynamic-plugins.py with a TypeScript/Node.js
  implementation (18 modules, 105 Jest tests)
• **Performance improvements**: Incorporates parallel OCI downloads, shared image cache, and
  streaming SHA verification from the install-dynamic-plugins-fast.py POC
• **New package structure**: scripts/install-dynamic-plugins/ with TypeScript sources, bundled to
  single dist/install-dynamic-plugins.cjs (~412 KB)
• **Runtime contract unchanged**: Same dynamic-plugins.yaml input schema, same output format, same
  lock-file behavior, same {{inherit}} semantics
• **Resource-conscious concurrency**: Respects cgroup CPU limits with default workers `max(1,
  min(floor(cpus/2), 6)), configurable via DYNAMIC_PLUGINS_WORKERS`
• **Memory-efficient**: Streaming tar extraction and SHA verification; typical 10-plugin run uses
  20–80 MB peak RSS
• **Security parity**: Path traversal prevention, zip-bomb detection, symlink validation, device
  file rejection, SRI integrity verification, registry fallback
• **CI updated**: Replaced pytest with npm run tsc && npm test plus bundle freshness
  verification
• **Container image**: Updated Containerfile to copy .cjs bundle instead of .py; wrapper shell
  script execs node instead of python
• **Deleted**: install-dynamic-plugins.py (1288 LOC), test_install-dynamic-plugins.py (3065
  LOC), pytest.ini
• **Documentation**: Comprehensive README, updated user docs and CI references
Diagram
flowchart LR
  A["Python Script<br/>install-dynamic-plugins.py"] -->|"Replaced by"| B["TypeScript/Node.js<br/>18 modules + 105 tests"]
  B -->|"Bundled to"| C["dist/install-dynamic-plugins.cjs<br/>~412 KB"]
  C -->|"Copied in"| D["Container Image<br/>Containerfile"]
  E["Parallel OCI<br/>Downloads"] -->|"Incorporated"| B
  F["Shared Image<br/>Cache"] -->|"Incorporated"| B
  G["Streaming SHA<br/>Verification"] -->|"Incorporated"| B
  H["pytest"] -->|"Replaced by"| I["npm test<br/>Jest 105 tests"]
Loading

Grey Divider

File Changes

1. scripts/install-dynamic-plugins/src/index.ts ✨ Enhancement +301/-0

Main orchestrator for dynamic plugin installation

• Main entry point orchestrating the full plugin installation flow
• Handles dynamic-plugins.yaml parsing, plugin merging, and categorization (OCI/NPM/local)
• Manages parallel OCI and sequential NPM installations with error collection
• Implements cleanup of obsolete plugins and lock file management

scripts/install-dynamic-plugins/src/index.ts


2. scripts/install-dynamic-plugins/src/merger.ts ✨ Enhancement +238/-0

Plugin configuration merging and conflict detection

• Implements deep recursive merging of plugin configurations with conflict detection
• Handles OCI plugin merging with {{inherit}} tag resolution and auto-path detection
• Manages NPM plugin merging with version stripping and deduplication
• Raises on conflicting scalar values to prevent silent overwrites

scripts/install-dynamic-plugins/src/merger.ts


3. scripts/install-dynamic-plugins/__tests__/oci-key.test.ts 🧪 Tests +197/-0

OCI package specification parsing tests

• Tests OCI package-spec parsing with tags, digests, and {{inherit}} syntax
• Validates error handling for malformed OCI references
• Tests auto-detection of plugin paths from image cache
• Covers registry with ports and multiple digest algorithms

scripts/install-dynamic-plugins/tests/oci-key.test.ts


View more (45)
4. scripts/install-dynamic-plugins/__tests__/tar-extract.test.ts 🧪 Tests +134/-0

Tar extraction security and functionality tests

• Tests streaming extraction of OCI plugin layers with security checks
• Validates path-traversal protection and zip-bomb detection
• Tests NPM tarball extraction with package/ prefix enforcement
• Covers symlink escape detection and entry size limits

scripts/install-dynamic-plugins/tests/tar-extract.test.ts


5. scripts/install-dynamic-plugins/src/tar-extract.ts ✨ Enhancement +168/-0

Secure streaming tar extraction with validation

• Streaming tar extraction for OCI layers and NPM packages using node-tar
• Security checks: path-traversal prevention, per-entry size limits, symlink validation
• Rejects device files and non-regular entry types
• Enforces package/ prefix for NPM tarballs

scripts/install-dynamic-plugins/src/tar-extract.ts


6. scripts/install-dynamic-plugins/src/catalog-index.ts ✨ Enhancement +142/-0

Catalog index image extraction and processing

• Extracts catalog index OCI image and produces dynamic-plugins.default.yaml
• Optionally extracts catalog entities to configurable directory
• Streams layer extraction with security checks (size limits, symlink validation)
• Handles manifest parsing and layer digest resolution

scripts/install-dynamic-plugins/src/catalog-index.ts


7. scripts/install-dynamic-plugins/src/oci-key.ts ✨ Enhancement +100/-0

OCI package specification parsing and validation

• Parses OCI package specifications with tags, digests, and {{inherit}} syntax
• Auto-detects plugin paths from image cache when not explicitly specified
• Validates registry format including host:port combinations
• Supports multiple digest algorithms (sha256, sha512, blake3)

scripts/install-dynamic-plugins/src/oci-key.ts


8. scripts/install-dynamic-plugins/src/image-cache.ts ✨ Enhancement +98/-0

OCI image caching and manifest inspection

• Shared cache for OCI image tarballs to avoid redundant downloads
• Caches promises to deduplicate concurrent requests for the same image
• Retrieves image digests and plugin paths from OCI manifests
• Handles io.backstage.dynamic-packages annotation parsing

scripts/install-dynamic-plugins/src/image-cache.ts


9. scripts/install-dynamic-plugins/src/installer-oci.ts ✨ Enhancement +96/-0

OCI plugin installation with change detection

• Installs single OCI-packaged plugin with pull-policy support
• Implements change detection via plugin hash and image digest comparison
• Writes hash files for tracking installed plugins
• Handles skip logic for already-installed plugins

scripts/install-dynamic-plugins/src/installer-oci.ts


10. scripts/install-dynamic-plugins/src/npm-key.ts ✨ Enhancement +68/-0

NPM package specification parsing

• Parses NPM package specifications (standard, aliases, git URLs, GitHub shorthand)
• Strips version/ref information for deduplication keys
• Handles local paths and tarball files unchanged
• Matches npm CLI v11 package-spec reference

scripts/install-dynamic-plugins/src/npm-key.ts


11. scripts/install-dynamic-plugins/__tests__/merger.test.ts 🧪 Tests +62/-0

Plugin merger and deep-merge tests

• Tests deep merge functionality with conflict detection
• Validates NPM plugin merging with level-based override semantics
• Tests duplicate detection within same configuration level
• Covers type validation for plugin package field

scripts/install-dynamic-plugins/tests/merger.test.ts


12. scripts/install-dynamic-plugins/src/installer-npm.ts ✨ Enhancement +80/-0

NPM plugin installation with integrity verification

• Installs NPM or local plugins using npm pack and streaming extraction
• Verifies SRI integrity for remote packages (unless skipped)
• Implements pull-policy and force-download logic
• Writes config hash for change detection

scripts/install-dynamic-plugins/src/installer-npm.ts


13. scripts/install-dynamic-plugins/src/skopeo.ts ✨ Enhancement +79/-0

Skopeo CLI wrapper with caching

• Wrapper around skopeo CLI with promise-based caching for inspect results
• Deduplicates concurrent requests for the same image
• Provides copy, inspect, and exists operations
• Handles both raw and parsed manifest inspection

scripts/install-dynamic-plugins/src/skopeo.ts


14. scripts/install-dynamic-plugins/src/plugin-hash.ts ✨ Enhancement +74/-0

Plugin hash computation for change detection

• Computes SHA256 hash for change detection ("already installed?")
• For local packages, includes package.json and lock-file mtimes
• Excludes version, pluginConfig, and _level from hash calculation
• Uses deterministic JSON stringification with sorted keys

scripts/install-dynamic-plugins/src/plugin-hash.ts


15. scripts/install-dynamic-plugins/__tests__/concurrency.test.ts 🧪 Tests +76/-0

Concurrency control and worker selection tests

• Tests Semaphore class for bounding concurrent operations
• Validates mapConcurrent respects concurrency limits and captures errors
• Tests getWorkers() with environment variable override and auto-detection
• Covers cgroup CPU limit awareness

scripts/install-dynamic-plugins/tests/concurrency.test.ts


16. scripts/install-dynamic-plugins/__tests__/npm-key.test.ts 🧪 Tests +48/-0

NPM package specification parsing tests

• Tests NPM package-spec parsing for standard packages, aliases, and git URLs
• Validates version/ref stripping for deduplication
• Covers local paths and tarball files (unchanged)
• Tests GitHub shorthand and various git URL formats

scripts/install-dynamic-plugins/tests/npm-key.test.ts


17. scripts/install-dynamic-plugins/src/concurrency.ts ✨ Enhancement +77/-0

Concurrency control with resource-conscious defaults

• Implements Semaphore for bounding concurrent async operations
• Provides mapConcurrent for parallel work with error capture (no cancellation)
• Implements getWorkers() with cgroup-aware CPU limit detection
• Default: max(1, min(floor(cpus/2), 6)) with DYNAMIC_PLUGINS_WORKERS override

scripts/install-dynamic-plugins/src/concurrency.ts


18. scripts/install-dynamic-plugins/__tests__/integrity.test.ts 🧪 Tests +63/-0

SRI integrity verification tests

• Tests SRI integrity verification for sha256, sha512, and sha384
• Validates error handling for malformed integrity strings and unsupported algorithms
• Tests base64 validation and hash mismatch detection
• Covers streaming verification without full archive load

scripts/install-dynamic-plugins/tests/integrity.test.ts


19. scripts/install-dynamic-plugins/src/integrity.ts ✨ Enhancement +65/-0

Streaming SRI integrity verification

• Verifies NPM package archives against SRI-style integrity strings
• Uses streaming createHash to avoid loading large files into memory
• Supports sha256, sha384, and sha512 algorithms
• Validates base64 encoding and algorithm support

scripts/install-dynamic-plugins/src/integrity.ts


20. scripts/install-dynamic-plugins/src/lock-file.ts ✨ Enhancement +69/-0

Exclusive lock file management with signal cleanup

• Acquires exclusive lock file with polling for concurrent process safety
• Registers cleanup handlers for SIGTERM, SIGINT, and process exit
• Implements atomic lock creation with wx flag
• Mirrors Python loop behavior for resilience to stale locks

scripts/install-dynamic-plugins/src/lock-file.ts


21. scripts/install-dynamic-plugins/__tests__/image-resolver.test.ts 🧪 Tests +36/-0

Registry fallback resolution tests

• Tests registry fallback from registry.access.redhat.com/rhdh to quay.io/rhdh
• Validates protocol preservation (oci:// and docker://) on fallback
• Tests non-RHDH images pass through unchanged
• Covers existence check via skopeo inspect

scripts/install-dynamic-plugins/tests/image-resolver.test.ts


22. scripts/install-dynamic-plugins/src/run.ts ✨ Enhancement +39/-0

Subprocess execution with structured error handling

• Executes subprocesses with captured stdout/stderr
• Throws InstallException with full context (exit code, stderr) on failure
• Provides structured error messages for debugging
• Matches Python run() contract

scripts/install-dynamic-plugins/src/run.ts


23. scripts/install-dynamic-plugins/src/types.ts ✨ Enhancement +41/-0

Type definitions and configuration constants

• Defines Plugin, PluginMap, and DynamicPluginsConfig types
• Exports constants for file names, protocols, and registry URLs
• Defines PullPolicy enum and Algorithm type for integrity
• Sets MAX_ENTRY_SIZE and RECOGNIZED_ALGORITHMS from environment/defaults

scripts/install-dynamic-plugins/src/types.ts


24. scripts/install-dynamic-plugins/__tests__/lock-file.test.ts 🧪 Tests +33/-0

Lock file creation and release tests

• Tests atomic lock file creation and removal
• Validates no-op behavior when lock file is absent
• Tests waiting for existing lock release before acquisition
• Covers concurrent process safety

scripts/install-dynamic-plugins/tests/lock-file.test.ts


25. scripts/install-dynamic-plugins/src/image-resolver.ts ✨ Enhancement +27/-0

OCI image reference resolution with fallback

• Resolves OCI image references with registry fallback logic
• Falls back from registry.access.redhat.com/rhdh to quay.io/rhdh when unavailable
• Preserves protocol (oci:// or docker://) on fallback
• Uses skopeo exists for availability check

scripts/install-dynamic-plugins/src/image-resolver.ts


26. scripts/install-dynamic-plugins/__tests__/plugin-hash.test.ts 🧪 Tests +29/-0

Plugin hash computation tests

• Tests deterministic hash generation for plugin change detection
• Validates that pluginConfig and version don't affect hash
• Tests hash changes when package or pullPolicy changes
• Covers local package info inclusion in hash

scripts/install-dynamic-plugins/tests/plugin-hash.test.ts


27. scripts/install-dynamic-plugins/src/which.ts ✨ Enhancement +26/-0

PATH lookup utility without external dependency

• Minimal which(1) implementation without external dependency
• Searches PATH for executable binary with platform-specific extensions
• Returns absolute path or null if not found
• Handles Windows and Unix path separators

scripts/install-dynamic-plugins/src/which.ts


28. scripts/install-dynamic-plugins/src/errors.ts ✨ Enhancement +10/-0

Custom exception type for installer errors

• Defines InstallException for installer-level failures
• Allows callers to distinguish expected failures from bugs
• Extends Error with custom name for better error handling

scripts/install-dynamic-plugins/src/errors.ts


29. scripts/install-dynamic-plugins/src/log.ts ✨ Enhancement +3/-0

Uniform logging utility

• Provides uniform stdout logging function
• Simple wrapper around process.stdout.write with newline

scripts/install-dynamic-plugins/src/log.ts


30. scripts/install-dynamic-plugins/install-dynamic-plugins.sh ✨ Enhancement +1/-1

Shell wrapper updated for Node.js execution

• Updated wrapper script to execute Node.js instead of Python
• Changed from python install-dynamic-plugins.py to node install-dynamic-plugins.cjs
• Uses exec for process replacement

scripts/install-dynamic-plugins/install-dynamic-plugins.sh


31. scripts/install-dynamic-plugins/.prettierrc.js ⚙️ Configuration changes +15/-0

Prettier code formatting configuration

• Prettier configuration for TypeScript code formatting
• Sets printWidth to 100, trailing commas, and consistent style
• Enforces consistent formatting across the package

scripts/install-dynamic-plugins/.prettierrc.js


32. scripts/install-dynamic-plugins/README.md 📝 Documentation +110/-0

Complete documentation for TypeScript implementation

• Comprehensive documentation of the TypeScript implementation
• Describes architecture, concurrency strategy, memory budget, and security checks
• Documents environment variables and development workflow
• Explains compatibility with previous Python implementation

scripts/install-dynamic-plugins/README.md


33. scripts/install-dynamic-plugins/package.json ⚙️ Configuration changes +34/-0

NPM package configuration and dependencies

• Defines npm package with Node.js 22+ requirement
• Lists dependencies: tar and yaml
• Includes dev dependencies for TypeScript, Jest, esbuild, and linting
• Provides build, test, and lint scripts

scripts/install-dynamic-plugins/package.json


34. .github/workflows/pr.yaml ⚙️ Configuration changes +16/-2

CI workflow updated for TypeScript testing

• Replaced Python pytest with TypeScript npm test workflow
• Added npm run tsc for type checking
• Added bundle freshness verification via npm run build and git diff
• Updated working directory to scripts/install-dynamic-plugins

.github/workflows/pr.yaml


35. build/containerfiles/Containerfile ⚙️ Configuration changes +2/-1

Container build updated for Node.js bundle

• Updated COPY directive to use bundled .cjs instead of .py
• Now copies dist/install-dynamic-plugins.cjs and install-dynamic-plugins.sh
• Removed reference to Python implementation

build/containerfiles/Containerfile


36. scripts/install-dynamic-plugins/tsconfig.json ⚙️ Configuration changes +22/-0

TypeScript compiler configuration

• TypeScript compiler configuration with strict mode enabled
• Sets target to ES2022 and module to NodeNext
• Enables noUncheckedIndexedAccess for safety
• Configures Jest and Node.js type definitions

scripts/install-dynamic-plugins/tsconfig.json


37. scripts/install-dynamic-plugins/jest.config.cjs ⚙️ Configuration changes +17/-0

Jest test runner configuration

• Jest test runner configuration with ts-jest preset
• Configures Node.js test environment and 20-second timeout
• Maps .js imports to .ts sources for NodeNext compatibility
• Includes coverage collection from src files

scripts/install-dynamic-plugins/jest.config.cjs


38. .cursor/rules/ci-e2e-testing.mdc 📝 Documentation +1/-1

Documentation reference updated for TypeScript

• Updated reference from install-dynamic-plugins.py to install-dynamic-plugins directory
• Reflects migration to TypeScript/Node.js implementation

.cursor/rules/ci-e2e-testing.mdc


39. docs/dynamic-plugins/installing-plugins.md 📝 Documentation +1/-1

User documentation updated for TypeScript

• Updated documentation reference from install-dynamic-plugins.py to install-dynamic-plugins
 directory
• Reflects migration to TypeScript/Node.js implementation

docs/dynamic-plugins/installing-plugins.md


40. scripts/install-dynamic-plugins/esbuild.config.mjs ⚙️ Configuration changes +15/-0

esbuild bundler configuration

• esbuild configuration for bundling TypeScript to CommonJS
• Targets Node.js 22 with shebang banner
• Produces single dist/install-dynamic-plugins.cjs file
• Disables minification and sourcemaps for readability

scripts/install-dynamic-plugins/esbuild.config.mjs


41. .claude/memories/ci-e2e-testing.md 📝 Documentation +1/-1

Memory note updated for TypeScript

• Updated reference from install-dynamic-plugins.py to install-dynamic-plugins directory
• Reflects migration to TypeScript/Node.js implementation

.claude/memories/ci-e2e-testing.md


42. .opencode/memories/ci-e2e-testing.md 📝 Documentation +1/-1

Update dynamic plugin installer documentation reference

• Updated documentation link from Python script to TypeScript/Node.js implementation directory
• Changed reference from scripts/install-dynamic-plugins/install-dynamic-plugins.py to
 scripts/install-dynamic-plugins

.opencode/memories/ci-e2e-testing.md


43. .claude/rules/ci-e2e-testing.md 📝 Documentation +1/-1

Update dynamic plugin installer documentation reference

• Updated documentation link from Python script to TypeScript/Node.js implementation directory
• Changed reference from scripts/install-dynamic-plugins/install-dynamic-plugins.py to
 scripts/install-dynamic-plugins

.claude/rules/ci-e2e-testing.md


44. .rulesync/rules/ci-e2e-testing.md 📝 Documentation +1/-1

Update dynamic plugin installer documentation reference

• Updated documentation link from Python script to TypeScript/Node.js implementation directory
• Changed reference from scripts/install-dynamic-plugins/install-dynamic-plugins.py to
 scripts/install-dynamic-plugins

.rulesync/rules/ci-e2e-testing.md


45. scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs Additional files +11501/-0

...

scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs


46. scripts/install-dynamic-plugins/install-dynamic-plugins.py Additional files +0/-1288

...

scripts/install-dynamic-plugins/install-dynamic-plugins.py


47. scripts/install-dynamic-plugins/pytest.ini Additional files +0/-4

...

scripts/install-dynamic-plugins/pytest.ini


48. scripts/install-dynamic-plugins/test_install-dynamic-plugins.py Additional files +0/-3065

...

scripts/install-dynamic-plugins/test_install-dynamic-plugins.py


Grey Divider

Qodo Logo

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: failure.

@rostalan
Copy link
Copy Markdown
Contributor

my browser cannot even load the 11,5k-line file 😅

@gustavolira
Copy link
Copy Markdown
Member Author

my browser cannot even load the 11,5k-line file 😅

@rostalan No need to review that file, it's the auto-generated esbuild bundle of src/. Just pushed a commit marking it as linguist-generated so GitHub collapses it in the diff. Only src/ and tests/ need review; CI verifies the bundle is up-to-date on every push.

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

gustavolira added a commit to gustavolira/rhdh that referenced this pull request Apr 13, 2026
… feedback

Addresses review feedback on PR redhat-developer#4574:

- Extract shared helpers into `src/util.ts`: `fileExists`, `isInside`,
  `isPlainObject`, `isAllowedEntryType`, `markAsFresh`. Removes 4x
  duplication across `index.ts`, `catalog-index.ts`, `installer-oci.ts`,
  `merger.ts`, and `tar-extract.ts`.

- Unify `catalog-index.ts` tar filter with `tar-extract.ts`: oversized
  entries now throw `InstallException` instead of being silently
  dropped; `OldFile` and `ContiguousFile` are accepted (were previously
  excluded by mistake). Uses the `isAllowedEntryType` helper.

- Extract `markAsFresh(installed, pluginPath)` helper used by both
  installers to drop stale hash entries after a successful install.

- `installer-npm.ts`: use `npm pack --json` instead of parsing the
  last line of text stdout (warnings on stdout would shift the
  filename). Also simplify the integrity-check flow — one gate that
  throws on missing hash, then one verify call (was two overlapping
  conditionals).

- Split `Plugin` → `PluginSpec` (YAML schema) + `Plugin` (internal,
  with `_level`/`plugin_hash`/`version`). Makes it explicit which
  fields originate from user YAML vs runtime state.

- `lock-file.ts`: add `DYNAMIC_PLUGINS_LOCK_TIMEOUT_MS` (default 10
  min) so a stale lock from a `kill -9`'d process no longer wedges
  the init container forever. New test covers the timeout path.

- Drop the broken `lint:check` script — it had `|| true` silencing
  every lint error and there is no ESLint config in the package.

- `README.md`: remove stale reference to non-existent `cli.ts`,
  document the new lock-timeout env var, mention `util.ts`.

Tests: 115 (was 114). Bundle rebuilt (413.4 KB).
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

gustavolira added a commit to gustavolira/rhdh that referenced this pull request Apr 13, 2026
Resolves the 21 issues flagged by SonarQube on PR redhat-developer#4574:

Prototype pollution (CodeQL, merger.ts)
- `deepMerge` now assigns via `Object.defineProperty` (bypasses the
  `__proto__` setter on `Object.prototype`) in addition to the existing
  `FORBIDDEN_KEYS` guard. CodeQL recognizes this pattern.

Redundant type assertions
- `index.ts:180`: drop `pc as Record<string, unknown>` — use the
  `isPlainObject` type guard already imported from `util.ts`.
- `installer-npm.ts:37`, `installer-oci.ts:35`: replace
  `(plugin.pluginConfig ?? {}) as Record<string, unknown>` with a
  typed local variable.
- `installer-oci.ts:41,51,71,78`: drop `as string` casts by restructuring
  the `isAlreadyInstalled` helper with proper `undefined` checks.
- `merger.ts:136-140`: replace `.slice(-1)[0] as string` with
  `.at(-1) ?? ''`.
- `merger.ts:215`: `ReadonlyArray<keyof Plugin | string>` collapses to
  `ReadonlyArray<string>`.

Cognitive complexity reductions
- `installOciPlugin` (17 → ~10): extract `resolvePullPolicy` and
  `isAlreadyInstalled` helpers.
- `mergeOciPlugin` (20 → ~12): extract `resolveInherit`.
- `npmPluginKey` (16 → ~7): extract `tryParseAlias`, `isGitLikeSpec`,
  `stripRefSuffix`.
- `ociPluginKey`: extract `autoDetectPluginPath`.

Modern JS / readability (es2015-es2022)
- `integrity.ts`: `charCodeAt` → `codePointAt` (es2015).
- `oci-key.ts`: use `String.raw` for the regex pieces containing `\s`,
  `\d`, `\]`, `\\` instead of escaped string literals (es2015).
- `oci-key.ts:escape`: `.replace(/.../g, ...)` → `.replaceAll(...)` (es2021).
- `plugin-hash.ts`: pass an explicit code-point comparator to `sort` so
  deterministic-hash behavior is spelled out. `localeCompare` is NOT
  used — it varies per-locale and would break hash stability.

All 115 tests still pass. Bundle rebuilt (415.1 KB).
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

* gives CodeQL the pattern it recognizes for prototype-pollution safety.
*/
function safeSet(dst: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(dst, key, {
@gustavolira
Copy link
Copy Markdown
Member Author

/review

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Apr 13, 2026

PR Reviewer Guide 🔍

(Review updated until commit ee984d5)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Path Handling

The installer reads dynamic-plugins.yaml and includes via relative paths (fileExists(CONFIG_FILE), fs.readFile(CONFIG_FILE), fileExists(inc)), which depends on the current working directory. Validate that the runtime wrapper/container always sets cwd consistently (or consider resolving these paths relative to root or the config file directory) to avoid skipping configs/includes unexpectedly.

const CONFIG_FILE = 'dynamic-plugins.yaml';

async function main(): Promise<void> {
  const argv = process.argv.slice(2);
  if (argv.length < 1) {
    process.stderr.write(`Usage: install-dynamic-plugins <dynamic-plugins-root>\n`);
    process.exit(1);
  }
  const root = path.resolve(argv[0] as string);
  const lockPath = path.join(root, LOCK_FILENAME);
  registerLockCleanup(lockPath);
  await fs.mkdir(root, { recursive: true });
  await createLock(lockPath);

  let exitCode = 0;
  try {
    exitCode = await runInstaller(root);
  } finally {
    await cleanupCatalogIndexTemp(root).catch(() => undefined);
    await removeLock(lockPath).catch(() => undefined);
  }
  process.exit(exitCode);
}

async function runInstaller(root: string): Promise<number> {
  const skopeo = new Skopeo();
  const workers = getWorkers();
  log(`======= Workers: ${workers} (CPUs: ${os.cpus().length})`);

  // Optional catalog index extraction — surfaces `dynamic-plugins.default.yaml`.
  const catalogImage = process.env.CATALOG_INDEX_IMAGE ?? '';
  let catalogDpdy: string | null = null;
  if (catalogImage) {
    const entitiesDir =
      process.env.CATALOG_ENTITIES_EXTRACT_DIR ?? path.join(os.tmpdir(), 'extensions');
    catalogDpdy = await extractCatalogIndex(skopeo, catalogImage, root, entitiesDir);
  }

  const skipIntegrity = (process.env.SKIP_INTEGRITY_CHECK ?? '').toLowerCase() === 'true';

  const globalConfigFile = path.join(root, GLOBAL_CONFIG_FILENAME);
  if (!(await fileExists(CONFIG_FILE))) {
    log(`No ${CONFIG_FILE} found. Skipping.`);
    await fs.writeFile(globalConfigFile, '');
    return 0;
  }

  const rawContent = await fs.readFile(CONFIG_FILE, 'utf8');
  const content = parseYaml(rawContent) as DynamicPluginsConfig | null;
  if (!content) {
    log(`${CONFIG_FILE} is empty. Skipping.`);
    await fs.writeFile(globalConfigFile, '');
    return 0;
  }

  const imageCache = new OciImageCache(
    skopeo,
    await fs.mkdtemp(path.join(os.tmpdir(), 'rhdh-oci-cache-')),
  );

  const allPlugins: PluginMap = {};
  const includes = [...(content.includes ?? [])];

  // Substitute the placeholder DPDY include with the extracted catalog-index file.
  if (catalogDpdy) {
    const idx = includes.indexOf(DPDY_FILENAME);
    if (idx !== -1) includes[idx] = catalogDpdy;
  }

  for (const inc of includes) {
    if (!(await fileExists(inc))) {
      log(`WARNING: include file ${inc} not found, skipping`);
      continue;
    }
    log(`\n======= Including plugins from ${inc}`);
    await mergePluginsFromFile(inc, allPlugins, /* level */ 0, imageCache);
  }

  for (const plugin of content.plugins ?? []) {
    await mergePlugin(plugin, allPlugins, CONFIG_FILE, /* level */ 1, imageCache);
  }
Path Traversal

assertSafePluginPath currently checks pluginPath.includes('..'), which may both over-block benign names containing .. and miss more nuanced traversal patterns; consider validating path segments (e.g., splitting on / and rejecting ./.. segments) and normalizing before checks. Also revalidate that symlink/hardlink target checks are anchored correctly for the extraction root.

export async function extractOciPlugin(
  tarball: string,
  pluginPath: string,
  destination: string,
): Promise<void> {
  assertSafePluginPath(pluginPath);

  const destAbs = path.resolve(destination);
  const pluginDir = path.join(destAbs, pluginPath);
  await fs.rm(pluginDir, { recursive: true, force: true });
  await fs.mkdir(destAbs, { recursive: true });

  // Boundary-safe path prefix — prevents `plugin-one` from matching sibling
  // directories with the same prefix (e.g., `plugin-one-evil/`).
  const pluginPathBoundary = pluginPath.endsWith('/') ? pluginPath : pluginPath + '/';

  // Errors thrown inside `tar` filter callbacks are sometimes swallowed by the
  // parser; capture them in a closure and re-throw after extraction completes.
  let pending: InstallException | null = null;

  await tar.x({
    file: tarball,
    cwd: destAbs,
    preservePaths: false,
    filter: (filePath, entry) => {
      if (pending) return false;
      const stat = entry as tar.ReadEntry;
      if (filePath !== pluginPath && !filePath.startsWith(pluginPathBoundary)) return false;

      if (stat.size > MAX_ENTRY_SIZE) {
        pending = new InstallException(`Zip bomb detected in ${filePath}`);
        return false;
      }
      if (stat.type === 'SymbolicLink' || stat.type === 'Link') {
        const linkName = stat.linkpath ?? '';
        const linkTarget = path.resolve(destAbs, linkName);
        if (!isInside(linkTarget, destAbs)) {
          log(
            `\t==> WARNING: skipping file containing link outside of the archive: ${filePath} -> ${linkName}`,
          );
          return false;
        }
      }
      if (!isAllowedEntryType(stat.type)) {
        pending = new InstallException(`Disallowed tar entry type ${stat.type} for ${filePath}`);
        return false;
      }
      return true;
    },
  });

  if (pending) throw pending;
}

/**
 * Extract an NPM tarball (`npm pack` output). Entries all start with `package/`
 * which is stripped. Matches `extract_npm_package` in fast.py, including the
 * realpath-based escape check for symlinks inside the archive.
 *
 * Returns the directory name (basename) the package was extracted into.
 */
export async function extractNpmPackage(archive: string): Promise<string> {
  if (!archive.endsWith('.tgz')) {
    throw new InstallException(`Expected .tgz archive, got ${archive}`);
  }
  const pkgDir = archive.slice(0, -'.tgz'.length);
  const pkgDirReal = path.resolve(pkgDir);
  await fs.rm(pkgDir, { recursive: true, force: true });
  await fs.mkdir(pkgDir, { recursive: true });

  let pending: InstallException | null = null;

  await tar.x({
    file: archive,
    cwd: pkgDir,
    preservePaths: false,
    filter: (filePath, entry) => {
      if (pending) return false;
      const stat = entry as tar.ReadEntry;
      if (stat.type === 'Directory') return false;

      if (stat.type === 'File') {
        if (!filePath.startsWith(PACKAGE_PREFIX)) {
          pending = new InstallException(
            `NPM package archive does not start with 'package/' as it should: ${filePath}`,
          );
          return false;
        }
        if (stat.size > MAX_ENTRY_SIZE) {
          pending = new InstallException(`Zip bomb detected in ${filePath}`);
          return false;
        }
        stat.path = filePath.slice(PACKAGE_PREFIX.length);
        return true;
      }

      if (stat.type === 'SymbolicLink' || stat.type === 'Link') {
        const linkPath = stat.linkpath ?? '';
        if (!linkPath.startsWith(PACKAGE_PREFIX)) {
          pending = new InstallException(
            `NPM package archive contains a link outside of the archive: ${filePath} -> ${linkPath}`,
          );
          return false;
        }
        stat.path = filePath.slice(PACKAGE_PREFIX.length);
        stat.linkpath = linkPath.slice(PACKAGE_PREFIX.length);
        const linkTarget = path.resolve(pkgDir, stat.linkpath);
        if (!isInside(linkTarget, pkgDirReal)) {
          pending = new InstallException(
            `NPM package archive contains a link outside of the archive: ${stat.path} -> ${stat.linkpath}`,
          );
          return false;
        }
        return true;
      }

      pending = new InstallException(
        `NPM package archive contains a non-regular file: ${filePath}`,
      );
      return false;
    },
  });

  if (pending) throw pending;

  await fs.rm(archive, { force: true });
  return path.basename(pkgDirReal);
}

function assertSafePluginPath(pluginPath: string): void {
  if (pluginPath.includes('..') || path.isAbsolute(pluginPath)) {
    throw new InstallException(`Invalid plugin path (path traversal detected): ${pluginPath}`);
  }
}
📄 References
  1. redhat-developer/rhdh/dynamic-plugins/_utils/src/wrappers.test.ts [141-307]
  2. redhat-developer/rhdh/plugins/dynamic-plugins-info-backend/__fixtures__/data.ts [1-68]
  3. redhat-developer/rhdh/e2e-tests/playwright/e2e/plugins/bulk-import.spec.ts [10-282]
  4. redhat-developer/rhdh/plugins/dynamic-plugins-info-backend/__fixtures__/data.ts [144-152]
  5. redhat-developer/rhdh/dynamic-plugins/_utils/src/wrappers.test.ts [1-58]
  6. redhat-developer/rhdh/e2e-tests/playwright/e2e/plugins/tekton/tekton.spec.ts [1-12]
  7. redhat-developer/rhdh/plugins/dynamic-plugins-info-backend/__fixtures__/data.ts [77-143]

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: failure.

gustavolira added a commit to gustavolira/rhdh that referenced this pull request Apr 13, 2026
… feedback

Addresses review feedback on PR redhat-developer#4574:

- Extract shared helpers into `src/util.ts`: `fileExists`, `isInside`,
  `isPlainObject`, `isAllowedEntryType`, `markAsFresh`. Removes 4x
  duplication across `index.ts`, `catalog-index.ts`, `installer-oci.ts`,
  `merger.ts`, and `tar-extract.ts`.

- Unify `catalog-index.ts` tar filter with `tar-extract.ts`: oversized
  entries now throw `InstallException` instead of being silently
  dropped; `OldFile` and `ContiguousFile` are accepted (were previously
  excluded by mistake). Uses the `isAllowedEntryType` helper.

- Extract `markAsFresh(installed, pluginPath)` helper used by both
  installers to drop stale hash entries after a successful install.

- `installer-npm.ts`: use `npm pack --json` instead of parsing the
  last line of text stdout (warnings on stdout would shift the
  filename). Also simplify the integrity-check flow — one gate that
  throws on missing hash, then one verify call (was two overlapping
  conditionals).

- Split `Plugin` → `PluginSpec` (YAML schema) + `Plugin` (internal,
  with `_level`/`plugin_hash`/`version`). Makes it explicit which
  fields originate from user YAML vs runtime state.

- `lock-file.ts`: add `DYNAMIC_PLUGINS_LOCK_TIMEOUT_MS` (default 10
  min) so a stale lock from a `kill -9`'d process no longer wedges
  the init container forever. New test covers the timeout path.

- Drop the broken `lint:check` script — it had `|| true` silencing
  every lint error and there is no ESLint config in the package.

- `README.md`: remove stale reference to non-existent `cli.ts`,
  document the new lock-timeout env var, mention `util.ts`.

Tests: 115 (was 114). Bundle rebuilt (413.4 KB).
@gustavolira gustavolira force-pushed the feat/install-dynamic-plugins-ts branch from b76ee6a to dd84f75 Compare April 13, 2026 19:48
gustavolira added a commit to gustavolira/rhdh that referenced this pull request Apr 13, 2026
Resolves the 21 issues flagged by SonarQube on PR redhat-developer#4574:

Prototype pollution (CodeQL, merger.ts)
- `deepMerge` now assigns via `Object.defineProperty` (bypasses the
  `__proto__` setter on `Object.prototype`) in addition to the existing
  `FORBIDDEN_KEYS` guard. CodeQL recognizes this pattern.

Redundant type assertions
- `index.ts:180`: drop `pc as Record<string, unknown>` — use the
  `isPlainObject` type guard already imported from `util.ts`.
- `installer-npm.ts:37`, `installer-oci.ts:35`: replace
  `(plugin.pluginConfig ?? {}) as Record<string, unknown>` with a
  typed local variable.
- `installer-oci.ts:41,51,71,78`: drop `as string` casts by restructuring
  the `isAlreadyInstalled` helper with proper `undefined` checks.
- `merger.ts:136-140`: replace `.slice(-1)[0] as string` with
  `.at(-1) ?? ''`.
- `merger.ts:215`: `ReadonlyArray<keyof Plugin | string>` collapses to
  `ReadonlyArray<string>`.

Cognitive complexity reductions
- `installOciPlugin` (17 → ~10): extract `resolvePullPolicy` and
  `isAlreadyInstalled` helpers.
- `mergeOciPlugin` (20 → ~12): extract `resolveInherit`.
- `npmPluginKey` (16 → ~7): extract `tryParseAlias`, `isGitLikeSpec`,
  `stripRefSuffix`.
- `ociPluginKey`: extract `autoDetectPluginPath`.

Modern JS / readability (es2015-es2022)
- `integrity.ts`: `charCodeAt` → `codePointAt` (es2015).
- `oci-key.ts`: use `String.raw` for the regex pieces containing `\s`,
  `\d`, `\]`, `\\` instead of escaped string literals (es2015).
- `oci-key.ts:escape`: `.replace(/.../g, ...)` → `.replaceAll(...)` (es2021).
- `plugin-hash.ts`: pass an explicit code-point comparator to `sort` so
  deterministic-hash behavior is spelled out. `localeCompare` is NOT
  used — it varies per-locale and would break hash stability.

All 115 tests still pass. Bundle rebuilt (415.1 KB).
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

@gustavolira
Copy link
Copy Markdown
Member Author

/review

@rhdh-qodo-merge
Copy link
Copy Markdown

Persistent review updated to latest commit ee984d5

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: failure.

@github-actions
Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

gustavolira added a commit to gustavolira/rhdh that referenced this pull request Apr 13, 2026
…tions

Two focus-area suggestions from the Qodo reviewer guide on redhat-developer#4574:

1. Path handling (index.ts)
   - Resolve `dynamic-plugins.yaml` to an absolute path at startup and
     log it — the cwd-dependency is now explicit in the operator log.
   - Resolve `includes[]` entries relative to the config file's
     directory (was implicitly relative to cwd). Absolute include paths
     are preserved untouched so existing deployments that pass a full
     path still work.

2. Segment-based path-traversal check (tar-extract.ts)
   - Replace the coarse `pluginPath.includes('..')` filter with a
     segment-based scan: split on `/` and `\\` and reject any segment
     that is empty, `.`, or `..`. Absolute paths are still rejected up
     front.
   - Benign filenames that happen to contain `..` inside a segment
     (e.g., `my..plugin/`) are now accepted; path-traversal via
     `foo/../bar`, leading `.`/`..` segments, or empty segments
     (`foo//bar`) is still blocked.

Added 3 new Jest cases covering:
  - `foo/../bar` rejected
  - `./plugin-one` rejected
  - `foo//bar` rejected
  - `my..plugin` accepted (regression guard for the stricter check)

Total tests: 117 (was 115). Bundle rebuilt.
@github-actions
Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

gustavolira and others added 9 commits April 14, 2026 11:44
Replace the Python implementation of install-dynamic-plugins (used by the
RHDH init container to install dynamic plugins from OCI/NPM/local sources)
with a TypeScript/Node.js implementation, distributed as a single bundled
CommonJS file consumed directly by the runtime container.

Why
- Node.js 22 is already in the runtime image (it runs Backstage), so no new
  system dependencies are needed; Python is no longer pulled in for the init
  container path (the techdocs venv still uses python3.11 separately).
- Easier to maintain alongside the rest of the TS/Node ecosystem in the repo.
- Picks up the parallelization improvements from PR redhat-developer#4523 from the start
  (parallel OCI downloads, shared OCI image cache, streaming SHA verification).

Runtime contract preserved
- Same 'dynamic-plugins.yaml' input schema (includes, plugins, package,
  pluginConfig, disabled, pullPolicy, forceDownload, integrity).
- Same output 'app-config.dynamic-plugins.yaml', plugin directory layout,
  dynamic-plugin-config.hash / dynamic-plugin-image.hash files, lock file.
- {{inherit}} semantics, OCI path auto-detection from the
  io.backstage.dynamic-packages annotation, registry.access.redhat.com→quay.io
  fallback, and SRI integrity (sha256/sha384/sha512) all preserved.

Resource-conscious for OpenShift init containers
- Streaming tar extraction via node-tar (no full layers in memory).
- Streaming SHA via node:crypto pipeline.
- Worker count via os.availableParallelism()/2 capped at 6 (honours cgroup
  CPU limits); override with DYNAMIC_PLUGINS_WORKERS.
- NPM 'npm pack' calls stay sequential to avoid registry throttling.

Security checks ported with parity
- Per-entry MAX_ENTRY_SIZE cap (zip bomb protection).
- Symlink/hardlink realpath validation.
- Device file / FIFO / unknown entry-type rejection.
- 'package/' prefix enforced for NPM tarballs.
- Plugin path traversal (.., absolute) rejected.

Container build
- build/containerfiles/Containerfile copies dist/install-dynamic-plugins.cjs
  instead of install-dynamic-plugins.py. The bundle (~412 KB, built by
  esbuild) is committed to the repo so the hermetic build doesn't need
  network access — same pattern as .yarn/releases/yarn-*.cjs.
- install-dynamic-plugins.sh now execs 'node install-dynamic-plugins.cjs'.

CI
- .github/workflows/pr.yaml replaces 'pytest' with 'npm install && npm run
  tsc && npm test' (105 Jest tests with full parity to the pytest suite),
  plus a freshness check on dist/install-dynamic-plugins.cjs.

Cleanup
- Deletes install-dynamic-plugins.py (1288 LOC), test_install-dynamic-plugins.py
  (3065 LOC), pytest.ini.
- Updates docs and AI rule references that pointed at the old Python script.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Marks `scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs`
as `linguist-generated=true` so GitHub collapses it by default in PR
diffs and excludes it from language statistics. The 11.5k-line esbuild
bundle is not meant to be reviewed line-by-line — reviewers should look
at `src/` instead.
Three fixes + regression tests from automated PR review:

1. `MAX_ENTRY_SIZE` NaN fallback (Qodo bug #1)
   `Number(process.env.MAX_ENTRY_SIZE)` returned NaN for non-numeric
   values, which silently disabled the zip-bomb / oversized-entry size
   check (every `stat.size > NaN` evaluates false). Extracted to a
   `parseMaxEntrySize()` helper that uses `Number.parseInt` + validates
   the result is a finite integer >= 1, else falls back to the default.

2. Boundary-safe OCI plugin-path filter (Qodo bug #2)
   `extractOciPlugin` used `filePath.startsWith(pluginPath)` which also
   matched sibling directories with the same prefix (e.g. requesting
   `plugin-one` would also extract `plugin-one-evil/`). Now requires
   either exact match or a path-separator boundary.

3. Prototype-pollution guard in `deepMerge` (CodeQL finding)
   User-supplied YAML could contain `__proto__`, `constructor`, or
   `prototype` keys. `deepMerge` blindly copied them via `dst[key] =`,
   allowing prototype-chain pollution. These keys are now skipped in
   both `deepMerge` and `copyPluginFields`.

Added regression tests: prefix-collision extraction, prototype-pollution
merge, 7 tests for `parseMaxEntrySize` covering NaN/empty/negative/zero
inputs. Total: 114 tests (was 105).

Bundle rebuilt via `npm run build`.
… feedback

Addresses review feedback on PR redhat-developer#4574:

- Extract shared helpers into `src/util.ts`: `fileExists`, `isInside`,
  `isPlainObject`, `isAllowedEntryType`, `markAsFresh`. Removes 4x
  duplication across `index.ts`, `catalog-index.ts`, `installer-oci.ts`,
  `merger.ts`, and `tar-extract.ts`.

- Unify `catalog-index.ts` tar filter with `tar-extract.ts`: oversized
  entries now throw `InstallException` instead of being silently
  dropped; `OldFile` and `ContiguousFile` are accepted (were previously
  excluded by mistake). Uses the `isAllowedEntryType` helper.

- Extract `markAsFresh(installed, pluginPath)` helper used by both
  installers to drop stale hash entries after a successful install.

- `installer-npm.ts`: use `npm pack --json` instead of parsing the
  last line of text stdout (warnings on stdout would shift the
  filename). Also simplify the integrity-check flow — one gate that
  throws on missing hash, then one verify call (was two overlapping
  conditionals).

- Split `Plugin` → `PluginSpec` (YAML schema) + `Plugin` (internal,
  with `_level`/`plugin_hash`/`version`). Makes it explicit which
  fields originate from user YAML vs runtime state.

- `lock-file.ts`: add `DYNAMIC_PLUGINS_LOCK_TIMEOUT_MS` (default 10
  min) so a stale lock from a `kill -9`'d process no longer wedges
  the init container forever. New test covers the timeout path.

- Drop the broken `lint:check` script — it had `|| true` silencing
  every lint error and there is no ESLint config in the package.

- `README.md`: remove stale reference to non-existent `cli.ts`,
  document the new lock-timeout env var, mention `util.ts`.

Tests: 115 (was 114). Bundle rebuilt (413.4 KB).
Sonar typescript:S5852 flagged `/^[A-Za-z0-9+/]+={0,2}$/` and `/=+$/` in
`integrity.ts` as ReDoS hotspots (super-linear backtracking risk). In
practice these are O(n) because the character classes are flat, but
Sonar is conservative about any `+`/`*` quantifier.

Replaces both regexes with `isBase64Shape()` (char-code scan, one pass)
and `stripTrailingEquals()` (char-code trim). Same linear complexity,
no regex engine involved, Sonar-clean.
Resolves the 21 issues flagged by SonarQube on PR redhat-developer#4574:

Prototype pollution (CodeQL, merger.ts)
- `deepMerge` now assigns via `Object.defineProperty` (bypasses the
  `__proto__` setter on `Object.prototype`) in addition to the existing
  `FORBIDDEN_KEYS` guard. CodeQL recognizes this pattern.

Redundant type assertions
- `index.ts:180`: drop `pc as Record<string, unknown>` — use the
  `isPlainObject` type guard already imported from `util.ts`.
- `installer-npm.ts:37`, `installer-oci.ts:35`: replace
  `(plugin.pluginConfig ?? {}) as Record<string, unknown>` with a
  typed local variable.
- `installer-oci.ts:41,51,71,78`: drop `as string` casts by restructuring
  the `isAlreadyInstalled` helper with proper `undefined` checks.
- `merger.ts:136-140`: replace `.slice(-1)[0] as string` with
  `.at(-1) ?? ''`.
- `merger.ts:215`: `ReadonlyArray<keyof Plugin | string>` collapses to
  `ReadonlyArray<string>`.

Cognitive complexity reductions
- `installOciPlugin` (17 → ~10): extract `resolvePullPolicy` and
  `isAlreadyInstalled` helpers.
- `mergeOciPlugin` (20 → ~12): extract `resolveInherit`.
- `npmPluginKey` (16 → ~7): extract `tryParseAlias`, `isGitLikeSpec`,
  `stripRefSuffix`.
- `ociPluginKey`: extract `autoDetectPluginPath`.

Modern JS / readability (es2015-es2022)
- `integrity.ts`: `charCodeAt` → `codePointAt` (es2015).
- `oci-key.ts`: use `String.raw` for the regex pieces containing `\s`,
  `\d`, `\]`, `\\` instead of escaped string literals (es2015).
- `oci-key.ts:escape`: `.replace(/.../g, ...)` → `.replaceAll(...)` (es2021).
- `plugin-hash.ts`: pass an explicit code-point comparator to `sort` so
  deterministic-hash behavior is spelled out. `localeCompare` is NOT
  used — it varies per-locale and would break hash stability.

All 115 tests still pass. Bundle rebuilt (415.1 KB).
- `merger.ts isEqual` (complexity 20 → ~6): extract `isArrayEqual` and
  `isObjectEqual` helpers. Each branch now dispatches to a single-
  purpose function so the main `isEqual` is a flat type-dispatch
  instead of nested loops.

- `plugin-hash.ts compareCodePoint`: replace the nested ternary
  `a < b ? -1 : a > b ? 1 : 0` with three early returns. Same
  behaviour, no nested conditional.

115 tests still pass.
…d context

The root `.dockerignore` has `**/dist` which also filters out the
committed esbuild bundle at
`scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs`.
The runtime stage of `build/containerfiles/Containerfile` copies that
file, so the Hermetic Build Image job fails with:

    no items matching glob
    ".../scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs"
    copied (1 filtered out using .dockerignore)

Add negation patterns so the bundle (and only the bundle) is
re-included in the build context, matching the `.yarn/releases/yarn-*.cjs`
pattern the repo already uses for committed bundled entry points.
…tions

Two focus-area suggestions from the Qodo reviewer guide on redhat-developer#4574:

1. Path handling (index.ts)
   - Resolve `dynamic-plugins.yaml` to an absolute path at startup and
     log it — the cwd-dependency is now explicit in the operator log.
   - Resolve `includes[]` entries relative to the config file's
     directory (was implicitly relative to cwd). Absolute include paths
     are preserved untouched so existing deployments that pass a full
     path still work.

2. Segment-based path-traversal check (tar-extract.ts)
   - Replace the coarse `pluginPath.includes('..')` filter with a
     segment-based scan: split on `/` and `\\` and reject any segment
     that is empty, `.`, or `..`. Absolute paths are still rejected up
     front.
   - Benign filenames that happen to contain `..` inside a segment
     (e.g., `my..plugin/`) are now accepted; path-traversal via
     `foo/../bar`, leading `.`/`..` segments, or empty segments
     (`foo//bar`) is still blocked.

Added 3 new Jest cases covering:
  - `foo/../bar` rejected
  - `./plugin-one` rejected
  - `foo//bar` rejected
  - `my..plugin` accepted (regression guard for the stricter check)

Total tests: 117 (was 115). Bundle rebuilt.
@gustavolira gustavolira force-pushed the feat/install-dynamic-plugins-ts branch from a0032b4 to add3f1d Compare April 14, 2026 14:45
@github-actions
Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

@gustavolira
Copy link
Copy Markdown
Member Author

/test ?

@gustavolira
Copy link
Copy Markdown
Member Author

/test e2e-ocp-helm-nightly

@gustavolira
Copy link
Copy Markdown
Member Author

/test ?

@openshift-ci
Copy link
Copy Markdown

openshift-ci bot commented Apr 14, 2026

@gustavolira: The following commands are available to trigger required jobs:

/test e2e-ocp-helm

The following commands are available to trigger optional jobs:

/test cleanup-mapt-destroy-orphaned-aks-clusters
/test cleanup-mapt-destroy-orphaned-eks-clusters
/test e2e-aks-helm-nightly
/test e2e-aks-operator-nightly
/test e2e-eks-helm-nightly
/test e2e-eks-operator-nightly
/test e2e-gke-helm-nightly
/test e2e-gke-operator-nightly
/test e2e-ocp-helm-nightly
/test e2e-ocp-helm-upgrade-nightly
/test e2e-ocp-operator-auth-providers-nightly
/test e2e-ocp-operator-nightly
/test e2e-ocp-v4-19-helm-nightly
/test e2e-ocp-v4-20-helm-nightly
/test e2e-ocp-v4-21-helm-nightly
/test e2e-osd-gcp-helm-nightly
/test e2e-osd-gcp-operator-nightly

Use /test all to run the following jobs that were automatically triggered:

pull-ci-redhat-developer-rhdh-main-e2e-ocp-helm
Details

In response to this:

/test ?

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@kadel
Copy link
Copy Markdown
Member

kadel commented Apr 15, 2026

One thing I noticed in the review — the error handling behavior changed from the Python version.

In Python, when a plugin fails to install, the script stops immediately. The config file is never written, nothing is cleaned up. It's messy but clearly "not finished".

In the new TS version, when a plugin fails, the script continues with the rest. It writes app-config.dynamic-plugins.yaml with configs from the successful plugins, runs cleanup, and then exits with code 1.

The exit code 1 prevents the main container from starting, so in normal Kubernetes/Compose flow this is fine. But the problem is that you end up with a state that looks "almost valid" — most plugins installed, config file written, cleanup done. If someone troubleshooting manually restarts just the main container (skipping the init), or if the deployment doesn't enforce init container success, they could get a partially working instance with missing plugins.

Also plugin dependencies could be an issue — a frontend plugin might be installed and configured while its backend dependency failed. The app would start but with broken UI.

I think the idea of collecting all errors and showing them at once is great (much better than fix-one-restart-fix-one-restart with 50 plugins). But maybe don't write the config file when there are errors? That way you get the diagnostic benefit without leaving a valid-looking state on disk.

@gustavolira
Copy link
Copy Markdown
Member Author

One thing I noticed in the review — the error handling behavior changed from the Python version.

In Python, when a plugin fails to install, the script stops immediately. The config file is never written, nothing is cleaned up. It's messy but clearly "not finished".

In the new TS version, when a plugin fails, the script continues with the rest. It writes app-config.dynamic-plugins.yaml with configs from the successful plugins, runs cleanup, and then exits with code 1.

The exit code 1 prevents the main container from starting, so in normal Kubernetes/Compose flow this is fine. But the problem is that you end up with a state that looks "almost valid" — most plugins installed, config file written, cleanup done. If someone troubleshooting manually restarts just the main container (skipping the init), or if the deployment doesn't enforce init container success, they could get a partially working instance with missing plugins.

Also plugin dependencies could be an issue — a frontend plugin might be installed and configured while its backend dependency failed. The app would start but with broken UI.

I think the idea of collecting all errors and showing them at once is great (much better than fix-one-restart-fix-one-restart with 50 plugins). But maybe don't write the config file when there are errors? That way you get the diagnostic benefit without leaving a valid-looking state on disk.

@kadel tyvm for your review! Just pushed a fix that on any plugin failure we now skip both the app-config.dynamic-plugins.yaml write and cleanupRemoved(), so the prior-run state is preserved instead of an "almost valid" one. Still collect all errors into one summary so we keep the UX win over Python's fail-fast.

Skipped cleanup too (not just the write) because otherwise the previous run's config would remain on disk referencing plugin dirs we just deleted, worse than the original bug.

Added unit tests for the success and failure paths, and verified end-to-end with a bogus OCI ref: exit 1, no config written, prior sentinel preserved.

@gashcrumb
Copy link
Copy Markdown
Member

I took a moment to try this out locally by grabbing the dynamic-plugins.default.yaml from the latest catalog image and used this dynamic-plugins.yaml:

includes:
  - dynamic-plugins.default.yaml
plugins: []

Script worked fine but it didn't appear to me like it was much faster than the python script. So I opted to time it's execution as compared to the python script, it seems to take about the same amount of time, at least in my environment:

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' node scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs dynamic-plugins-root
elapsed 68.06 s (user 59.52, sys 9.47)

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' python scripts/install-dynamic-plugins/install-dynamic-plugins.py dynamic-plugins-root
elapsed 62.30 s (user 53.11, sys 6.12)

I suppose most of this is spent doing npm unpack as several of our defaults still point to wrappers.

@gashcrumb
Copy link
Copy Markdown
Member

gashcrumb commented Apr 16, 2026

One other observation, flipping between scripts reveals that the two scripts use different methods to evaluate differences in existing dynamic-plugins-root content. I figured I'd do an additional test where I'd run each script with an already populated dynamic-plugins-root. For both the python script and this javascript version I actually had to run each script a couple of times before getting to a point where the scripts would not download and reinstall an existing plugin. I grabbed the execution time for this case once the contents of the dynamic-plugins-root folder settled:

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' node scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs dynamic-plugins-root
elapsed 17.77 s (user 4.38, sys 1.64)

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' python scripts/install-dynamic-plugins/install-dynamic-plugins.py dynamic-plugins-root
elapsed 13.72 s (user 2.38, sys 0.85)

@gustavolira
Copy link
Copy Markdown
Member Author

I took a moment to try this out locally by grabbing the dynamic-plugins.default.yaml from the latest catalog image and used this dynamic-plugins.yaml:

includes:
  - dynamic-plugins.default.yaml
plugins: []

Script worked fine but it didn't appear to me like it was much faster than the python script. So I opted to time it's execution as compared to the python script, it seems to take about the same amount of time, at least in my environment:

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' node scripts/install-dynamic-plugins/dist/install-dynamic-plugins.cjs dynamic-plugins-root
elapsed 68.06 s (user 59.52, sys 9.47)

/usr/bin/time -f 'elapsed %e s (user %U, sys %S)' python scripts/install-dynamic-plugins/install-dynamic-plugins.py dynamic-plugins-root
elapsed 62.30 s (user 53.11, sys 6.12)

I suppose most of this is spent doing npm unpack as several of our defaults still point to wrappers.

My test was using sanity-plugin check which loads every plugin that we are shipping

Comment thread .github/workflows/pr.yaml
run: yarn run test --continue --affected

- name: Run Python tests
- name: Test install-dynamic-plugins (TypeScript)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think mentioning the technology involved doesn't really provide value here.

Suggested change
- name: Test install-dynamic-plugins (TypeScript)
- name: Test install-dynamic-plugins

Comment thread .github/workflows/pr.yaml
run: pytest scripts/install-dynamic-plugins/test_install-dynamic-plugins.py -v
working-directory: scripts/install-dynamic-plugins
run: |
npm install --no-audit --no-fund
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NPM has a dedicated command for installations on CI, see npm ci.

Suggested change
npm install --no-audit --no-fund
npm ci

Comment thread .github/workflows/pr.yaml
- name: Verify install-dynamic-plugins bundle is up to date
if: ${{ steps.check-image.outputs.is_skipped != 'true' }}
working-directory: scripts/install-dynamic-plugins
run: |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see there is a build step to bundle all of this up into a single file. I assume that this is to reduce the size of the final image?

Would it not be acceptable to npm install --production to only install the production dependencies and then bring in the source + node_modules instead?

Note that Node.js also supports single executable applications, perhaps that would be fit for purpose here?

…exists + skip parallel for no-ops

Three perf/correctness fixes targeting the no-op "already-installed"
restart path that Stan Lewis benchmarked at 17.77s vs 13.72s for Python
in redhat-developer#4574 (comment):

1. plugin-hash: byte-compatible with Python (correctness)
   The previous TS implementation used different field names and JSON
   separators than Python, so install hashes never matched across the
   migration — switching from Python to TS triggered a full reinstall
   of every plugin on the first run. Fixes:
   - Rename internal field `_level` → `last_modified_level` (Python's name)
   - Keep `last_modified_level` in the hash (Python keeps it, we used to strip)
   - Rename `_local` → `_local_package_info`, plus inner field renames
     (`_pj` → `_package_json`, `_pj_mtime` → `_package_json_mtime`,
      `_mtime` → `_directory_mtime`, `_missing` → `_not_found`,
      `_err` → `_error`)
   - Convert `mtimeMs` to seconds (Python's `os.path.getmtime` returns
     float seconds)
   - `stableStringify` now emits Python-style `, ` and `: ` separators so
     the serialized JSON is byte-identical to `json.dumps(..., sort_keys=True)`

   Two regression tests pin the hash to known Python-computed SHA256
   values; if these break, cross-compat is silently lost.

2. Skopeo.exists: cache forks across calls (perf)
   `exists()` was the only Skopeo wrapper without an in-memory cache —
   `image-resolver.ts` calls it per OCI plugin to probe the RHDH
   registry. With N plugins from the same image, that's N redundant
   `skopeo inspect --no-tags` forks each restart. Now memoized like
   `inspect()` / `inspectRaw()` (promise cache for in-flight dedup).
   For a 30-plugin / 1-image catalog: 30 forks → 1.

3. installOci: skip parallel pool for definitely-no-op plugins (perf)
   The IF_NOT_PRESENT no-op path was already a fast hash lookup, but it
   went through `mapConcurrent` + Semaphore + Promise wrapping for every
   plugin. Added a synchronous pre-pass that filters out "hash present
   + not forced + pull-policy ≠ Always" plugins before spinning up the
   worker pool. Saves the entire Promise machinery for the common
   restart case where every plugin is steady-state.

ALWAYS-pull plugins (`:latest!` or explicit `pullPolicy: Always`) still
go through the regular install path because they need a `skopeo inspect`
to compare local vs remote digest — that's where Fix #2's exists-cache
delivers the win.

124 tests now (was 117): +5 Python-compat hash assertions, +2 Skopeo
exists-cache assertions. Bundle 418.1 KB (was 415.7).
@sonarqubecloud
Copy link
Copy Markdown

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants