@red-hat-developer-hub/cli-module-install-dynamic-plugins 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +126 -0
  3. package/bin/install-dynamic-plugins +32 -0
  4. package/dist/catalog-index.cjs.js +242 -0
  5. package/dist/catalog-index.cjs.js.map +1 -0
  6. package/dist/command.cjs.js +12 -0
  7. package/dist/command.cjs.js.map +1 -0
  8. package/dist/concurrency.cjs.js +86 -0
  9. package/dist/concurrency.cjs.js.map +1 -0
  10. package/dist/errors.cjs.js +11 -0
  11. package/dist/errors.cjs.js.map +1 -0
  12. package/dist/image-cache.cjs.js +116 -0
  13. package/dist/image-cache.cjs.js.map +1 -0
  14. package/dist/image-resolver.cjs.js +24 -0
  15. package/dist/image-resolver.cjs.js.map +1 -0
  16. package/dist/index.cjs.js +20 -0
  17. package/dist/index.cjs.js.map +1 -0
  18. package/dist/index.d.ts +12 -0
  19. package/dist/installer-npm.cjs.js +111 -0
  20. package/dist/installer-npm.cjs.js.map +1 -0
  21. package/dist/installer-oci.cjs.js +106 -0
  22. package/dist/installer-oci.cjs.js.map +1 -0
  23. package/dist/installer.cjs.js +426 -0
  24. package/dist/installer.cjs.js.map +1 -0
  25. package/dist/integrity.cjs.js +79 -0
  26. package/dist/integrity.cjs.js.map +1 -0
  27. package/dist/lock-file.cjs.js +100 -0
  28. package/dist/lock-file.cjs.js.map +1 -0
  29. package/dist/log.cjs.js +9 -0
  30. package/dist/log.cjs.js.map +1 -0
  31. package/dist/merger.cjs.js +333 -0
  32. package/dist/merger.cjs.js.map +1 -0
  33. package/dist/npm-key.cjs.js +44 -0
  34. package/dist/npm-key.cjs.js.map +1 -0
  35. package/dist/oci-key.cjs.js +102 -0
  36. package/dist/oci-key.cjs.js.map +1 -0
  37. package/dist/package.json.cjs.js +104 -0
  38. package/dist/package.json.cjs.js.map +1 -0
  39. package/dist/plugin-hash.cjs.js +85 -0
  40. package/dist/plugin-hash.cjs.js.map +1 -0
  41. package/dist/run.cjs.js +37 -0
  42. package/dist/run.cjs.js.map +1 -0
  43. package/dist/skopeo.cjs.js +87 -0
  44. package/dist/skopeo.cjs.js.map +1 -0
  45. package/dist/tar-extract.cjs.js +155 -0
  46. package/dist/tar-extract.cjs.js.map +1 -0
  47. package/dist/types.cjs.js +45 -0
  48. package/dist/types.cjs.js.map +1 -0
  49. package/dist/util.cjs.js +56 -0
  50. package/dist/util.cjs.js.map +1 -0
  51. package/dist/which.cjs.js +45 -0
  52. package/dist/which.cjs.js.map +1 -0
  53. package/package.json +68 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @red-hat-developer-hub/cli-module-install-dynamic-plugins
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2d59f34: Initial release. TypeScript/Node.js port of the RHDH init-container installer (originally Python; see [redhat-developer/rhdh#4574](https://github.com/redhat-developer/rhdh/pull/4574)), packaged as a Backstage CLI module. The `install` command is registered through `createCliModule` so the package is auto-discovered by `backstage-cli` when listed as a dependency. The package also ships a self-contained esbuild bundle as its `bin`, so direct `npx install-dynamic-plugins <dir>` invocations (and RHDH's init-container `COPY` of the `.cjs`) stay fast and don't require `@backstage/cli-node` at runtime. Env vars, on-disk layout, `plugin-hash` format, and tar/OCI security guards are byte-compatible with the previous Python implementation.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # cli-module-install-dynamic-plugins
2
+
3
+ Backstage CLI module that downloads, extracts, and configures RHDH dynamic plugins listed in a `dynamic-plugins.yaml` file.
4
+
5
+ This package replaces the previous Python implementation (`install-dynamic-plugins.py`) with a TypeScript/Node.js implementation. The runtime contract — input config, output `app-config.dynamic-plugins.yaml`, on-disk layout, hash-based change detection, lock file — is **unchanged**.
6
+
7
+ The package has two invocation paths, both running the same `installer.ts` pipeline:
8
+
9
+ - **`bin/install-dynamic-plugins` → fast-path** that loads `dist/installer.cjs.js` directly. Direct `npx install-dynamic-plugins` and any host that resolves the bin via `node_modules/.bin/...` hits this path — bypasses `@backstage/cli-node`'s `runCliModule` dispatch (~80 ms saved on cold start).
10
+ - **`main: dist/index.cjs.js` → `createCliModule(...)`**, exposed for `backstage-cli` discovery. When a host project lists this package as a dependency, `backstage-cli install <dynamic-plugins-root>` is registered automatically.
11
+
12
+ ## Usage
13
+
14
+ ### Direct (bundled bin)
15
+
16
+ ```sh
17
+ npx @red-hat-developer-hub/cli-module-install-dynamic-plugins ./dynamic-plugins-root
18
+ ```
19
+
20
+ Or install globally:
21
+
22
+ ```sh
23
+ npm install -g @red-hat-developer-hub/cli-module-install-dynamic-plugins
24
+ install-dynamic-plugins ./dynamic-plugins-root
25
+ ```
26
+
27
+ ### Via `backstage-cli` discovery
28
+
29
+ When the package is a dependency of a project that uses `backstage-cli`, the `install` command is registered automatically:
30
+
31
+ ```sh
32
+ backstage-cli install ./dynamic-plugins-root
33
+ ```
34
+
35
+ Runtime requirements: Node.js 22 or 24, and `skopeo` on `PATH` for OCI plugin support. `npm` is also expected on `PATH` for NPM-sourced plugins.
36
+
37
+ ## How RHDH consumes it
38
+
39
+ The init container invokes the wrapper `install-dynamic-plugins.sh /dynamic-plugins-root`, which delegates to the bin installed via `yarn install` from this package (see [redhat-developer/rhdh#4908](https://github.com/redhat-developer/rhdh/pull/4908)). Node.js is already present in the runtime image (it runs the Backstage backend), and `skopeo` is installed for OCI inspection — no new system packages are required.
40
+
41
+ ## Architecture
42
+
43
+ ```
44
+ src/
45
+ ├── index.ts # createCliModule default export (backstage-cli discovery)
46
+ ├── command.ts # loader for the `install` command (used by cli-module)
47
+ ├── installer.ts # install pipeline + main() — the single source of truth
48
+ ├── log.ts # uniform stdout logger
49
+ ├── errors.ts # InstallException
50
+ ├── types.ts # PluginSpec / Plugin / PluginMap / PullPolicy + constants
51
+ ├── util.ts # shared helpers (fileExists, isInside, isPlainObject, tar filters)
52
+ ├── run.ts # subprocess wrapper with structured errors
53
+ ├── concurrency.ts # Semaphore + mapConcurrent + getWorkers()
54
+ ├── which.ts # PATH lookup (no `which` dep)
55
+ ├── skopeo.ts # Skopeo wrapper with promise-based inspect cache
56
+ ├── image-resolver.ts # registry.access.redhat.com → quay.io fallback
57
+ ├── image-cache.ts # OciImageCache — share OCI tarballs across plugins
58
+ ├── tar-extract.ts # streaming OCI / NPM extraction with security checks
59
+ ├── npm-key.ts # NPM package-spec parsing
60
+ ├── oci-key.ts # OCI package-spec parsing + {{inherit}} + auto-path
61
+ ├── integrity.ts # streaming SRI integrity verification
62
+ ├── merger.ts # plugin merging + deep-merge with conflict detection
63
+ ├── plugin-hash.ts # hash for change-detection ("already installed?")
64
+ ├── installer-oci.ts # install one OCI plugin
65
+ ├── installer-npm.ts # install one NPM (or local) plugin
66
+ ├── catalog-index.ts # CATALOG_INDEX_IMAGE extraction
67
+ └── lock-file.ts # exclusive lock + SIGTERM cleanup
68
+ ```
69
+
70
+ ### Concurrency strategy (resource-conscious)
71
+
72
+ OCI plugin downloads are parallelized via `mapConcurrent`. NPM `npm pack` calls stay sequential because the upstream npm registry throttles parallel fetches.
73
+
74
+ The default worker count comes from `getWorkers()`:
75
+
76
+ ```
77
+ Math.max(1, Math.min(Math.floor(availableParallelism() / 2), 6))
78
+ ```
79
+
80
+ `availableParallelism()` honours cgroup CPU limits, so init containers in OpenShift won't try to use 16 workers on a 0.5 CPU pod. Override with `DYNAMIC_PLUGINS_WORKERS=<n>`.
81
+
82
+ ### Memory budget
83
+
84
+ All tar extraction is streaming via `node-tar` — large layers never load into RAM. SHA verification streams chunks through `node:crypto`. A typical 10-plugin run sits around 20–80 MB peak RSS, comfortably below an init-container memory limit of 512 Mi.
85
+
86
+ ### Security checks (parity with the previous Python script)
87
+
88
+ | Check | Source |
89
+ | --------------------------------------------------------------------- | ------------------------------------ |
90
+ | Path-traversal in plugin path (`..`, absolute paths) | `tar-extract.ts` |
91
+ | Per-entry size cap (zip bomb) — `MAX_ENTRY_SIZE`, default 20 MB | `tar-extract.ts`, `catalog-index.ts` |
92
+ | Symlink / hardlink target must stay inside destination | `tar-extract.ts` |
93
+ | Reject device files / FIFOs / unknown entry types | `tar-extract.ts` |
94
+ | `package/` prefix enforced for NPM tarballs | `tar-extract.ts` |
95
+ | SRI integrity verification (`sha256` / `sha384` / `sha512`) | `integrity.ts` |
96
+ | Registry fallback: `registry.access.redhat.com/rhdh` → `quay.io/rhdh` | `image-resolver.ts` |
97
+
98
+ ## Environment variables
99
+
100
+ | Variable | Default | Purpose |
101
+ | --------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
102
+ | `MAX_ENTRY_SIZE` | `20000000` | Per-entry byte limit when extracting tarballs |
103
+ | `SKIP_INTEGRITY_CHECK` | `false` | When `true`, skip the SRI integrity check for remote NPM packages |
104
+ | `CATALOG_INDEX_IMAGE` | _(unset)_ | OCI image to extract `dynamic-plugins.default.yaml` and catalog entities from |
105
+ | `CATALOG_ENTITIES_EXTRACT_DIR` | `$TMPDIR/extensions` | Where to extract `catalog-entities/` from the catalog-index image |
106
+ | `DYNAMIC_PLUGINS_WORKERS` | `auto` | Worker count override for parallel OCI downloads (`auto` uses `availableParallelism()/2`, capped at 6) |
107
+ | `DYNAMIC_PLUGINS_LOCK_TIMEOUT_MS` | `600000` (10 min) | Max time to wait for the lock file before aborting with an error |
108
+
109
+ ## Development
110
+
111
+ From the workspace root:
112
+
113
+ ```sh
114
+ yarn install
115
+ yarn tsc # type-check
116
+ yarn test # Jest unit tests (166 tests)
117
+ yarn workspace @red-hat-developer-hub/cli-module-install-dynamic-plugins build
118
+ ```
119
+
120
+ `yarn build` runs `backstage-cli package build` and emits the unbundled `dist/*.cjs.js` + type declarations. The package is published as-is; no committed bundle.
121
+
122
+ ## Compatibility notes
123
+
124
+ - The **input contract** matches the previous Python script exactly: same `dynamic-plugins.yaml` schema (`includes`, `plugins`, `package`, `pluginConfig`, `disabled`, `pullPolicy`, `forceDownload`, `integrity`).
125
+ - The **output contract** matches: same `app-config.dynamic-plugins.yaml`, same plugin directory layout, same `dynamic-plugin-config.hash` / `dynamic-plugin-image.hash` files.
126
+ - `{{inherit}}` semantics, OCI path auto-detection, registry fallback, integrity algorithms, lock-file behaviour are preserved.
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Copyright Red Hat, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+
18
+ const path = require('node:path');
19
+ const fs = require('node:fs');
20
+
21
+ /* eslint-disable-next-line no-restricted-syntax */
22
+ const isLocal = fs.existsSync(path.resolve(__dirname, '../src'));
23
+
24
+ if (isLocal) {
25
+ require('@backstage/cli-node/config/nodeTransform.cjs');
26
+ }
27
+
28
+ const { runCliModule } = require('@backstage/cli-node');
29
+ const cliModule = require(isLocal ? '../src/index' : '..').default;
30
+ const pkg = require('../package.json');
31
+
32
+ runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ var fs = require('node:fs/promises');
4
+ var os = require('node:os');
5
+ var path = require('node:path');
6
+ var tar = require('tar');
7
+ var errors = require('./errors.cjs.js');
8
+ var log = require('./log.cjs.js');
9
+ var imageResolver = require('./image-resolver.cjs.js');
10
+ var types = require('./types.cjs.js');
11
+ var util = require('./util.cjs.js');
12
+
13
+ function _interopNamespaceCompat(e) {
14
+ if (e && typeof e === 'object' && 'default' in e) return e;
15
+ var n = Object.create(null);
16
+ if (e) {
17
+ Object.keys(e).forEach(function (k) {
18
+ if (k !== 'default') {
19
+ var d = Object.getOwnPropertyDescriptor(e, k);
20
+ Object.defineProperty(n, k, d.get ? d : {
21
+ enumerable: true,
22
+ get: function () { return e[k]; }
23
+ });
24
+ }
25
+ });
26
+ }
27
+ n.default = e;
28
+ return Object.freeze(n);
29
+ }
30
+
31
+ var fs__namespace = /*#__PURE__*/_interopNamespaceCompat(fs);
32
+ var os__namespace = /*#__PURE__*/_interopNamespaceCompat(os);
33
+ var path__namespace = /*#__PURE__*/_interopNamespaceCompat(path);
34
+ var tar__namespace = /*#__PURE__*/_interopNamespaceCompat(tar);
35
+
36
+ async function extractCatalogIndex(skopeo, image, mountDir, entitiesDir) {
37
+ log.log(`
38
+ ======= Extracting catalog index from ${image}`);
39
+ const tempDir = path__namespace.join(mountDir, ".catalog-index-temp");
40
+ await fs__namespace.mkdir(tempDir, { recursive: true });
41
+ const tempDirAbs = path__namespace.resolve(tempDir);
42
+ await extractCatalogIndexLayers(skopeo, image, tempDirAbs);
43
+ const dpdy = path__namespace.join(tempDir, types.DPDY_FILENAME);
44
+ if (!await util.fileExists(dpdy)) {
45
+ throw new errors.InstallException(
46
+ `dynamic-plugins.default.yaml not found in ${image}`
47
+ );
48
+ }
49
+ log.log(" ==> Extracted dynamic-plugins.default.yaml");
50
+ for (const sub of [
51
+ "catalog-entities/extensions",
52
+ "catalog-entities/marketplace"
53
+ ]) {
54
+ const src = path__namespace.join(tempDir, sub);
55
+ if (await util.fileExists(src)) {
56
+ await fs__namespace.mkdir(entitiesDir, { recursive: true });
57
+ const dst = path__namespace.join(entitiesDir, "catalog-entities");
58
+ await fs__namespace.rm(dst, { recursive: true, force: true });
59
+ await copyDir(src, dst);
60
+ log.log(` ==> Extracted catalog entities from ${sub}`);
61
+ break;
62
+ }
63
+ }
64
+ return dpdy;
65
+ }
66
+ async function extractCatalogIndexLayers(skopeo, image, destDirAbs) {
67
+ const resolved = await imageResolver.resolveImage(skopeo, image);
68
+ const workDir = await fs__namespace.mkdtemp(
69
+ path__namespace.join(os__namespace.tmpdir(), "rhdh-catalog-index-")
70
+ );
71
+ try {
72
+ const url = resolved.startsWith(types.DOCKER_PROTO) ? resolved : `${types.DOCKER_PROTO}${resolved.replace(types.OCI_PROTO, "")}`;
73
+ const localDir = path__namespace.join(workDir, "idx");
74
+ log.log(" ==> Downloading catalog index image");
75
+ await skopeo.copy(url, `dir:${localDir}`);
76
+ const manifestPath = path__namespace.join(localDir, "manifest.json");
77
+ if (!await util.fileExists(manifestPath)) {
78
+ throw new errors.InstallException(
79
+ `manifest.json not found in catalog index image ${image}`
80
+ );
81
+ }
82
+ const manifest = JSON.parse(
83
+ await fs__namespace.readFile(manifestPath, "utf8")
84
+ );
85
+ const layers = manifest.layers ?? [];
86
+ let pending = null;
87
+ for (const layer of layers) {
88
+ if (pending) break;
89
+ const digest = layer.digest;
90
+ if (!digest) continue;
91
+ const [, fname] = digest.split(":");
92
+ if (!fname) continue;
93
+ const layerPath = path__namespace.join(localDir, fname);
94
+ if (!await util.fileExists(layerPath)) continue;
95
+ await tar__namespace.x({
96
+ file: layerPath,
97
+ cwd: destDirAbs,
98
+ preservePaths: false,
99
+ // The filter captures `pending` (a single-write latch) and runs
100
+ // synchronously inside the awaited tar.x call — iterations are
101
+ // serialised, so the closure-in-loop hazard the rule guards against
102
+ // does not apply.
103
+ // eslint-disable-next-line no-loop-func
104
+ filter: (filePath, entry) => {
105
+ if (pending) return false;
106
+ const stat = entry;
107
+ if (stat.size > types.MAX_ENTRY_SIZE) {
108
+ pending = new errors.InstallException(`Zip bomb detected in ${filePath}`);
109
+ return false;
110
+ }
111
+ if (stat.type === "SymbolicLink" || stat.type === "Link") {
112
+ const linkTarget = path__namespace.resolve(destDirAbs, stat.linkpath ?? "");
113
+ if (!util.isInside(linkTarget, destDirAbs)) return false;
114
+ }
115
+ const memberPath = path__namespace.resolve(destDirAbs, filePath);
116
+ if (!util.isInside(memberPath, destDirAbs)) return false;
117
+ return util.isAllowedEntryType(stat.type);
118
+ }
119
+ });
120
+ }
121
+ if (pending) throw pending;
122
+ } finally {
123
+ await fs__namespace.rm(workDir, { recursive: true, force: true });
124
+ }
125
+ }
126
+ async function extractExtraCatalogIndex(skopeo, image, subdirectory, parentDir, previouslyUsedBy) {
127
+ if (!isSafeSubdirectoryName(subdirectory)) {
128
+ throw new errors.InstallException(
129
+ `Refusing to extract extra catalog index into unsafe subdirectory '${subdirectory}'`
130
+ );
131
+ }
132
+ log.log(
133
+ `
134
+ ======= Extracting extra catalog index '${subdirectory}' from ${image}`
135
+ );
136
+ if (previouslyUsedBy) {
137
+ log.log(
138
+ ` ==> WARNING: Subdirectory '${subdirectory}' was already used by '${previouslyUsedBy}'. The previous extraction will be overwritten.`
139
+ );
140
+ }
141
+ const workDir = await fs__namespace.mkdtemp(
142
+ path__namespace.join(os__namespace.tmpdir(), "rhdh-extra-catalog-index-")
143
+ );
144
+ try {
145
+ const extractedDir = path__namespace.join(workDir, "extracted");
146
+ await fs__namespace.mkdir(extractedDir, { recursive: true });
147
+ await extractCatalogIndexLayers(skopeo, image, extractedDir);
148
+ const subdirParent = path__namespace.join(parentDir, subdirectory);
149
+ log.log(` ==> Extracting extensions catalog entities to ${subdirParent}`);
150
+ let sourceDir = null;
151
+ for (const sub of [
152
+ "catalog-entities/extensions",
153
+ "catalog-entities/marketplace"
154
+ ]) {
155
+ const candidate = path__namespace.join(extractedDir, sub);
156
+ if (await util.fileExists(candidate)) {
157
+ sourceDir = candidate;
158
+ break;
159
+ }
160
+ }
161
+ if (!sourceDir) {
162
+ log.log(
163
+ ` ==> WARNING: Extra catalog index image ${image} does not have neither 'catalog-entities/extensions/' nor 'catalog-entities/marketplace/' directory`
164
+ );
165
+ return;
166
+ }
167
+ await fs__namespace.mkdir(subdirParent, { recursive: true });
168
+ const dst = path__namespace.join(subdirParent, "catalog-entities");
169
+ await fs__namespace.rm(dst, { recursive: true, force: true });
170
+ await copyDir(sourceDir, dst);
171
+ log.log(
172
+ ` ==> Successfully extracted extensions catalog entities from extra index image to ${subdirParent}`
173
+ );
174
+ } finally {
175
+ await fs__namespace.rm(workDir, { recursive: true, force: true });
176
+ }
177
+ }
178
+ function imageRefToSubdirectory(imageRef) {
179
+ return imageRef.replaceAll(/[/:@]/g, "_");
180
+ }
181
+ function parseExtraCatalogIndexImages(raw) {
182
+ const out = [];
183
+ for (const rawEntry of raw.split(",")) {
184
+ const entry = rawEntry.trim();
185
+ if (!entry) continue;
186
+ let name;
187
+ let imageRef;
188
+ const eq = entry.indexOf("=");
189
+ if (eq === -1) {
190
+ imageRef = entry;
191
+ name = imageRefToSubdirectory(imageRef);
192
+ } else {
193
+ name = entry.slice(0, eq).trim();
194
+ imageRef = entry.slice(eq + 1).trim();
195
+ }
196
+ if (!imageRef) {
197
+ log.log(
198
+ `WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with empty image reference: '${entry}'`
199
+ );
200
+ continue;
201
+ }
202
+ if (!isSafeSubdirectoryName(name)) {
203
+ log.log(
204
+ String.raw`WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with unsafe subdirectory name '${name}' in '${entry}'. Names must be non-empty and must not contain '/', '\\', or '..'.`
205
+ );
206
+ continue;
207
+ }
208
+ out.push([name, imageRef]);
209
+ }
210
+ return out;
211
+ }
212
+ function isSafeSubdirectoryName(name) {
213
+ if (!name || name === "." || name === "..") return false;
214
+ return !/[/\\]/.test(name);
215
+ }
216
+ async function cleanupCatalogIndexTemp(mountDir) {
217
+ await fs__namespace.rm(path__namespace.join(mountDir, ".catalog-index-temp"), {
218
+ recursive: true,
219
+ force: true
220
+ });
221
+ }
222
+ async function copyDir(src, dst) {
223
+ await fs__namespace.mkdir(dst, { recursive: true });
224
+ const entries = await fs__namespace.readdir(src, { withFileTypes: true });
225
+ for (const entry of entries) {
226
+ const s = path__namespace.join(src, entry.name);
227
+ const d = path__namespace.join(dst, entry.name);
228
+ if (entry.isDirectory()) {
229
+ await copyDir(s, d);
230
+ } else if (entry.isFile()) {
231
+ await fs__namespace.copyFile(s, d);
232
+ }
233
+ }
234
+ }
235
+
236
+ exports.cleanupCatalogIndexTemp = cleanupCatalogIndexTemp;
237
+ exports.extractCatalogIndex = extractCatalogIndex;
238
+ exports.extractCatalogIndexLayers = extractCatalogIndexLayers;
239
+ exports.extractExtraCatalogIndex = extractExtraCatalogIndex;
240
+ exports.imageRefToSubdirectory = imageRefToSubdirectory;
241
+ exports.parseExtraCatalogIndexImages = parseExtraCatalogIndexImages;
242
+ //# sourceMappingURL=catalog-index.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"catalog-index.cjs.js","sources":["../src/catalog-index.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport * as fs from 'node:fs/promises';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\nimport * as tar from 'tar';\nimport { InstallException } from './errors';\nimport { log } from './log';\nimport { resolveImage } from './image-resolver';\nimport { type Skopeo } from './skopeo';\nimport {\n DOCKER_PROTO,\n DPDY_FILENAME,\n MAX_ENTRY_SIZE,\n OCI_PROTO,\n} from './types';\nimport { fileExists, isAllowedEntryType, isInside } from './util';\n\ntype OciManifest = {\n layers?: Array<{ digest: string }>;\n};\n\n/**\n * Extract the plugin catalog index OCI image (when `CATALOG_INDEX_IMAGE` is\n * set). Produces:\n * - `<mountDir>/.catalog-index-temp/dynamic-plugins.default.yaml`\n * - `<entitiesDir>/catalog-entities/` (if present in the image)\n *\n * Returns the absolute path to the extracted `dynamic-plugins.default.yaml`,\n * which the caller will substitute into `includes[]`.\n */\nexport async function extractCatalogIndex(\n skopeo: Skopeo,\n image: string,\n mountDir: string,\n entitiesDir: string,\n): Promise<string> {\n log(`\\n======= Extracting catalog index from ${image}`);\n const tempDir = path.join(mountDir, '.catalog-index-temp');\n await fs.mkdir(tempDir, { recursive: true });\n const tempDirAbs = path.resolve(tempDir);\n\n await extractCatalogIndexLayers(skopeo, image, tempDirAbs);\n\n const dpdy = path.join(tempDir, DPDY_FILENAME);\n if (!(await fileExists(dpdy))) {\n throw new InstallException(\n `dynamic-plugins.default.yaml not found in ${image}`,\n );\n }\n log('\\t==> Extracted dynamic-plugins.default.yaml');\n\n // Also surface catalog entities if present.\n for (const sub of [\n 'catalog-entities/extensions',\n 'catalog-entities/marketplace',\n ]) {\n const src = path.join(tempDir, sub);\n if (await fileExists(src)) {\n await fs.mkdir(entitiesDir, { recursive: true });\n const dst = path.join(entitiesDir, 'catalog-entities');\n await fs.rm(dst, { recursive: true, force: true });\n await copyDir(src, dst);\n log(`\\t==> Extracted catalog entities from ${sub}`);\n break;\n }\n }\n return dpdy;\n}\n\n/**\n * Pull an OCI image with `skopeo copy` and untar every layer into `destDirAbs`.\n * Shared by the primary `extractCatalogIndex` and the per-image\n * `extractExtraCatalogIndex` flows. Applies the same security filter as\n * `extractCatalogIndex` (per-entry size cap, path-traversal rejection,\n * link-target containment, allowed-type whitelist).\n */\nexport async function extractCatalogIndexLayers(\n skopeo: Skopeo,\n image: string,\n destDirAbs: string,\n): Promise<void> {\n const resolved = await resolveImage(skopeo, image);\n const workDir = await fs.mkdtemp(\n path.join(os.tmpdir(), 'rhdh-catalog-index-'),\n );\n try {\n const url = resolved.startsWith(DOCKER_PROTO)\n ? resolved\n : `${DOCKER_PROTO}${resolved.replace(OCI_PROTO, '')}`;\n const localDir = path.join(workDir, 'idx');\n log('\\t==> Downloading catalog index image');\n await skopeo.copy(url, `dir:${localDir}`);\n\n const manifestPath = path.join(localDir, 'manifest.json');\n if (!(await fileExists(manifestPath))) {\n throw new InstallException(\n `manifest.json not found in catalog index image ${image}`,\n );\n }\n\n const manifest = JSON.parse(\n await fs.readFile(manifestPath, 'utf8'),\n ) as OciManifest;\n const layers = manifest.layers ?? [];\n\n let pending: InstallException | null = null;\n for (const layer of layers) {\n if (pending) break;\n const digest = layer.digest;\n if (!digest) continue;\n const [, fname] = digest.split(':');\n if (!fname) continue;\n const layerPath = path.join(localDir, fname);\n if (!(await fileExists(layerPath))) continue;\n\n await tar.x({\n file: layerPath,\n cwd: destDirAbs,\n preservePaths: false,\n // The filter captures `pending` (a single-write latch) and runs\n // synchronously inside the awaited tar.x call — iterations are\n // serialised, so the closure-in-loop hazard the rule guards against\n // does not apply.\n // eslint-disable-next-line no-loop-func\n filter: (filePath, entry) => {\n if (pending) return false;\n const stat = entry as tar.ReadEntry;\n\n if (stat.size > MAX_ENTRY_SIZE) {\n pending = new InstallException(`Zip bomb detected in ${filePath}`);\n return false;\n }\n\n if (stat.type === 'SymbolicLink' || stat.type === 'Link') {\n const linkTarget = path.resolve(destDirAbs, stat.linkpath ?? '');\n if (!isInside(linkTarget, destDirAbs)) return false;\n }\n\n // Reject any entry that would resolve outside destDirAbs.\n const memberPath = path.resolve(destDirAbs, filePath);\n if (!isInside(memberPath, destDirAbs)) return false;\n\n return isAllowedEntryType(stat.type);\n },\n });\n }\n if (pending) throw pending;\n } finally {\n await fs.rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Extract an extra catalog index image (driven by `EXTRA_CATALOG_INDEX_IMAGES`).\n * Unlike `extractCatalogIndex`, this does NOT require a\n * `dynamic-plugins.default.yaml` — extra images contribute catalog entities\n * for the Extensions UI only.\n *\n * Writes catalog entities to `<parentDir>/<subdirectory>/catalog-entities`,\n * overwriting any prior content at that path. When the source image carries\n * neither `catalog-entities/extensions/` nor `catalog-entities/marketplace/`,\n * a warning is logged and the function returns without throwing.\n *\n * `previouslyUsedBy` should be the image ref that previously mapped to this\n * subdirectory name in the same `EXTRA_CATALOG_INDEX_IMAGES` invocation;\n * pass `null` on first use. When non-null, an overwrite warning is logged\n * AFTER the extraction header (matches the Python fix-up commit ordering).\n */\nexport async function extractExtraCatalogIndex(\n skopeo: Skopeo,\n image: string,\n subdirectory: string,\n parentDir: string,\n previouslyUsedBy: string | null,\n): Promise<void> {\n if (!isSafeSubdirectoryName(subdirectory)) {\n throw new InstallException(\n `Refusing to extract extra catalog index into unsafe subdirectory '${subdirectory}'`,\n );\n }\n log(\n `\\n======= Extracting extra catalog index '${subdirectory}' from ${image}`,\n );\n if (previouslyUsedBy) {\n log(\n `\\t==> WARNING: Subdirectory '${subdirectory}' was already used by '${previouslyUsedBy}'. ` +\n `The previous extraction will be overwritten.`,\n );\n }\n\n const workDir = await fs.mkdtemp(\n path.join(os.tmpdir(), 'rhdh-extra-catalog-index-'),\n );\n try {\n const extractedDir = path.join(workDir, 'extracted');\n await fs.mkdir(extractedDir, { recursive: true });\n await extractCatalogIndexLayers(skopeo, image, extractedDir);\n\n const subdirParent = path.join(parentDir, subdirectory);\n log(`\\t==> Extracting extensions catalog entities to ${subdirParent}`);\n\n let sourceDir: string | null = null;\n for (const sub of [\n 'catalog-entities/extensions',\n 'catalog-entities/marketplace',\n ]) {\n const candidate = path.join(extractedDir, sub);\n if (await fileExists(candidate)) {\n sourceDir = candidate;\n break;\n }\n }\n\n if (!sourceDir) {\n log(\n `\\t==> WARNING: Extra catalog index image ${image} does not have neither ` +\n `'catalog-entities/extensions/' nor 'catalog-entities/marketplace/' directory`,\n );\n return;\n }\n\n await fs.mkdir(subdirParent, { recursive: true });\n const dst = path.join(subdirParent, 'catalog-entities');\n await fs.rm(dst, { recursive: true, force: true });\n await copyDir(sourceDir, dst);\n log(\n `\\t==> Successfully extracted extensions catalog entities from extra index image to ${subdirParent}`,\n );\n } finally {\n await fs.rm(workDir, { recursive: true, force: true });\n }\n}\n\n/**\n * Convert an OCI image reference to a filesystem-safe subdirectory name by\n * replacing `/`, `:`, and `@` with `_`. Matches the Python\n * `image_ref_to_subdirectory` helper so the on-disk layout is identical\n * between the two implementations.\n */\nexport function imageRefToSubdirectory(imageRef: string): string {\n return imageRef.replaceAll(/[/:@]/g, '_');\n}\n\n/**\n * Parse the `EXTRA_CATALOG_INDEX_IMAGES` env var. Each comma-separated entry\n * is either a plain image reference (subdirectory auto-derived via\n * `imageRefToSubdirectory`) or `<name>=<image_ref>` (explicit subdirectory\n * name). Empty entries and empty image_refs are skipped with a warning —\n * the caller still consumes the rest of the list.\n */\nexport function parseExtraCatalogIndexImages(\n raw: string,\n): Array<[name: string, imageRef: string]> {\n const out: Array<[string, string]> = [];\n for (const rawEntry of raw.split(',')) {\n const entry = rawEntry.trim();\n if (!entry) continue;\n let name: string;\n let imageRef: string;\n const eq = entry.indexOf('=');\n if (eq === -1) {\n imageRef = entry;\n name = imageRefToSubdirectory(imageRef);\n } else {\n name = entry.slice(0, eq).trim();\n imageRef = entry.slice(eq + 1).trim();\n }\n if (!imageRef) {\n log(\n `WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with empty image reference: '${entry}'`,\n );\n continue;\n }\n if (!isSafeSubdirectoryName(name)) {\n log(\n String.raw`WARNING: Skipping EXTRA_CATALOG_INDEX_IMAGES entry with unsafe subdirectory name '${name}' in '${entry}'. Names must be non-empty and must not contain '/', '\\\\', or '..'.`,\n );\n continue;\n }\n out.push([name, imageRef]);\n }\n return out;\n}\n\n/**\n * Reject subdirectory names that are empty or could escape `<parentDir>` once\n * passed to `path.join` (path separators or `..` segments). Mirrors the\n * defensive check applied to plugin paths during tar extraction.\n */\nfunction isSafeSubdirectoryName(name: string): boolean {\n if (!name || name === '.' || name === '..') return false;\n return !/[/\\\\]/.test(name);\n}\n\nexport async function cleanupCatalogIndexTemp(mountDir: string): Promise<void> {\n await fs.rm(path.join(mountDir, '.catalog-index-temp'), {\n recursive: true,\n force: true,\n });\n}\n\nasync function copyDir(src: string, dst: string): Promise<void> {\n await fs.mkdir(dst, { recursive: true });\n const entries = await fs.readdir(src, { withFileTypes: true });\n for (const entry of entries) {\n const s = path.join(src, entry.name);\n const d = path.join(dst, entry.name);\n if (entry.isDirectory()) {\n await copyDir(s, d);\n } else if (entry.isFile()) {\n await fs.copyFile(s, d);\n }\n }\n}\n"],"names":["log","path","fs","DPDY_FILENAME","fileExists","InstallException","resolveImage","os","DOCKER_PROTO","OCI_PROTO","tar","MAX_ENTRY_SIZE","isInside","isAllowedEntryType"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,eAAsB,mBAAA,CACpB,MAAA,EACA,KAAA,EACA,QAAA,EACA,WAAA,EACiB;AACjB,EAAAA,OAAA,CAAI;AAAA,sCAAA,EAA2C,KAAK,CAAA,CAAE,CAAA;AACtD,EAAA,MAAM,OAAA,GAAUC,eAAA,CAAK,IAAA,CAAK,QAAA,EAAU,qBAAqB,CAAA;AACzD,EAAA,MAAMC,cAAG,KAAA,CAAM,OAAA,EAAS,EAAE,SAAA,EAAW,MAAM,CAAA;AAC3C,EAAA,MAAM,UAAA,GAAaD,eAAA,CAAK,OAAA,CAAQ,OAAO,CAAA;AAEvC,EAAA,MAAM,yBAAA,CAA0B,MAAA,EAAQ,KAAA,EAAO,UAAU,CAAA;AAEzD,EAAA,MAAM,IAAA,GAAOA,eAAA,CAAK,IAAA,CAAK,OAAA,EAASE,mBAAa,CAAA;AAC7C,EAAA,IAAI,CAAE,MAAMC,eAAA,CAAW,IAAI,CAAA,EAAI;AAC7B,IAAA,MAAM,IAAIC,uBAAA;AAAA,MACR,6CAA6C,KAAK,CAAA;AAAA,KACpD;AAAA,EACF;AACA,EAAAL,OAAA,CAAI,6CAA8C,CAAA;AAGlD,EAAA,KAAA,MAAW,GAAA,IAAO;AAAA,IAChB,6BAAA;AAAA,IACA;AAAA,GACF,EAAG;AACD,IAAA,MAAM,GAAA,GAAMC,eAAA,CAAK,IAAA,CAAK,OAAA,EAAS,GAAG,CAAA;AAClC,IAAA,IAAI,MAAMG,eAAA,CAAW,GAAG,CAAA,EAAG;AACzB,MAAA,MAAMF,cAAG,KAAA,CAAM,WAAA,EAAa,EAAE,SAAA,EAAW,MAAM,CAAA;AAC/C,MAAA,MAAM,GAAA,GAAMD,eAAA,CAAK,IAAA,CAAK,WAAA,EAAa,kBAAkB,CAAA;AACrD,MAAA,MAAMC,aAAA,CAAG,GAAG,GAAA,EAAK,EAAE,WAAW,IAAA,EAAM,KAAA,EAAO,MAAM,CAAA;AACjD,MAAA,MAAM,OAAA,CAAQ,KAAK,GAAG,CAAA;AACtB,MAAAF,OAAA,CAAI,CAAA,qCAAA,EAAyC,GAAG,CAAA,CAAE,CAAA;AAClD,MAAA;AAAA,IACF;AAAA,EACF;AACA,EAAA,OAAO,IAAA;AACT;AASA,eAAsB,yBAAA,CACpB,MAAA,EACA,KAAA,EACA,UAAA,EACe;AACf,EAAA,MAAM,QAAA,GAAW,MAAMM,0BAAA,CAAa,MAAA,EAAQ,KAAK,CAAA;AACjD,EAAA,MAAM,OAAA,GAAU,MAAMJ,aAAA,CAAG,OAAA;AAAA,IACvBD,eAAA,CAAK,IAAA,CAAKM,aAAA,CAAG,MAAA,IAAU,qBAAqB;AAAA,GAC9C;AACA,EAAA,IAAI;AACF,IAAA,MAAM,GAAA,GAAM,QAAA,CAAS,UAAA,CAAWC,kBAAY,CAAA,GACxC,QAAA,GACA,CAAA,EAAGA,kBAAY,CAAA,EAAG,QAAA,CAAS,OAAA,CAAQC,eAAA,EAAW,EAAE,CAAC,CAAA,CAAA;AACrD,IAAA,MAAM,QAAA,GAAWR,eAAA,CAAK,IAAA,CAAK,OAAA,EAAS,KAAK,CAAA;AACzC,IAAAD,OAAA,CAAI,sCAAuC,CAAA;AAC3C,IAAA,MAAM,MAAA,CAAO,IAAA,CAAK,GAAA,EAAK,CAAA,IAAA,EAAO,QAAQ,CAAA,CAAE,CAAA;AAExC,IAAA,MAAM,YAAA,GAAeC,eAAA,CAAK,IAAA,CAAK,QAAA,EAAU,eAAe,CAAA;AACxD,IAAA,IAAI,CAAE,MAAMG,eAAA,CAAW,YAAY,CAAA,EAAI;AACrC,MAAA,MAAM,IAAIC,uBAAA;AAAA,QACR,kDAAkD,KAAK,CAAA;AAAA,OACzD;AAAA,IACF;AAEA,IAAA,MAAM,WAAW,IAAA,CAAK,KAAA;AAAA,MACpB,MAAMH,aAAA,CAAG,QAAA,CAAS,YAAA,EAAc,MAAM;AAAA,KACxC;AACA,IAAA,MAAM,MAAA,GAAS,QAAA,CAAS,MAAA,IAAU,EAAC;AAEnC,IAAA,IAAI,OAAA,GAAmC,IAAA;AACvC,IAAA,KAAA,MAAW,SAAS,MAAA,EAAQ;AAC1B,MAAA,IAAI,OAAA,EAAS;AACb,MAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,MAAA,IAAI,CAAC,MAAA,EAAQ;AACb,MAAA,MAAM,GAAG,KAAK,CAAA,GAAI,MAAA,CAAO,MAAM,GAAG,CAAA;AAClC,MAAA,IAAI,CAAC,KAAA,EAAO;AACZ,MAAA,MAAM,SAAA,GAAYD,eAAA,CAAK,IAAA,CAAK,QAAA,EAAU,KAAK,CAAA;AAC3C,MAAA,IAAI,CAAE,MAAMG,eAAA,CAAW,SAAS,CAAA,EAAI;AAEpC,MAAA,MAAMM,eAAI,CAAA,CAAE;AAAA,QACV,IAAA,EAAM,SAAA;AAAA,QACN,GAAA,EAAK,UAAA;AAAA,QACL,aAAA,EAAe,KAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAMf,MAAA,EAAQ,CAAC,QAAA,EAAU,KAAA,KAAU;AAC3B,UAAA,IAAI,SAAS,OAAO,KAAA;AACpB,UAAA,MAAM,IAAA,GAAO,KAAA;AAEb,UAAA,IAAI,IAAA,CAAK,OAAOC,oBAAA,EAAgB;AAC9B,YAAA,OAAA,GAAU,IAAIN,uBAAA,CAAiB,CAAA,qBAAA,EAAwB,QAAQ,CAAA,CAAE,CAAA;AACjE,YAAA,OAAO,KAAA;AAAA,UACT;AAEA,UAAA,IAAI,IAAA,CAAK,IAAA,KAAS,cAAA,IAAkB,IAAA,CAAK,SAAS,MAAA,EAAQ;AACxD,YAAA,MAAM,aAAaJ,eAAA,CAAK,OAAA,CAAQ,UAAA,EAAY,IAAA,CAAK,YAAY,EAAE,CAAA;AAC/D,YAAA,IAAI,CAACW,aAAA,CAAS,UAAA,EAAY,UAAU,GAAG,OAAO,KAAA;AAAA,UAChD;AAGA,UAAA,MAAM,UAAA,GAAaX,eAAA,CAAK,OAAA,CAAQ,UAAA,EAAY,QAAQ,CAAA;AACpD,UAAA,IAAI,CAACW,aAAA,CAAS,UAAA,EAAY,UAAU,GAAG,OAAO,KAAA;AAE9C,UAAA,OAAOC,uBAAA,CAAmB,KAAK,IAAI,CAAA;AAAA,QACrC;AAAA,OACD,CAAA;AAAA,IACH;AACA,IAAA,IAAI,SAAS,MAAM,OAAA;AAAA,EACrB,CAAA,SAAE;AACA,IAAA,MAAMX,aAAA,CAAG,GAAG,OAAA,EAAS,EAAE,WAAW,IAAA,EAAM,KAAA,EAAO,MAAM,CAAA;AAAA,EACvD;AACF;AAkBA,eAAsB,wBAAA,CACpB,MAAA,EACA,KAAA,EACA,YAAA,EACA,WACA,gBAAA,EACe;AACf,EAAA,IAAI,CAAC,sBAAA,CAAuB,YAAY,CAAA,EAAG;AACzC,IAAA,MAAM,IAAIG,uBAAA;AAAA,MACR,qEAAqE,YAAY,CAAA,CAAA;AAAA,KACnF;AAAA,EACF;AACA,EAAAL,OAAA;AAAA,IACE;AAAA,wCAAA,EAA6C,YAAY,UAAU,KAAK,CAAA;AAAA,GAC1E;AACA,EAAA,IAAI,gBAAA,EAAkB;AACpB,IAAAA,OAAA;AAAA,MACE,CAAA,4BAAA,EAAgC,YAAY,CAAA,uBAAA,EAA0B,gBAAgB,CAAA,+CAAA;AAAA,KAExF;AAAA,EACF;AAEA,EAAA,MAAM,OAAA,GAAU,MAAME,aAAA,CAAG,OAAA;AAAA,IACvBD,eAAA,CAAK,IAAA,CAAKM,aAAA,CAAG,MAAA,IAAU,2BAA2B;AAAA,GACpD;AACA,EAAA,IAAI;AACF,IAAA,MAAM,YAAA,GAAeN,eAAA,CAAK,IAAA,CAAK,OAAA,EAAS,WAAW,CAAA;AACnD,IAAA,MAAMC,cAAG,KAAA,CAAM,YAAA,EAAc,EAAE,SAAA,EAAW,MAAM,CAAA;AAChD,IAAA,MAAM,yBAAA,CAA0B,MAAA,EAAQ,KAAA,EAAO,YAAY,CAAA;AAE3D,IAAA,MAAM,YAAA,GAAeD,eAAA,CAAK,IAAA,CAAK,SAAA,EAAW,YAAY,CAAA;AACtD,IAAAD,OAAA,CAAI,CAAA,+CAAA,EAAmD,YAAY,CAAA,CAAE,CAAA;AAErE,IAAA,IAAI,SAAA,GAA2B,IAAA;AAC/B,IAAA,KAAA,MAAW,GAAA,IAAO;AAAA,MAChB,6BAAA;AAAA,MACA;AAAA,KACF,EAAG;AACD,MAAA,MAAM,SAAA,GAAYC,eAAA,CAAK,IAAA,CAAK,YAAA,EAAc,GAAG,CAAA;AAC7C,MAAA,IAAI,MAAMG,eAAA,CAAW,SAAS,CAAA,EAAG;AAC/B,QAAA,SAAA,GAAY,SAAA;AACZ,QAAA;AAAA,MACF;AAAA,IACF;AAEA,IAAA,IAAI,CAAC,SAAA,EAAW;AACd,MAAAJ,OAAA;AAAA,QACE,2CAA4C,KAAK,CAAA,mGAAA;AAAA,OAEnD;AACA,MAAA;AAAA,IACF;AAEA,IAAA,MAAME,cAAG,KAAA,CAAM,YAAA,EAAc,EAAE,SAAA,EAAW,MAAM,CAAA;AAChD,IAAA,MAAM,GAAA,GAAMD,eAAA,CAAK,IAAA,CAAK,YAAA,EAAc,kBAAkB,CAAA;AACtD,IAAA,MAAMC,aAAA,CAAG,GAAG,GAAA,EAAK,EAAE,WAAW,IAAA,EAAM,KAAA,EAAO,MAAM,CAAA;AACjD,IAAA,MAAM,OAAA,CAAQ,WAAW,GAAG,CAAA;AAC5B,IAAAF,OAAA;AAAA,MACE,qFAAsF,YAAY,CAAA;AAAA,KACpG;AAAA,EACF,CAAA,SAAE;AACA,IAAA,MAAME,aAAA,CAAG,GAAG,OAAA,EAAS,EAAE,WAAW,IAAA,EAAM,KAAA,EAAO,MAAM,CAAA;AAAA,EACvD;AACF;AAQO,SAAS,uBAAuB,QAAA,EAA0B;AAC/D,EAAA,OAAO,QAAA,CAAS,UAAA,CAAW,QAAA,EAAU,GAAG,CAAA;AAC1C;AASO,SAAS,6BACd,GAAA,EACyC;AACzC,EAAA,MAAM,MAA+B,EAAC;AACtC,EAAA,KAAA,MAAW,QAAA,IAAY,GAAA,CAAI,KAAA,CAAM,GAAG,CAAA,EAAG;AACrC,IAAA,MAAM,KAAA,GAAQ,SAAS,IAAA,EAAK;AAC5B,IAAA,IAAI,CAAC,KAAA,EAAO;AACZ,IAAA,IAAI,IAAA;AACJ,IAAA,IAAI,QAAA;AACJ,IAAA,MAAM,EAAA,GAAK,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA;AAC5B,IAAA,IAAI,OAAO,EAAA,EAAI;AACb,MAAA,QAAA,GAAW,KAAA;AACX,MAAA,IAAA,GAAO,uBAAuB,QAAQ,CAAA;AAAA,IACxC,CAAA,MAAO;AACL,MAAA,IAAA,GAAO,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,EAAE,EAAE,IAAA,EAAK;AAC/B,MAAA,QAAA,GAAW,KAAA,CAAM,KAAA,CAAM,EAAA,GAAK,CAAC,EAAE,IAAA,EAAK;AAAA,IACtC;AACA,IAAA,IAAI,CAAC,QAAA,EAAU;AACb,MAAAF,OAAA;AAAA,QACE,mFAAmF,KAAK,CAAA,CAAA;AAAA,OAC1F;AACA,MAAA;AAAA,IACF;AACA,IAAA,IAAI,CAAC,sBAAA,CAAuB,IAAI,CAAA,EAAG;AACjC,MAAAA,OAAA;AAAA,QACE,MAAA,CAAO,GAAA,CAAA,kFAAA,EAAwF,IAAI,CAAA,MAAA,EAAS,KAAK,CAAA,mEAAA;AAAA,OACnH;AACA,MAAA;AAAA,IACF;AACA,IAAA,GAAA,CAAI,IAAA,CAAK,CAAC,IAAA,EAAM,QAAQ,CAAC,CAAA;AAAA,EAC3B;AACA,EAAA,OAAO,GAAA;AACT;AAOA,SAAS,uBAAuB,IAAA,EAAuB;AACrD,EAAA,IAAI,CAAC,IAAA,IAAQ,IAAA,KAAS,GAAA,IAAO,IAAA,KAAS,MAAM,OAAO,KAAA;AACnD,EAAA,OAAO,CAAC,OAAA,CAAQ,IAAA,CAAK,IAAI,CAAA;AAC3B;AAEA,eAAsB,wBAAwB,QAAA,EAAiC;AAC7E,EAAA,MAAME,cAAG,EAAA,CAAGD,eAAA,CAAK,IAAA,CAAK,QAAA,EAAU,qBAAqB,CAAA,EAAG;AAAA,IACtD,SAAA,EAAW,IAAA;AAAA,IACX,KAAA,EAAO;AAAA,GACR,CAAA;AACH;AAEA,eAAe,OAAA,CAAQ,KAAa,GAAA,EAA4B;AAC9D,EAAA,MAAMC,cAAG,KAAA,CAAM,GAAA,EAAK,EAAE,SAAA,EAAW,MAAM,CAAA;AACvC,EAAA,MAAM,OAAA,GAAU,MAAMA,aAAA,CAAG,OAAA,CAAQ,KAAK,EAAE,aAAA,EAAe,MAAM,CAAA;AAC7D,EAAA,KAAA,MAAW,SAAS,OAAA,EAAS;AAC3B,IAAA,MAAM,CAAA,GAAID,eAAA,CAAK,IAAA,CAAK,GAAA,EAAK,MAAM,IAAI,CAAA;AACnC,IAAA,MAAM,CAAA,GAAIA,eAAA,CAAK,IAAA,CAAK,GAAA,EAAK,MAAM,IAAI,CAAA;AACnC,IAAA,IAAI,KAAA,CAAM,aAAY,EAAG;AACvB,MAAA,MAAM,OAAA,CAAQ,GAAG,CAAC,CAAA;AAAA,IACpB,CAAA,MAAA,IAAW,KAAA,CAAM,MAAA,EAAO,EAAG;AACzB,MAAA,MAAMC,aAAA,CAAG,QAAA,CAAS,CAAA,EAAG,CAAC,CAAA;AAAA,IACxB;AAAA,EACF;AACF;;;;;;;;;"}
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var installer = require('./installer.cjs.js');
6
+
7
+ var command = async ({ args, info }) => {
8
+ await installer.main(args, info.usage);
9
+ };
10
+
11
+ exports.default = command;
12
+ //# sourceMappingURL=command.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.cjs.js","sources":["../src/command.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport type { CliCommandContext } from '@backstage/cli-node';\nimport { main } from './installer';\n\nexport default async ({ args, info }: CliCommandContext): Promise<void> => {\n await main(args, info.usage);\n};\n"],"names":["main"],"mappings":";;;;;;AAkBA,cAAe,OAAO,EAAE,IAAA,EAAM,IAAA,EAAK,KAAwC;AACzE,EAAA,MAAMA,cAAA,CAAK,IAAA,EAAM,IAAA,CAAK,KAAK,CAAA;AAC7B,CAAA;;;;"}
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ var os = require('node:os');
4
+
5
+ function _interopNamespaceCompat(e) {
6
+ if (e && typeof e === 'object' && 'default' in e) return e;
7
+ var n = Object.create(null);
8
+ if (e) {
9
+ Object.keys(e).forEach(function (k) {
10
+ if (k !== 'default') {
11
+ var d = Object.getOwnPropertyDescriptor(e, k);
12
+ Object.defineProperty(n, k, d.get ? d : {
13
+ enumerable: true,
14
+ get: function () { return e[k]; }
15
+ });
16
+ }
17
+ });
18
+ }
19
+ n.default = e;
20
+ return Object.freeze(n);
21
+ }
22
+
23
+ var os__namespace = /*#__PURE__*/_interopNamespaceCompat(os);
24
+
25
+ class Semaphore {
26
+ available;
27
+ queue = [];
28
+ constructor(max) {
29
+ if (max < 1) throw new RangeError(`Semaphore max must be >= 1, got ${max}`);
30
+ this.available = max;
31
+ }
32
+ async acquire() {
33
+ if (this.available > 0) {
34
+ this.available--;
35
+ return void 0;
36
+ }
37
+ return new Promise((resolve) => this.queue.push(resolve));
38
+ }
39
+ release() {
40
+ const next = this.queue.shift();
41
+ if (next) next();
42
+ else this.available++;
43
+ }
44
+ }
45
+ async function mapConcurrent(items, limit, fn) {
46
+ const sem = new Semaphore(Math.max(1, limit));
47
+ return Promise.all(
48
+ items.map(async (item) => {
49
+ await sem.acquire();
50
+ try {
51
+ return { ok: true, value: await fn(item), item };
52
+ } catch (err) {
53
+ return { ok: false, error: err, item };
54
+ } finally {
55
+ sem.release();
56
+ }
57
+ })
58
+ );
59
+ }
60
+ const MAX_OCI_WORKERS = 6;
61
+ const MAX_NPM_WORKERS = 3;
62
+ function getWorkers() {
63
+ return resolveWorkers(process.env.DYNAMIC_PLUGINS_WORKERS, MAX_OCI_WORKERS);
64
+ }
65
+ function getNpmWorkers() {
66
+ return resolveWorkers(
67
+ process.env.DYNAMIC_PLUGINS_NPM_WORKERS,
68
+ MAX_NPM_WORKERS
69
+ );
70
+ }
71
+ function resolveWorkers(rawEnv, cap) {
72
+ const env = rawEnv ?? "auto";
73
+ if (env !== "auto") {
74
+ const n = Number.parseInt(env, 10);
75
+ if (!Number.isFinite(n) || n < 1) return 1;
76
+ return n;
77
+ }
78
+ const cpus = typeof os__namespace.availableParallelism === "function" ? os__namespace.availableParallelism() : os__namespace.cpus().length;
79
+ return Math.max(1, Math.min(Math.floor(cpus / 2), cap));
80
+ }
81
+
82
+ exports.Semaphore = Semaphore;
83
+ exports.getNpmWorkers = getNpmWorkers;
84
+ exports.getWorkers = getWorkers;
85
+ exports.mapConcurrent = mapConcurrent;
86
+ //# sourceMappingURL=concurrency.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"concurrency.cjs.js","sources":["../src/concurrency.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport * as os from 'node:os';\n\n/**\n * Minimal semaphore for bounding concurrent async work.\n * Matches the Python `ThreadPoolExecutor(max_workers=N)` worker model from\n * install-dynamic-plugins-fast.py — single-threaded JS means no lock needed\n * on the counter itself.\n */\nexport class Semaphore {\n private available: number;\n private readonly queue: Array<() => void> = [];\n\n constructor(max: number) {\n if (max < 1) throw new RangeError(`Semaphore max must be >= 1, got ${max}`);\n this.available = max;\n }\n\n async acquire(): Promise<void> {\n if (this.available > 0) {\n this.available--;\n return undefined;\n }\n return new Promise<void>(resolve => this.queue.push(resolve));\n }\n\n release(): void {\n const next = this.queue.shift();\n if (next) next();\n else this.available++;\n }\n}\n\nexport type Outcome<T, Item> =\n | { ok: true; value: T; item: Item }\n | { ok: false; error: Error; item: Item };\n\n/**\n * Run `fn` over `items` with at most `limit` concurrent executions.\n * Returns every outcome — errors are captured, not thrown, so one failure\n * does not cancel the others. Mirrors the behaviour of fast.py's parallel install loop.\n */\nexport async function mapConcurrent<Item, T>(\n items: readonly Item[],\n limit: number,\n fn: (item: Item) => Promise<T>,\n): Promise<Array<Outcome<T, Item>>> {\n const sem = new Semaphore(Math.max(1, limit));\n return Promise.all(\n items.map(async item => {\n await sem.acquire();\n try {\n return { ok: true as const, value: await fn(item), item };\n } catch (err) {\n return { ok: false as const, error: err as Error, item };\n } finally {\n sem.release();\n }\n }),\n );\n}\n\n/** Conservative upper bound for parallel `skopeo` calls. */\nconst MAX_OCI_WORKERS = 6;\n\n/**\n * Lower cap for NPM installs because `npm pack` hits the public registry\n * and shares a single CLI cache (`~/.npm/_cacache`) — excessive concurrency\n * triggers throttling and cache contention without a wall-clock benefit.\n */\nconst MAX_NPM_WORKERS = 3;\n\n/**\n * Worker count selection, honouring `DYNAMIC_PLUGINS_WORKERS` env and cgroup\n * CPU limits (via `availableParallelism`). Conservative default for OpenShift\n * init containers: half of available CPUs, capped at `MAX_OCI_WORKERS`.\n */\nexport function getWorkers(): number {\n return resolveWorkers(process.env.DYNAMIC_PLUGINS_WORKERS, MAX_OCI_WORKERS);\n}\n\n/**\n * Worker count for concurrent NPM installs. Override via\n * `DYNAMIC_PLUGINS_NPM_WORKERS` (set to `1` to restore the original\n * sequential behaviour).\n */\nexport function getNpmWorkers(): number {\n return resolveWorkers(\n process.env.DYNAMIC_PLUGINS_NPM_WORKERS,\n MAX_NPM_WORKERS,\n );\n}\n\nfunction resolveWorkers(rawEnv: string | undefined, cap: number): number {\n const env = rawEnv ?? 'auto';\n if (env !== 'auto') {\n const n = Number.parseInt(env, 10);\n if (!Number.isFinite(n) || n < 1) return 1;\n return n;\n }\n const cpus =\n typeof os.availableParallelism === 'function'\n ? os.availableParallelism()\n : os.cpus().length;\n return Math.max(1, Math.min(Math.floor(cpus / 2), cap));\n}\n"],"names":["os"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAuBO,MAAM,SAAA,CAAU;AAAA,EACb,SAAA;AAAA,EACS,QAA2B,EAAC;AAAA,EAE7C,YAAY,GAAA,EAAa;AACvB,IAAA,IAAI,MAAM,CAAA,EAAG,MAAM,IAAI,UAAA,CAAW,CAAA,gCAAA,EAAmC,GAAG,CAAA,CAAE,CAAA;AAC1E,IAAA,IAAA,CAAK,SAAA,GAAY,GAAA;AAAA,EACnB;AAAA,EAEA,MAAM,OAAA,GAAyB;AAC7B,IAAA,IAAI,IAAA,CAAK,YAAY,CAAA,EAAG;AACtB,MAAA,IAAA,CAAK,SAAA,EAAA;AACL,MAAA,OAAO,MAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAI,OAAA,CAAc,CAAA,OAAA,KAAW,KAAK,KAAA,CAAM,IAAA,CAAK,OAAO,CAAC,CAAA;AAAA,EAC9D;AAAA,EAEA,OAAA,GAAgB;AACd,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,KAAA,CAAM,KAAA,EAAM;AAC9B,IAAA,IAAI,MAAM,IAAA,EAAK;AAAA,SACV,IAAA,CAAK,SAAA,EAAA;AAAA,EACZ;AACF;AAWA,eAAsB,aAAA,CACpB,KAAA,EACA,KAAA,EACA,EAAA,EACkC;AAClC,EAAA,MAAM,MAAM,IAAI,SAAA,CAAU,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,CAAC,CAAA;AAC5C,EAAA,OAAO,OAAA,CAAQ,GAAA;AAAA,IACb,KAAA,CAAM,GAAA,CAAI,OAAM,IAAA,KAAQ;AACtB,MAAA,MAAM,IAAI,OAAA,EAAQ;AAClB,MAAA,IAAI;AACF,QAAA,OAAO,EAAE,IAAI,IAAA,EAAe,KAAA,EAAO,MAAM,EAAA,CAAG,IAAI,GAAG,IAAA,EAAK;AAAA,MAC1D,SAAS,GAAA,EAAK;AACZ,QAAA,OAAO,EAAE,EAAA,EAAI,KAAA,EAAgB,KAAA,EAAO,KAAc,IAAA,EAAK;AAAA,MACzD,CAAA,SAAE;AACA,QAAA,GAAA,CAAI,OAAA,EAAQ;AAAA,MACd;AAAA,IACF,CAAC;AAAA,GACH;AACF;AAGA,MAAM,eAAA,GAAkB,CAAA;AAOxB,MAAM,eAAA,GAAkB,CAAA;AAOjB,SAAS,UAAA,GAAqB;AACnC,EAAA,OAAO,cAAA,CAAe,OAAA,CAAQ,GAAA,CAAI,uBAAA,EAAyB,eAAe,CAAA;AAC5E;AAOO,SAAS,aAAA,GAAwB;AACtC,EAAA,OAAO,cAAA;AAAA,IACL,QAAQ,GAAA,CAAI,2BAAA;AAAA,IACZ;AAAA,GACF;AACF;AAEA,SAAS,cAAA,CAAe,QAA4B,GAAA,EAAqB;AACvE,EAAA,MAAM,MAAM,MAAA,IAAU,MAAA;AACtB,EAAA,IAAI,QAAQ,MAAA,EAAQ;AAClB,IAAA,MAAM,CAAA,GAAI,MAAA,CAAO,QAAA,CAAS,GAAA,EAAK,EAAE,CAAA;AACjC,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,CAAC,CAAA,IAAK,CAAA,GAAI,GAAG,OAAO,CAAA;AACzC,IAAA,OAAO,CAAA;AAAA,EACT;AACA,EAAA,MAAM,IAAA,GACJ,OAAOA,aAAA,CAAG,oBAAA,KAAyB,UAAA,GAC/BA,cAAG,oBAAA,EAAqB,GACxBA,aAAA,CAAG,IAAA,EAAK,CAAE,MAAA;AAChB,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,KAAA,CAAM,IAAA,GAAO,CAAC,CAAA,EAAG,GAAG,CAAC,CAAA;AACxD;;;;;;;"}
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ class InstallException extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "InstallException";
7
+ }
8
+ }
9
+
10
+ exports.InstallException = InstallException;
11
+ //# sourceMappingURL=errors.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.cjs.js","sources":["../src/errors.ts"],"sourcesContent":["/*\n * Copyright Red Hat, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nexport class InstallException extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'InstallException';\n }\n}\n"],"names":[],"mappings":";;AAgBO,MAAM,yBAAyB,KAAA,CAAM;AAAA,EAC1C,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,kBAAA;AAAA,EACd;AACF;;;;"}