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) {