From 24047558ba0083885e28a075657e8110fb59f155 Mon Sep 17 00:00:00 2001 From: Jon Koops Date: Thu, 30 Jul 2026 18:58:17 +0200 Subject: [PATCH] feat(install-dynamic-plugins): add ref:// plugin reference resolution Allow plugin configurations to reference other plugins by name using ref://plugin-name instead of repeating full OCI or HTTP package URLs. The resolver matches the ref name against extracted plugin names from already-merged plugins and replaces the ref with the resolved package URL. Ref: RHIDP-15875 Signed-off-by: Jon Koops --- .../.changeset/ref-plugin-resolution.md | 5 + .../src/merger.test.ts | 79 +++++++++- .../install-dynamic-plugins/src/merger.ts | 23 ++- .../src/plugin-name.test.ts | 145 ++++++++++++++++++ .../src/plugin-name.ts | 72 +++++++++ .../install-dynamic-plugins/src/protocols.ts | 5 + 6 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 workspaces/install-dynamic-plugins/.changeset/ref-plugin-resolution.md create mode 100644 workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.test.ts create mode 100644 workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.ts diff --git a/workspaces/install-dynamic-plugins/.changeset/ref-plugin-resolution.md b/workspaces/install-dynamic-plugins/.changeset/ref-plugin-resolution.md new file mode 100644 index 00000000000..fc91c04b5d9 --- /dev/null +++ b/workspaces/install-dynamic-plugins/.changeset/ref-plugin-resolution.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/cli-module-install-dynamic-plugins': minor +--- + +Add `ref://` plugin reference resolution to simplify plugin configuration diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.test.ts index c35b00f3d1f..ceb0dd70c41 100644 --- a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.test.ts +++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { InstallException } from './errors'; -import { deepMerge, mergePlugin } from './merger'; +import { deepMerge, mergePlugin, resolveRefPlugin } from './merger'; import type { PluginMap } from './types'; describe('deepMerge', () => { @@ -138,3 +138,80 @@ describe('mergePlugin — NPM', () => { ).rejects.toThrow(/must be a string/); }); }); + +describe('mergePlugin — ref://', () => { + it('resolves ref to an OCI plugin by name', async () => { + const all: PluginMap = {}; + await mergePlugin( + { + package: + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!backstage-plugin-foo', + }, + all, + 'inc.yaml', + 0, + ); + await mergePlugin( + { package: 'ref://backstage-plugin-foo' }, + all, + 'cfg.yaml', + 1, + ); + const key = 'oci://quay.io/rhdh/backstage-plugin-foo:!backstage-plugin-foo'; + expect(all[key]?.package).toBe( + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!backstage-plugin-foo', + ); + }); +}); + +describe('resolveRefPlugin', () => { + it('resolves ref to an OCI plugin', () => { + const all: PluginMap = { + key: { + package: + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!backstage-plugin-foo', + }, + }; + expect(resolveRefPlugin('ref://backstage-plugin-foo', all)).toBe( + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!backstage-plugin-foo', + ); + }); + + it('resolves ref to an HTTP tarball plugin', () => { + const all: PluginMap = { + key: { + package: 'https://example.com/plugins/backstage-plugin-bar-1.2.3.tgz', + }, + }; + expect(resolveRefPlugin('ref://backstage-plugin-bar', all)).toBe( + 'https://example.com/plugins/backstage-plugin-bar-1.2.3.tgz', + ); + }); + + it('resolves ref to a local plugin', () => { + const all: PluginMap = { + key: { package: './dynamic-plugins/dist/backstage-plugin-baz' }, + }; + expect(resolveRefPlugin('ref://backstage-plugin-baz', all)).toBe( + './dynamic-plugins/dist/backstage-plugin-baz', + ); + }); + + it('throws on unknown plugin name', () => { + const all: PluginMap = { + key: { + package: + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!backstage-plugin-foo', + }, + }; + expect(() => resolveRefPlugin('ref://unknown-plugin', all)).toThrow( + "Cannot resolve ref:// reference: no plugin named 'unknown-plugin' found in included plugins", + ); + }); + + it('throws on empty ref', () => { + expect(() => resolveRefPlugin('ref://', {})).toThrow( + 'Invalid ref:// reference: empty plugin name in ref://', + ); + }); +}); diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.ts index da92576b786..4c250fa8045 100644 --- a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.ts +++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/merger.ts @@ -24,7 +24,8 @@ import { type ParsedOciKey, tryParseOciRegistryAndPath, } from './oci-key'; -import { isOciUrl, OCI_PROTO } from './protocols'; +import { extractPluginName } from './plugin-name'; +import { isOciUrl, isRefUrl, OCI_PROTO, REF_PROTO } from './protocols'; import { type DynamicPluginsConfig, isPluginDisabled, @@ -125,6 +126,23 @@ export async function mergePluginsFromFile( } } +export function resolveRefPlugin(pkg: string, allPlugins: PluginMap): string { + const refName = pkg.slice(REF_PROTO.length); + if (!refName) { + throw new InstallException( + `Invalid ref:// reference: empty plugin name in ref://`, + ); + } + for (const entry of Object.values(allPlugins)) { + if (extractPluginName(entry.package) === refName) { + return entry.package; + } + } + throw new InstallException( + `Cannot resolve ref:// reference: no plugin named '${refName}' found in included plugins`, + ); +} + export async function mergePlugin( plugin: Plugin, allPlugins: PluginMap, @@ -137,6 +155,9 @@ export async function mergePlugin( `content of the 'plugins.package' field must be a string in ${configFile}`, ); } + if (isRefUrl(plugin.package)) { + plugin.package = resolveRefPlugin(plugin.package, allPlugins); + } if (isOciUrl(plugin.package)) { await mergeOciPlugin(plugin, allPlugins, configFile, level, imageCache); } else { diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.test.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.test.ts new file mode 100644 index 00000000000..0db99ba8cd6 --- /dev/null +++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { extractPluginName } from './plugin-name'; + +describe('extractPluginName', () => { + const cases: [string, string | null][] = [ + // OCI — digest + [ + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123', + 'backstage-plugin-foo', + ], + // OCI — tag + ['oci://quay.io/rhdh/backstage-plugin-bar:v1.0.0', 'backstage-plugin-bar'], + // OCI — latest tag + ['oci://quay.io/rhdh/backstage-plugin-bar:latest', 'backstage-plugin-bar'], + // OCI — registry with port, no tag + ['oci://localhost:5000/path/my-plugin', 'my-plugin'], + // OCI — registry with port and tag + ['oci://localhost:5000/path/my-plugin:v1.0.0', 'my-plugin'], + // OCI — IP address registry with port + ['oci://10.0.0.1:5000/repo/plugin:tag', 'plugin'], + // OCI — with !plugin-path suffix + [ + 'oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123!plugin-path', + 'backstage-plugin-foo', + ], + // OCI — with !plugin-path with slashes + [ + 'oci://quay.io/rhdh/backstage-plugin-foo:v1.0!path/to/plugin', + 'backstage-plugin-foo', + ], + // OCI — with {{inherit}} tag + [ + 'oci://quay.io/rhdh/backstage-plugin-foo:{{inherit}}', + 'backstage-plugin-foo', + ], + // OCI — with {{inherit}} tag and !path + [ + 'oci://quay.io/rhdh/backstage-plugin-foo:{{inherit}}!some-path', + 'backstage-plugin-foo', + ], + // OCI — deep paths (multiple segments) + [ + 'oci://quay.io/org/sub/backstage-plugin-foo@sha256:abc123', + 'backstage-plugin-foo', + ], + // OCI — single path segment + ['oci://quay.io/backstage-plugin-foo:v1.0', 'backstage-plugin-foo'], + // OCI — registry-only URL returns null + ['oci://localhost:5000', null], + // OCI — trailing slash returns null + ['oci://localhost:5000/', null], + // OCI — host-only returns null + ['oci://quay.io', null], + // OCI — bare scheme returns null + ['oci://', null], + // OCI — malformed URL returns null + ['oci://[invalid', null], + // OCI — empty image name returns null + ['oci://quay.io/rhdh/:v1.0', null], + + // HTTP(S) — .tgz with version + [ + 'https://example.com/plugins/backstage-plugin-foo-1.0.0.tgz', + 'backstage-plugin-foo', + ], + // HTTP(S) — .tar.gz with version + ['https://example.com/path/my-plugin-2.3.4.tar.gz', 'my-plugin'], + // HTTP(S) — http:// URL + [ + 'http://registry.example.com/backstage-plugin-bar-0.1.0.tgz', + 'backstage-plugin-bar', + ], + // HTTP(S) — with query string (stripped by URL) + [ + 'https://example.com/plugins/backstage-plugin-foo-1.0.0.tgz?token=abc', + 'backstage-plugin-foo', + ], + // HTTP(S) — no archive extension (version still stripped) + [ + 'https://example.com/plugins/backstage-plugin-foo-1.0.0', + 'backstage-plugin-foo', + ], + // HTTP(S) — no version suffix (name returned as-is) + [ + 'https://example.com/plugins/backstage-plugin-foo.tgz', + 'backstage-plugin-foo', + ], + // HTTP(S) — .tar.gz without version + [ + 'https://example.com/plugins/backstage-plugin-foo.tar.gz', + 'backstage-plugin-foo', + ], + // HTTP(S) — no path returns null + ['https://example.com', null], + // HTTP(S) — trailing slash returns null + ['https://example.com/', null], + // HTTP(S) — bare scheme returns null + ['https://', null], + // HTTP(S) — pre-release version + [ + 'https://example.com/plugins/backstage-plugin-foo-1.0.0-beta.1.tgz', + 'backstage-plugin-foo', + ], + // HTTP(S) — plugin name containing digits + [ + 'https://example.com/plugins/plugin-3scale-backend-1.2.3.tgz', + 'plugin-3scale-backend', + ], + + // Local path — deep + [ + './dynamic-plugins/dist/backstage-plugin-techdocs', + 'backstage-plugin-techdocs', + ], + // Local path — shallow + ['./plugin-foo', 'plugin-foo'], + // Local path — trailing slash + ['./foo/', 'foo'], + // Local path — bare prefix + ['./', '.'], + + // Unknown formats return null + ['@backstage/plugin-catalog', null], + ['some-package', null], + ['', null], + ]; + + it.each(cases)('parses %s -> %s', (input, expected) => { + expect(extractPluginName(input)).toBe(expected); + }); +}); diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.ts new file mode 100644 index 00000000000..729f6851392 --- /dev/null +++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.ts @@ -0,0 +1,72 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { basename } from 'node:path'; +import { isHttpUrl, isLocalPath, isOciUrl } from './protocols'; + +/** + * Extract the human-readable plugin name from a package URL. + * + * Supports OCI (`oci://`), HTTP(S) (`.tgz`/`.tar.gz` archives), and + * local (`./`) paths. Returns `null` for unrecognized formats. + */ +export function extractPluginName(pkg: string): string | null { + if (isOciUrl(pkg)) return ociName(pkg); + if (isHttpUrl(pkg)) return httpName(pkg); + if (isLocalPath(pkg)) return basename(pkg); + return null; +} + +/** + * Extract the plugin name from an OCI URL by stripping the plugin path + * (`!` suffix), digest, and tag. + * + * @example + * ociName('oci://quay.io/rhdh/backstage-plugin-foo@sha256:abc123') // 'backstage-plugin-foo' + * ociName('oci://quay.io/rhdh/backstage-plugin-foo:v1.0!path') // 'backstage-plugin-foo' + * ociName('oci://localhost:5000/path/my-plugin:v1.0.0') // 'my-plugin' + */ +function ociName(pkg: string): string | null { + const withoutBang = pkg.split('!').at(0) as string; + + if (!URL.canParse(withoutBang)) return null; + + const segment = basename(new URL(withoutBang).pathname); + if (!segment) return null; + + const beforeDigest = segment.split('@').at(0) as string; + const name = beforeDigest.split(':').at(0) as string; + + return name || null; +} + +/** + * Extract the plugin name from an HTTP(S) URL by stripping the archive + * extension and version suffix. + * + * @example + * httpName('https://example.com/backstage-plugin-foo-1.0.0.tgz') // 'backstage-plugin-foo' + * httpName('https://example.com/plugin-3scale-backend-1.2.3.tar.gz') // 'plugin-3scale-backend' + * httpName('https://example.com/backstage-plugin-foo.tgz') // 'backstage-plugin-foo' + */ +function httpName(pkg: string): string | null { + if (!URL.canParse(pkg)) return null; + + const name = basename(new URL(pkg).pathname) + .replace(/\.(tar\.gz|tgz)$/, '') + .replace(/(.*)-\d.*$/, '$1'); + + return name || null; +} diff --git a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/protocols.ts b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/protocols.ts index ce9c084b8f9..d613799b715 100644 --- a/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/protocols.ts +++ b/workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/protocols.ts @@ -16,6 +16,7 @@ export const DOCKER_PROTO = 'docker://'; export const OCI_PROTO = 'oci://'; +export const REF_PROTO = 'ref://'; export function isOciUrl(value: string): boolean { return value.startsWith(OCI_PROTO); @@ -32,3 +33,7 @@ export function isHttpUrl(value: string): boolean { export function isLocalPath(value: string): boolean { return value.startsWith('./'); } + +export function isRefUrl(value: string): boolean { + return value.startsWith(REF_PROTO); +}