Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/cli-module-install-dynamic-plugins': minor
---

Add `ref://` plugin reference resolution to simplify plugin configuration
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Comment thread
nickboldt marked this conversation as resolved.
},
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://',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
nickboldt marked this conversation as resolved.
*/
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');

Check warning on line 69 in workspaces/install-dynamic-plugins/packages/install-dynamic-plugins/src/plugin-name.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ-z6gBP0nOAQi7y7-O4&open=AZ-z6gBP0nOAQi7y7-O4&pullRequest=4110

return name || null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Loading