From d4b682feafb757d601972a79bd14973e5d1ecbcd Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 11 Sep 2025 22:24:11 +0300 Subject: [PATCH] feat(yarn-plugin): custom manifest location added two environment variables that can be used to control the location of backstage manifest file used by the yarn plugin. this helps using the plugin in restricted environments without direct or access at all to the internet. closes #31101 Signed-off-by: Hellgren Heikki --- packages/yarn-plugin/README.md | 15 + .../src/util/getPackageVersion.test.ts | 460 ++++++++++++++++++ .../yarn-plugin/src/util/getPackageVersion.ts | 71 +-- 3 files changed, 513 insertions(+), 33 deletions(-) create mode 100644 packages/yarn-plugin/src/util/getPackageVersion.test.ts diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index 5c112506e3..fe90cebe7b 100644 --- a/packages/yarn-plugin/README.md +++ b/packages/yarn-plugin/README.md @@ -25,6 +25,21 @@ The yarn plugin recognizes the version string `"backstage:^"`, and replaces it with the appropriate version based on the overall Backstage version in backstage.json. +### Configuration + +The plugin functionality can be configured with environment variables: + +- `BACKSTAGE_MANIFEST_BASE_URL` - The base URL for fetching the Backstage version + manifest. Defaults to `https://versions.backstage.io/v1/releases/VERSION/manifest.json`. + Useful for running the plugin in environment without direct access to the internet, + for example by using a mirror of the versions API or a proxy. + Note that the environment variable is just the host name, and the path is appended by + the plugin. +- `BACKSTAGE_MANIFEST_FILE` - Path to a local manifest file. If set, the plugin + will not attempt to fetch the manifest from the network. Useful for running + the plugin in environment without internet access and without mirror of the + versions API. + ## Local Development - Run unit tests: `yarn test` diff --git a/packages/yarn-plugin/src/util/getPackageVersion.test.ts b/packages/yarn-plugin/src/util/getPackageVersion.test.ts new file mode 100644 index 0000000000..d093a8f3ad --- /dev/null +++ b/packages/yarn-plugin/src/util/getPackageVersion.test.ts @@ -0,0 +1,460 @@ +/* + * Copyright 2024 The Backstage Authors + * + * 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 { + Configuration, + Descriptor, + httpUtils, + structUtils, +} from '@yarnpkg/core'; +import { xfs } from '@yarnpkg/fslib'; +import { getManifestByVersion } from '@backstage/release-manifests'; +import { getPackageVersion } from './getPackageVersion'; +import { getCurrentBackstageVersion } from './getCurrentBackstageVersion'; + +jest.mock('@yarnpkg/core'); +jest.mock('@backstage/release-manifests'); +jest.mock('./getCurrentBackstageVersion'); + +const mockHttpUtils = httpUtils as jest.Mocked; +const mockStructUtils = structUtils as jest.Mocked; +const mockGetManifestByVersion = getManifestByVersion as jest.MockedFunction< + typeof getManifestByVersion +>; +const mockGetCurrentBackstageVersion = + getCurrentBackstageVersion as jest.MockedFunction< + typeof getCurrentBackstageVersion + >; + +describe('getPackageVersion', () => { + const mockConfiguration = {} as Configuration; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetCurrentBackstageVersion.mockReturnValue('1.23.4'); + mockStructUtils.stringifyIdent.mockImplementation( + (descriptor: any) => + `${descriptor.scope || ''}${descriptor.scope ? '/' : ''}${ + descriptor.name + }`, + ); + + mockStructUtils.parseRange.mockImplementation((range: string) => ({ + protocol: range.includes('backstage:') ? 'backstage:' : 'npm:', + selector: range.includes('^') ? '^' : range.split(':')[1] || '', + })); + }); + + describe('successful package resolution', () => { + beforeEach(() => { + mockHttpUtils.get.mockResolvedValue({ + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }); + + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }); + }); + + it('returns the correct version for a package in the manifest', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('1.9.0'); + expect(mockGetCurrentBackstageVersion).toHaveBeenCalledTimes(1); + expect(mockGetManifestByVersion).toHaveBeenCalledWith({ + version: '1.23.4', + versionsBaseUrl: undefined, + fetch: expect.any(Function), + }); + }); + + it('uses custom versionsBaseUrl from environment when provided', async () => { + const originalEnv = process.env.BACKSTAGE_MANIFEST_BASE_URL; + process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + await getPackageVersion(descriptor, mockConfiguration); + + expect(mockGetManifestByVersion).toHaveBeenCalledWith({ + version: '1.23.4', + versionsBaseUrl: 'https://custom.example.com', + fetch: expect.any(Function), + }); + + process.env.BACKSTAGE_MANIFEST_BASE_URL = originalEnv; + }); + + it('uses yarn httpUtils for fetching with proper response format', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const mockResponse = { + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }; + + mockHttpUtils.get.mockResolvedValue(mockResponse); + + await getPackageVersion(descriptor, mockConfiguration); + + expect(mockGetManifestByVersion).toHaveBeenCalledTimes(1); + + // Get the fetch function that was passed to getManifestByVersion + const fetchFunction = mockGetManifestByVersion.mock.calls[0][0].fetch; + + // Test the custom fetch function + const testUrl = 'https://example.com/manifest.json'; + const fetchResult = await fetchFunction!(testUrl); + + expect(mockHttpUtils.get).toHaveBeenCalledWith(testUrl, { + configuration: mockConfiguration, + jsonResponse: true, + }); + + expect(fetchResult).toEqual({ + status: 200, + url: testUrl, + json: expect.any(Function), + }); + + expect(await fetchResult.json()).toEqual(mockResponse); + }); + }); + + describe('error cases', () => { + it('throws error for unsupported protocol', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'npm:^1.0.0', + } as Descriptor; + + mockStructUtils.parseRange.mockReturnValue({ + protocol: 'npm:', + selector: '^1.0.0', + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Unsupported version protocol in version range "npm:^1.0.0" for package @backstage/core-plugin-api', + ); + }); + + it('throws error for unexpected version selector', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:~1.0.0', + } as Descriptor; + + mockStructUtils.parseRange.mockReturnValue({ + protocol: 'backstage:', + selector: '~1.0.0', + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Unexpected version selector "~1.0.0" for package @backstage/core-plugin-api', + ); + }); + + it('throws error when package is not found in manifest', async () => { + const descriptor = { + scope: '@backstage', + name: 'non-existent-package', + range: 'backstage:^', + } as Descriptor; + + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Package @backstage/non-existent-package not found in manifest for Backstage v1.23.4. ' + + 'This means the specified package is not included in this Backstage ' + + 'release. This may imply the package has been replaced with an alternative - ' + + 'please review the documentation for the package. If you need to continue ' + + 'using this package, it will be necessary to switch to manually managing its ' + + 'version.', + ); + }); + + it('propagates errors from getManifestByVersion', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const manifestError = new Error('Failed to fetch manifest'); + mockGetManifestByVersion.mockRejectedValue(manifestError); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('Failed to fetch manifest'); + }); + + it('propagates errors from getCurrentBackstageVersion', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const versionError = new Error('Failed to get current version'); + mockGetCurrentBackstageVersion.mockImplementation(() => { + throw versionError; + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('Failed to get current version'); + }); + }); + + describe('different package name formats', () => { + beforeEach(() => { + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: 'backstage-plugin-simple', version: '0.5.0' }, + { name: '@internal/custom-package', version: '2.1.0' }, + ], + }); + }); + + it('handles scoped packages correctly', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('1.9.0'); + }); + + it('handles non-scoped packages correctly', async () => { + const descriptor = { + name: 'backstage-plugin-simple', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple'); + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('0.5.0'); + }); + + it('handles packages with different scopes', async () => { + const descriptor = { + scope: '@internal', + name: 'custom-package', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@internal/custom-package', + ); + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('2.1.0'); + }); + }); + + describe('BACKSTAGE_MANIFEST_FILE environment variable', () => { + const manifestFilePath = '/path/to/manifest.json'; + + beforeEach(() => { + // Clean up environment variables + delete process.env.BACKSTAGE_MANIFEST_FILE; + }); + + afterEach(() => { + // Clean up environment variables + delete process.env.BACKSTAGE_MANIFEST_FILE; + }); + + it('reads manifest from file when BACKSTAGE_MANIFEST_FILE is set', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('1.9.0'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + expect(mockHttpUtils.get).not.toHaveBeenCalled(); + }); + + it('finds package in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: '@backstage/core-plugin-api', version: '2.1.0' }, + { name: '@backstage/backend-common', version: '0.19.5' }, + { name: 'simple-package', version: '1.0.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'backend-common', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@backstage/backend-common', + ); + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('0.19.5'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + }); + + it('throws error when package not found in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'missing-package', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@backstage/missing-package', + ); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Package @backstage/missing-package not found in manifest for Backstage v1.23.4. ' + + 'This means the specified package is not included in this Backstage ' + + 'release. This may imply the package has been replaced with an alternative - ' + + 'please review the documentation for the package. If you need to continue ' + + 'using this package, it will be necessary to switch to manually managing its ' + + 'version.', + ); + + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + + it('propagates errors from xfs.readJsonSync when file cannot be read', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const fileError = new Error('ENOENT: no such file or directory'); + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockImplementation(() => { + throw fileError; + }); + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('ENOENT: no such file or directory'); + + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + + it('handles non-scoped packages in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: 'backstage-plugin-simple', version: '0.5.0' }, + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + name: 'backstage-plugin-simple', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple'); + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('0.5.0'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/yarn-plugin/src/util/getPackageVersion.ts b/packages/yarn-plugin/src/util/getPackageVersion.ts index 25ff795c06..ed1c1ee6d8 100644 --- a/packages/yarn-plugin/src/util/getPackageVersion.ts +++ b/packages/yarn-plugin/src/util/getPackageVersion.ts @@ -20,10 +20,12 @@ import { httpUtils, structUtils, } from '@yarnpkg/core'; +import { PortablePath, xfs } from '@yarnpkg/fslib'; import { getManifestByVersion } from '@backstage/release-manifests'; import { PROTOCOL } from '../constants'; import { getCurrentBackstageVersion } from './getCurrentBackstageVersion'; +import { env } from 'process'; export const getPackageVersion = async ( descriptor: Descriptor, @@ -45,43 +47,46 @@ export const getPackageVersion = async ( } const backstageVersion = getCurrentBackstageVersion(); + const manifestFile = env.BACKSTAGE_MANIFEST_FILE; + const manifest = manifestFile + ? await xfs.readJsonSync(manifestFile as PortablePath) + : await getManifestByVersion({ + version: backstageVersion, + versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + // We override the fetch function used inside getManifestByVersion with a + // custom implementation that calls yarn's built-in `httpUtils` method + // instead. This has a couple of benefits: + // + // 1. This means that the fetch should leverage yarn's built-in cache, so we + // don't need to explicitly memoize the fetch. + // 2. The request should automatically take account of any proxy settings + // configured in yarn. + fetch: async (url: string) => { + const response = await httpUtils.get(url, { + configuration, + jsonResponse: true, + }); - const manifest = await getManifestByVersion({ - version: backstageVersion, - // We override the fetch function used inside getManifestByVersion with a - // custom implementation that calls yarn's built-in `httpUtils` method - // instead. This has a couple of benefits: - // - // 1. This means that the fetch should leverage yarn's built-in cache, so we - // don't need to explicitly memoize the fetch. - // 2. The request should automatically take account of any proxy settings - // configured in yarn. - fetch: async (url: string) => { - const response = await httpUtils.get(url, { - configuration, - jsonResponse: true, + // The release-manifests package expects fetchFn to resolve with a subset + // of the native HTTP Response object, but yarn's httpUtils implementation + // keeps most of the details hidden. This means we need to construct an + // object which quacks like a Response in the appropriate ways. + return { + // The function has some custom handling for non-200 errors. Yarn + // doesn't provide the status code, but if we've got to this point + // without throwing, we can assume the request has been successful. + status: 200, + // The requested URL, used to correctly report errors + url, + // Yarn automatically parses the response as JSON, so our implementation + // can simply return it. + json: () => response, + }; + }, }); - // The release-manifests package expects fetchFn to resolve with a subset - // of the native HTTP Response object, but yarn's httpUtils implementation - // keeps most of the details hidden. This means we need to construct an - // object which quacks like a Response in the appropriate ways. - return { - // The function has some custom handling for non-200 errors. Yarn - // doesn't provide the status code, but if we've got to this point - // without throwing, we can assume the request has been successful. - status: 200, - // The requested URL, used to correctly report errors - url, - // Yarn automatically parses the response as JSON, so our implementation - // can simply return it. - json: () => response, - }; - }, - }); - const manifestEntry = manifest.packages.find( - candidate => candidate.name === ident, + (candidate: { name: string; version: string }) => candidate.name === ident, ); if (!manifestEntry) {