Skip to content

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

Open
gustavolira wants to merge 20 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 20 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

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.
…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).
…trees

Mirrors the Python-side fix in redhat-developer#4610 (RHDHBUGS-2139): deep-merging two
Backstage `HumanDuration` objects silently combined sibling keys, so a
default `{minutes: 60}` plus a user override `{seconds: 30}` produced
`PT60M30S` instead of replacing one with the other.

Subtrees ending in `schedule.frequency`, `schedule.timeout`, or
`schedule.initialDelay` are now replaced wholesale; everything else
continues to deep-merge as before. The constant list lives in both
implementations and must stay in sync.

Also picks up two related improvements from the Python fix:
- Explicit collision when `dst[key]` is a scalar but `src[key]` is a
  dict (was previously masked by safeSet overwriting the scalar).
- Splits the dict / scalar branches into `mergeDictValue` /
  `mergeScalarValue` helpers — keeps `deepMerge` itself well below the
  Sonar cognitive-complexity threshold.

8 new Jest tests:
- Per-subtree replacement (frequency, timeout, initialDelay)
- The full RHDHBUGS-2139 repro path
  (catalog.providers.keycloakOrg.default.schedule.frequency)
- Sibling merge inside `schedule` still works (timeout vs frequency)
- A bare `frequency` key not under `schedule` still deep-merges
- Scalar-vs-dict collision throws with full path
- Nested-collision error includes the dotted path

Total tests: 132. Bundle 418.5 KB.
Six small cleanups from @jonkoops's review on redhat-developer#4574:

- pr.yaml: rename CI step to "Test install-dynamic-plugins" (drop the
  TypeScript suffix — the implementation language isn't part of the
  contract being tested).
- pr.yaml: switch CI install from `npm install` to `npm ci` (faster,
  deterministic, fails on lockfile/manifest drift).
- package.json: replace `main` with `bin` — this is a CLI entry point,
  not a programmatic API, so `bin` is the appropriate field.
- package.json: switch dependencies from pinned to caret ranges so
  yarn/npm can dedupe transitive resolutions; the lockfile still
  guarantees reproducibility.
- .prettierrc.js: trim to only the three options that diverge from
  Prettier 3 defaults (printWidth, singleQuote, arrowParens). Matches
  RHDH conventions, drops noise.
- tsconfig.json: drop options that match TS 5.x defaults
  (`lib`, `noImplicitAny` (covered by strict), `forceConsistentCasingInFileNames`,
  `resolveJsonModule` (we don't import JSON), `outDir`/`declaration`/`sourceMap`
  (no emit needed — esbuild bundles), and add `noEmit: true` so the
  `tsc` script no longer needs the `--noEmit` flag.

132 tests still pass, bundle rebuilt.

Comments not addressed in this commit (out of scope or established
design):
- ESM vs CJS bundle output — CJS keeps faster startup and matches the
  `.yarn/releases/yarn-*.cjs` precedent in this repo.
- `npm install --production` + ship node_modules instead of bundling —
  hermetic Konflux builds disable network during the image build, so
  the bundle is the simplest path; happy to discuss alternatives.
- Vitest in place of Jest — significant test-framework swap that's
  worth its own PR.
Per @jonkoops's review feedback. Vitest already exists in the repo
(via `backstage-cli package test` in 3 plugins), is the modern direction
of the Backstage CLI test runner, and works natively with TypeScript +
ESM without the ts-jest transform layer.

Changes:
- Replace `jest` + `ts-jest` + `@types/jest` with `vitest@^4.1.4` +
  `@vitest/coverage-v8@^4.1.4` (matches the version pinned by the
  three RHDH plugins already on Vitest).
- Replace `jest.config.cjs` with `vitest.config.ts` — globals enabled
  so existing `describe` / `it` / `expect` / `beforeEach` / `afterEach`
  keep working with no per-file imports.
- Drop the `moduleNameMapper` workaround for `.js` imports — Vitest
  uses Vite's resolver which understands NodeNext-style imports
  natively.
- `finalize-install.test.ts` (the only file using a Jest-specific API):
  swap `jest.spyOn` for `vi.spyOn` and `jest.SpyInstance` for the
  `MockInstance` type imported from vitest.
- `tsconfig.json`: swap the `"jest"` types entry for `"vitest/globals"`.

Includes the previously-untracked `finalize-install.test.ts` (3 tests
covering `finalizeInstall`'s error-path skip-and-preserve behaviour)
because it is real coverage for code that actually ships.

Stats:
- node_modules count: 59 packages (was 294 with Jest+ts-jest)
- All 132 tests still pass; suite runs in ~2.6s
- Bundle unchanged (test runner is dev-only)
Per @jonkoops's review feedback. The previous `prettier --check src
__tests__` invocation missed the package's config files and docs
(`vitest.config.ts`, `esbuild.config.mjs`, `.prettierrc.js`,
`README.md`). Widen to `.` plus a `.prettierignore` so node_modules,
the committed bundle, coverage reports, and the npm lockfile stay out
of formatting.

Reformatted in this commit (auto-applied):
- README.md
- esbuild.config.mjs
- src/merger.ts (one array line)
- src/installer-npm.ts (one nested expression)
- __tests__/merger.test.ts (one nested expression)
- __tests__/skopeo.test.ts (whitespace)

132 tests still pass.
@gustavolira gustavolira force-pushed the feat/install-dynamic-plugins-ts branch from 76ab6eb to 22df76d Compare April 16, 2026 13:08
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

…eSet

CodeQL keeps flagging `safeSet` because it analyzes the function in
isolation and does not see the `FORBIDDEN_KEYS` check at the call site
in `deepMerge`. `Object.defineProperty` is already prototype-safe (it
bypasses the `__proto__` setter and writes an own descriptor), but the
analyzer wants the guard at the point of write.

Add the `FORBIDDEN_KEYS.has(key)` check inside `safeSet` itself.
Belt-and-suspenders: still safe even if a future caller forgets the
guard, and the static analysis is now happy.

132 tests still pass.
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

…ve complexity

Sonar flagged `runInstaller` at cognitive complexity 24 (limit 15). The
function was doing too many things in one body: env-var-driven catalog
extraction, config file load with two short-circuit cases, include path
resolution + DPDY substitution, plugin merge across two levels, hash
computation, and the install orchestration.

Pull the discrete phases into well-named helpers so `runInstaller`
reads as a top-level outline:

- `maybeExtractCatalogIndex` — `CATALOG_INDEX_IMAGE` extraction
- `loadDynamicPluginsConfig` — read + parse + the two short-circuit cases
- `loadAllPlugins` — merge across includes + main file + hash computation
- `resolveIncludes` — include-path resolution + DPDY placeholder swap

`runInstaller` itself is now a flat sequence with one early-return; well
under the 15-point threshold. No behaviour change. 132 tests still pass.
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

@gustavolira
Copy link
Copy Markdown
Member Author

@jonkoops tyvm for the review, addressed pretty much all of them, left inline comments on the two I didn't apply

@gustavolira gustavolira requested a review from jonkoops April 16, 2026 13:22
@github-actions
Copy link
Copy Markdown
Contributor

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

Stan Lewis benchmarked the full-install case (empty dynamic-plugins-root)
at TS 71.4s vs Python 63.5s. The gap is `npm pack` + tar extraction —
node-tar is pure JS while Python's `tarfile` is C-backed, so per-package
extraction is slower.

The earlier perf fixes (skopeo cache, no-op pre-pass) targeted the
restart path; they don't help when every plugin has to be downloaded.
NPM installs were sequential by design to avoid registry throttling.

Add `getNpmWorkers()` (default 3, capped lower than OCI's 6 because
the public NPM registry and the shared `~/.npm/_cacache` don't tolerate
heavy concurrency) and route NPM installs through `mapConcurrent`
instead of a serial loop. Also apply the same `definitelyNoOp` pre-pass
that OCI gained, so the steady-state restart path stays just-as-fast.

Override with `DYNAMIC_PLUGINS_NPM_WORKERS=1` to restore the original
sequential behaviour if a deployment needs to.

Local benchmark (30 local packages, ~100KB each, fresh dynamic-plugins-root,
3 runs averaged):

  TS sequential (NPM_WORKERS=1)  8.57s wall  (9% slower than Python)
  TS parallel (default 3)        3.89s wall  (~2x faster than Python)
  Python (always sequential)     7.80s wall  (baseline)

136 tests pass (+4 for getNpmWorkers).
@github-actions
Copy link
Copy Markdown
Contributor

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

…ts on npm pack

Two small follow-ups stacked on the parallel-NPM commit:

1. Block lifecycle hooks during `npm pack`
   Add `--ignore-scripts` to the `npm pack` invocation. Dynamic plugins
   are not expected to ship build steps that need to run at install
   time, and skipping the `preinstall`/`prepack`/`prepare` hooks
   removes a code-execution-on-install attack surface (a malicious or
   compromised package could otherwise run arbitrary code in the init
   container as part of being unpacked). Also shaves a fork+exec per
   package off the wall clock.

2. Minify the production bundle (with external sourcemap)
   esbuild now emits a minified `dist/install-dynamic-plugins.cjs`
   (~221 KB, was ~422 KB) plus a separate `dist/install-dynamic-plugins.cjs.map`
   for debugging. The sourcemap is not committed (`.gitignore` already
   restricts the tracked dist/ to just the .cjs) and is not copied into
   the runtime container by the Containerfile, so there's no production
   bloat. Cold-start parse cost drops accordingly — most relevant for
   init container startup which runs once per pod restart.

Local benchmark, 30 local NPM packages, fresh dynamic-plugins-root,
5 runs averaged:

  TS sequential (NPM_WORKERS=1)  8.54s wall  (was 8.57s)
  TS parallel (default 3)        3.82s wall  (was 3.99s, ~4% faster)
  Python (sequential)            8.40s wall

Difference is small in steady-state hot-cache benches; the real win is
the security hardening from #1 and the cold-start parse-cost reduction
from #2 (init container starts on a cold OS page cache).

136 tests still pass.
…ll cleanups

Eight items from the local self-review on redhat-developer#4574:

1. Extract `runInstallPipeline` so `installOci` and `installNpm` no
   longer share ~45 lines of copy-paste body. The pre-pass loop, the
   `mapConcurrent` invocation, and the outcome-handling loop now live
   in one place; `installOci` / `installNpm` are thin shims that pass
   in the worker count, the per-plugin install function, and the label.

2. New `mergeConfigSafely` helper that wraps the
   `isPlainObject + try/catch deepMerge` pattern that appeared in four
   different places. Returns false on conflict so the caller can skip
   the "Installed" success log.

3. `definitelyNoOp` is now a type guard
   (`plugin is Plugin & { plugin_hash: string }`) instead of a plain
   `boolean` predicate. The `as string` cast on `installed.delete`
   inside the consequent is gone.

4. `definitelyNoOp` uses `PullPolicy.ALWAYS` / `PullPolicy.IF_NOT_PRESENT`
   instead of the bare `'Always'` / `'IfNotPresent'` strings — matches
   what the rest of the codebase does and would survive a future enum
   value change.

5. Drop the dead inner short-circuit in `installNpmPlugin`. The pre-pass
   in `runInstallPipeline` already filters definitely-no-op plugins, so
   the `installed.has(hash) && !force && pullPolicy !== ALWAYS` block
   was unreachable under the normal call path. Updated the doc comment
   to reflect the bounded-parallel reality (no longer "kept sequential
   to avoid registry throttling").

6. New `LATEST_TAG_MARKER` constant in `types.ts` for the `:latest!`
   suffix that was hard-coded in `installer-oci.ts` and `index.ts`.
   Single source of truth if the OCI tag convention ever shifts.

7. `isAlreadyInstalled` (oci): drop the unused `_plugin` parameter and
   include the package name in the "already installed" / "digest
   unchanged" log lines so an operator can correlate the message with
   the right plugin in an init-container log.

8. The "already installed" log format is now consistent across both
   the pre-pass (already had it) and the OCI inner check (used to be
   nameless): `\t==> <package>: already installed, skipping`.

Net diff: -77 lines in `index.ts`, no behaviour change. 136 tests pass.
@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

…ognitive complexity

Sonar flagged `runInstallPipeline` at cognitive complexity 24 (limit 15)
right after the previous consolidation commit moved the per-category
loops into one place.

Pull the two largest blocks out as their own helpers:

- `partitionDefinitelyNoOp(plugins, installed, globalConfig, errors)` —
  the synchronous pre-pass that drops definitely-no-op plugins and
  returns the remaining `needsWork` list.
- `applyInstallOutcomes(results, globalConfig, errors)` — the loop that
  records errors, merges `pluginConfig` into the global config, and
  logs success lines.

`runInstallPipeline` is now just the four-step outline (early-exit on
empty list, partition, log + run, drain outcomes); cognitive complexity
falls well under 15 with no behaviour change. 136 tests still pass.
@sonarqubecloud
Copy link
Copy Markdown

@github-actions
Copy link
Copy Markdown
Contributor

The container image build workflow finished with status: cancelled.

@github-actions
Copy link
Copy Markdown
Contributor

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

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