diff --git a/docs/custom-registries.md b/docs/custom-registries.md index 0d3f4579f0..5c3f227939 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -203,7 +203,6 @@ https://github.com/containerbase/mono-prebuild/releases/download/6.14.1/mono-6.1 https://github.com/containerbase/mono-prebuild/releases/download/6.14.1/mono-6.14.1-jammy-x86_x64.tar.xz https://github.com/containerbase/mono-prebuild/releases/download/6.14.1/mono-6.14.1-jammy-aarch64.tar.xz.sha512 https://github.com/containerbase/mono-prebuild/releases/download/6.14.1/mono-6.14.1-jammy-aarch64.tar.xz -https://github.com/containerbase/mono-prebuild/releases/latest/download/version ``` #### `nuget` diff --git a/package.json b/package.json index 7d8ad08d65..7851d5d18a 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "got": "15.1.0", "ini": "7.0.0", "inversify": "8.2.3", + "ms": "2.1.3", "node-ipc": "12.0.0", "pino": "10.4.0", "pino-pretty": "13.2.0", @@ -71,6 +72,7 @@ "@types/common-tags": "1.8.4", "@types/global-agent": "3.0.0", "@types/ini": "4.1.1", + "@types/ms": "2.1.0", "@types/node": "24.19.2", "@types/node-ipc": "9.2.3", "@types/semver": "7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0276a8bed8..87e6b16665 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -224,6 +224,9 @@ importers: markdownlint: specifier: '*' version: 0.42.0(supports-color@7.2.0) + ms: + specifier: 2.1.3 + version: 2.1.3 node-ipc: specifier: 12.0.0 version: 12.0.0 @@ -273,6 +276,9 @@ importers: '@types/ini': specifier: 4.1.1 version: 4.1.1 + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 '@types/node': specifier: 24.19.2 version: 24.19.2 diff --git a/src/cli/command/install-npm.spec.ts b/src/cli/command/install-npm.spec.ts index eacb2273f4..96247b29cc 100644 --- a/src/cli/command/install-npm.spec.ts +++ b/src/cli/command/install-npm.spec.ts @@ -35,6 +35,13 @@ describe('cli/command/install-npm', () => { vi.stubEnv('DEL_CLI_VERSION', '5.0.0'); expect(await cli.run(['del-cli'])).toBe(0); + // install-npm never reads the tool-versions data + expect(mocks.resolveVersion).toHaveBeenLastCalledWith( + 'del-cli', + '5.0.0', + 'npm', + false, + ); expect(mocks.installTool).toHaveBeenCalledTimes(2); expect(mocks.installTool).toHaveBeenCalledWith( 'del-cli', diff --git a/src/cli/command/install-tool.spec.ts b/src/cli/command/install-tool.spec.ts index 1fc767b285..6f8b5d3330 100644 --- a/src/cli/command/install-tool.spec.ts +++ b/src/cli/command/install-tool.spec.ts @@ -38,6 +38,13 @@ describe('cli/command/install-tool', () => { ); vi.stubEnv('NODE_VERSION', '16.13.0'); expect(await cli.run(['node'])).toBe(0); + // install-tool may read the tool-versions data + expect(mocks.resolveVersion).toHaveBeenLastCalledWith( + 'node', + '16.13.0', + undefined, + true, + ); expect(mocks.installTool).toHaveBeenCalledTimes(1); expect(mocks.installTool).toHaveBeenCalledWith( 'node', diff --git a/src/cli/command/install-tool.ts b/src/cli/command/install-tool.ts index 5f0ab8111f..d28ff7c44c 100644 --- a/src/cli/command/install-tool.ts +++ b/src/cli/command/install-tool.ts @@ -75,7 +75,15 @@ export class InstallToolCommand extends Command { logger.debug( `Try resolving version for ${this.name}@${version ?? 'latest'} ...`, ); - version = await resolveVersion(this.name, version, type); + // only `install-tool` reads the tool-versions data, `install-gem`, + // `install-npm` and `install-pip` set `this.type` and always ask their + // registry + version = await resolveVersion( + this.name, + version, + type, + this.type === undefined, + ); if (!isNonEmptyStringAndNotWhitespace(version)) { error = true; diff --git a/src/cli/install-tool/index.spec.ts b/src/cli/install-tool/index.spec.ts index 5f0474f332..a92b017724 100644 --- a/src/cli/install-tool/index.spec.ts +++ b/src/cli/install-tool/index.spec.ts @@ -3,6 +3,7 @@ import { beforeAll, describe, expect, test, vi } from 'vitest'; import { IpcServer, PathService, + ToolVersionsService, VersionService, createContainer, } from '../services/index.ts'; @@ -241,6 +242,61 @@ describe('cli/install-tool/index', () => { vi.spyOn(svc.prototype, 'resolve').mockResolvedValue('1.0.0'); expect(await resolveVersion('dummy', '1.0.0', type)).toBe('1.0.0'); }); + + test.each(['flux', 'pnpm', 'npm', 'yarn'])( + 'resolves install-npm %s with the registry, not the tool-versions data', + async (tool) => { + // the data has a file with the name of the package + await fs.mkdir(rootPath('usr/local/containerbase/tool-versions/v1'), { + recursive: true, + }); + await fs.writeFile( + rootPath(`usr/local/containerbase/tool-versions/v1/${tool}.json`), + JSON.stringify({ + tool, + updatedAt: '2026-10-16T00:00:00Z', + type: 'npm', + latest: '9.9.9', + versions: [{ version: '9.9.9' }], + }), + ); + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + const data = vi.spyOn( + ToolVersionsService.prototype, + 'getResolveVersions', + ); + // the yarn resolver of install-tool is used for `yarn` too + vi.spyOn(NpmVersionResolver.prototype, 'resolve').mockResolvedValue( + '1.2.3', + ); + + // install-npm turns the data off + expect(await resolveVersion(tool, 'latest', 'npm', false)).toBe( + '1.2.3', + ); + + expect(embedded).not.toHaveBeenCalled(); + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([ + { tool: 'php', version: '8.3', type: 'npm' as const }, + { tool: 'php', version: '8.3.10', type: 'npm' as const }, + { tool: 'mise', version: '2026.10', type: 'pip' as const }, + { tool: 'mise', version: '2026.10.21', type: 'pip' as const }, + ])( + 'keeps install-$type $tool $version without the tool-versions data', + async ({ tool, version, type }) => { + // the latest-only resolver of install-tool answers, it keeps any + // version but `latest` + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await resolveVersion(tool, version, type, false)).toBe(version); + + expect(embedded).not.toHaveBeenCalled(); + }, + ); }); describe('linkTool', () => { diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 69caf8a905..7c483c8826 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -453,12 +453,17 @@ export async function linkTool( /** * Resolves the version to install, eg. `latest` to a concrete version. For a - * `gem`, `npm` or `pip` type, a generic resolver is registered first. + * `gem`, `npm` or `pip` type, a generic resolver is registered after the tool + * resolvers, so a tool's own resolver wins. + * @param data - whether the resolvers may read the tool-versions data first: + * true for `install-tool`, false for `install-gem`, `install-npm` and + * `install-pip`, which always ask their registry */ export async function resolveVersion( tool: string, version: string | undefined, type?: InstallToolType, + data = true, ): Promise { const container = prepareResolveContainer(); @@ -468,7 +473,7 @@ export async function resolveVersion( .to(createGenericVersionResolver(type, tool)); } const svc = await container.getAsync(ToolVersionResolverService); - return svc.resolve(tool, version); + return svc.resolve(tool, version, data); } interface UninstallToolConfig { diff --git a/src/cli/install-tool/tool-version-resolver.service.spec.ts b/src/cli/install-tool/tool-version-resolver.service.spec.ts index 2efe19dcf0..396b1fd7f2 100644 --- a/src/cli/install-tool/tool-version-resolver.service.spec.ts +++ b/src/cli/install-tool/tool-version-resolver.service.spec.ts @@ -4,6 +4,7 @@ import { type Container, injectFromHierarchy, injectable } from 'inversify'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { EnvService, + HttpService, ToolVersionsService, createContainer, } from '../services/index.ts'; @@ -27,6 +28,66 @@ class DummyVersionResolver extends ToolVersionResolver { } } +@injectable() +@injectFromHierarchy() +class LatestOnlyVersionResolver extends ToolVersionResolver { + readonly tool = 'latest-only'; + override readonly latestOnly = true; + + /** Resolves any version to `9.9.9`. */ + resolve(_version: string | undefined): Promise { + return Promise.resolve('9.9.9'); + } +} + +@injectable() +@injectFromHierarchy() +class LtsDataVersionResolver extends ToolVersionResolver { + readonly tool = 'lts-data'; + override readonly dataField = 'lts'; + + /** Resolves any version to `9.9.9`, so its use is visible. */ + resolve(_version: string | undefined): Promise { + return Promise.resolve('9.9.9'); + } +} + +@injectable() +@injectFromHierarchy() +class NpmDataVersionResolver extends ToolVersionResolver { + readonly tool = 'npm-data'; + override readonly dataField = 'latest'; + + /** Resolves any version to `9.9.9`, so its use is visible. */ + resolve(_version: string | undefined): Promise { + return Promise.resolve('9.9.9'); + } +} + +@injectable() +@injectFromHierarchy() +class ExactDataVersionResolver extends ToolVersionResolver { + readonly tool = 'exact-data'; + override readonly dataField = 'lts'; + + /** Keeps the version, like node does for an exact one. */ + resolve(version: string | undefined): Promise { + return Promise.resolve(version); + } +} + +@injectable() +@injectFromHierarchy() +class BuildDataVersionResolver extends ToolVersionResolver { + readonly tool = 'build-data'; + override readonly dataField = 'lts'; + + /** Appends the build `+8`, like java does for an exact version. */ + resolve(version: string | undefined): Promise { + return Promise.resolve(`${version}+8`); + } +} + vi.mock('node:os', async (importOriginal) => ({ ...(await importOriginal()), arch: vi.fn(() => 'x64'), @@ -57,6 +118,7 @@ type Entry = | { version: string; lts?: true; + deprecated?: true; archs?: string[]; distros?: string[]; }; @@ -136,6 +198,8 @@ describe('cli/install-tool/tool-version-resolver.service', () => { // no embedded data unless a test writes it await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true }); + // no embedded release, so the online data is looked up + await rm(`${dir}/../release.json`, { force: true }); // the host runs containerbase 14.51.0 vi.stubGlobal('CONTAINERBASE_VERSION', '14.51.0'); @@ -150,6 +214,11 @@ describe('cli/install-tool/tool-version-resolver.service', () => { child = createContainer(); child.bind(ToolVersionResolverService).toSelf(); child.bind(TOOL_VERSION_RESOLVER).to(DummyVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(LatestOnlyVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(LtsDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(NpmDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(ExactDataVersionResolver); + child.bind(TOOL_VERSION_RESOLVER).to(BuildDataVersionResolver); svc = await child.getAsync(ToolVersionResolverService); }); @@ -157,6 +226,375 @@ describe('cli/install-tool/tool-version-resolver.service', () => { vi.unstubAllGlobals(); }); + describe('a resolver with an lts data field', () => { + /** Releases of a tool with LTS lines, newest first. */ + const lts: Entry[] = [ + '25.1.0', + '24.2.0-lts', + '24.1.0-lts', + '23.0.0', + '22.5.0-lts', + ]; + + beforeEach(async () => { + // the data is only read for a tool with an embedded file + await embed('lts-data', ['0.0.1']); + }); + + test.each([undefined, '', 'latest'])( + 'resolves %j to the newest lts release of the data', + async (version) => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', version)).toBe('24.2.0'); + }, + ); + + test('resolves a partial version from the data', async () => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', '23')).toBe('23.0.0'); + }); + + test('uses the resolver when the partial version has no match, without a warning', async () => { + serve('lts-data', 200, { ...toolFile('lts-data', lts), lts: true }); + + expect(await svc.resolve('lts-data', '26')).toBe('9.9.9'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('uses the resolver when the online data has no lts field', async () => { + serve('lts-data', 200, lts); + + expect(await svc.resolve('lts-data', 'latest')).toBe('9.9.9'); + }); + + test('uses the resolver, not the embedded data, when the online data is not available', async () => { + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', lts), lts: true }), + ); + serve('lts-data', 404); + + expect(await svc.resolve('lts-data', 'latest')).toBe('9.9.9'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('uses the embedded data when the online data is turned off', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', lts), lts: true }), + ); + + expect(await svc.resolve('lts-data', 'latest')).toBe('24.2.0'); + }); + + test('uses the resolver when the embedded data has no lts field', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await embed('lts-data', lts); + + expect(await svc.resolve('lts-data', '24')).toBe('9.9.9'); + }); + + test('skips the data of a tool without an embedded file', async () => { + await rm(`${dir}/lts-data.json`); + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('lts-data', undefined)).toBe('9.9.9'); + + expect(online).not.toHaveBeenCalled(); + }); + + test.each([undefined, '24'])( + 'skips the data for %j when it is turned off, like for install-npm', + async (version) => { + const data = vi.spyOn( + ToolVersionsService.prototype, + 'getResolveVersions', + ); + + expect(await svc.resolve('lts-data', version, false)).toBe('9.9.9'); + + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([undefined, 'latest', '24'])( + 'warns and uses the resolver for %j when every match needs a newer containerbase', + async (version) => { + serve('lts-data', 200, { + ...toolFile('lts-data', lts), + lts: true, + minCb: [{ version: '22.5.0', cb: '15.0.0' }], + }); + + expect(await svc.resolve('lts-data', version)).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'lts-data 24.2.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used', + ); + }, + ); + + test.each([ + { + name: 'turned off online data', + setup: () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + return Promise.resolve(); + }, + // the embedded data may be months old then + note: ' (embedded, possibly outdated tool-versions data)', + }, + { + name: 'current embedded data', + setup: () => + writeFile( + `${dir}/../release.json`, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - 60_000).toISOString(), + }), + ), + note: '', + }, + ])( + 'notes the embedded data in the newer containerbase warning with $name', + async ({ setup, note }) => { + await setup(); + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ + ...toolFile('lts-data', lts), + lts: true, + minCb: [{ version: '22.5.0', cb: '15.0.0' }], + }), + ); + + expect(await svc.resolve('lts-data', 'latest')).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `lts-data 24.2.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used${note}`, + ); + }, + ); + + test('keeps a release the embedded data with the lts field lists', async () => { + await writeFile( + `${dir}/lts-data.json`, + JSON.stringify({ ...toolFile('lts-data', ['8.10']), lts: true }), + ); + + expect(await svc.resolve('lts-data', '8.10')).toBe('8.10'); + }); + + test('passes an exact version to the resolver', async () => { + expect(await svc.resolve('lts-data', '24.1.0')).toBe('9.9.9'); + }); + + test('warns and keeps an exact version the embedded data lists as needing a newer containerbase', async () => { + await embed( + 'exact-data', + ['26.13.0', '24.1.0'], + [{ version: '26.13.0', cb: '15.0.0' }], + ); + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('exact-data', '26.13.0')).toBe('26.13.0'); + + expect(online).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'exact-data 26.13.0 needs containerbase 15.0.0 or newer, running 14.51.0, 26.13.0 is used as is', + ); + }); + + test('warns about the version the resolver resolves an exact one to', async () => { + // the data only lists the builds, like for java + await embed( + 'build-data', + ['11.0.25+9', '11.0.24+8'], + [{ version: '11.0.24+8', cb: '15.0.0' }], + ); + + expect(await svc.resolve('build-data', '11.0.24')).toBe('11.0.24+8'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'build-data 11.0.24+8 needs containerbase 15.0.0 or newer, running 14.51.0, 11.0.24+8 is used as is', + ); + }); + + test('reads no data for an exact version when it is turned off, like for install-npm', async () => { + await embed( + 'exact-data', + ['26.13.0', '24.1.0'], + [{ version: '26.13.0', cb: '15.0.0' }], + ); + const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('exact-data', '26.13.0', false)).toBe('26.13.0'); + + expect(embedded).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('npm tools', () => { + /** + * The data of an npm tool with the `latest` dist-tag. + * @param tool - the tool name + * @param list - the versions, newest first + * @param latest - the `latest` dist-tag + */ + function npmFile(tool: string, list: Entry[], latest: string): object { + return { ...toolFile(tool, list), type: 'npm', latest }; + } + + beforeEach(async () => { + // the data is only read for a tool with an embedded file + await embed('npm-data', ['0.0.1']); + }); + + test.each([undefined, 'latest'])( + 'resolves %j to the latest dist-tag, not the newest release', + async (version) => { + serve( + 'npm-data', + 200, + npmFile('npm-data', ['11.0.0', '10.9.0', '10.8.0'], '10.9.0'), + ); + + expect(await svc.resolve('npm-data', version)).toBe('10.9.0'); + }, + ); + + test.each([ + // the latest dist-tag wins over a newer release of the line + { version: '10', latest: '10.8.0', expected: '10.8.0' }, + // a deprecated latest dist-tag is skipped + { version: '10', latest: '10.9.0', expected: '10.8.0' }, + // a line without the latest dist-tag + { version: '9', latest: '10.8.0', expected: '9.2.0' }, + // only deprecated releases match + { version: '8', latest: '10.8.0', expected: '8.1.0' }, + ])( + 'resolves $version with the latest dist-tag $latest', + async ({ version, latest, expected }) => { + serve( + 'npm-data', + 200, + npmFile( + 'npm-data', + [ + { version: '10.9.0', deprecated: true }, + '10.8.0', + '9.3.0-rc1', + { version: '9.3.0', deprecated: true }, + '9.2.0', + { version: '8.1.0', deprecated: true }, + { version: '8.0.0', deprecated: true }, + ], + latest, + ), + ); + + expect(await svc.resolve('npm-data', version)).toBe(expected); + }, + ); + + test('uses the resolver when the data has no latest dist-tag', async () => { + serve('npm-data', 200, ['10.9.0']); + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + }); + + test.each([ + { + name: 'needs a newer containerbase', + file: { + ...npmFile( + 'npm-data', + [ + '11.0.0', + '10.9.0', + { version: '10.8.0', deprecated: true }, + '10.7.0', + ], + '10.9.0', + ), + minCb: [{ version: '10.9.0', cb: '15.0.0' }], + }, + expected: '10.7.0', + }, + { + name: 'is not available for the host', + file: npmFile( + 'npm-data', + [{ version: '10.9.0', archs: ['arm64'] }, '10.8.0'], + '10.9.0', + ), + expected: '10.8.0', + }, + { + name: 'is not installable and only deprecated releases are', + file: npmFile( + 'npm-data', + [ + { version: '10.9.0', archs: ['arm64'] }, + { version: '10.8.0', deprecated: true }, + ], + '10.9.0', + ), + expected: '10.8.0', + }, + ])( + 'resolves latest to the newest installable release when the dist-tag $name', + async ({ file, expected }) => { + serve('npm-data', 200, file); + + expect(await svc.resolve('npm-data', 'latest')).toBe(expected); + }, + ); + + test('resolves latest to a prerelease dist-tag', async () => { + serve( + 'npm-data', + 200, + npmFile('npm-data', ['11.0.0-rc1', '10.9.0'], '11.0.0-rc1'), + ); + + expect(await svc.resolve('npm-data', undefined)).toBe('11.0.0-rc1'); + }); + + test('warns and uses the resolver when the whole line needs a newer containerbase', async () => { + serve('npm-data', 200, { + ...npmFile('npm-data', ['10.9.0', '10.8.0', '9.0.0'], '9.0.0'), + minCb: [{ version: '10.8.0', cb: '15.0.0' }], + }); + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'npm-data 10.9.0 needs containerbase 15.0.0 or newer, running 14.51.0, the own lookup of the tool is used', + ); + }); + + test('skips the data of a package without an embedded file', async () => { + await rm(`${dir}/npm-data.json`); + // no online mock: a request would fail the test + + expect(await svc.resolve('npm-data', '10')).toBe('9.9.9'); + }); + + test('resolves a tool without a resolver to the latest dist-tag', async () => { + serve('other', 200, npmFile('other', ['2.0.0', '1.9.0'], '1.9.0')); + + expect(await svc.resolve('other', 'latest')).toBe('1.9.0'); + }); + }); + test('resolves with the matching resolver', async () => { expect(await svc.resolve('dummy', 'latest')).toBe('1.2.3'); expect(await svc.resolve('dummy', '1.0.0')).toBe('1.0.0'); @@ -168,7 +606,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => { test('reads no data for a tool with a resolver', async () => { const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); - const online = vi.spyOn(ToolVersionsService.prototype, 'getOnlineVersions'); + const online = vi.spyOn(HttpService.prototype, 'getJson'); // there is data, but no online mock: a request would fail the test await embed('dummy', versions); @@ -179,6 +617,53 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(online).not.toHaveBeenCalled(); }); + test.each([undefined, '', ' ', 'latest'])( + 'resolves %j with a resolver which only resolves latest', + async (version) => { + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('latest-only', version)).toBe('9.9.9'); + + expect(online).not.toHaveBeenCalled(); + }, + ); + + test('resolves a partial version of a resolver which only resolves latest from the data', async () => { + serve('latest-only', 200, ['2.1.0', '2.0.0']); + + expect(await svc.resolve('latest-only', '2')).toBe('2.1.0'); + }); + + test('keeps an exact version of a resolver which only resolves latest', async () => { + await embed('latest-only', ['1.2.3']); + + expect(await svc.resolve('latest-only', '1.2.3')).toBe('1.2.3'); + }); + + test.each(['2', '1.2.3'])( + 'lets a resolver which only resolves latest answer %s without the data', + async (version) => { + await embed('latest-only', ['2.1.0', '1.2.3']); + const data = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('latest-only', version, false)).toBe('9.9.9'); + + expect(data).not.toHaveBeenCalled(); + }, + ); + + test.each([undefined, '2', '1.2.3'])( + 'keeps %j of a tool without a resolver without the data', + async (version) => { + await embed('other', ['2.1.0', '1.2.3']); + const data = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); + + expect(await svc.resolve('other', version, false)).toBe(version); + + expect(data).not.toHaveBeenCalled(); + }, + ); + test.each([undefined, 'latest'])( 'resolves %s of a tool without a resolver to the newest release', async (version) => { @@ -213,8 +698,6 @@ describe('cli/install-tool/tool-version-resolver.service', () => { { version: '3.12', resolved: '3.12.4' }, // a release itself stays as it is { version: '8.10', resolved: '8.10' }, - // no release of that line - { version: '21', resolved: '21' }, ])( 'resolves the partial version $version of a tool without a resolver', async ({ version, resolved }) => { @@ -234,7 +717,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => { test('keeps a release the embedded data lists without the online lookup', async () => { await embed('other', versions); // no online mock: a request would fail the test - const online = vi.spyOn(ToolVersionsService.prototype, 'getOnlineVersions'); + const online = vi.spyOn(HttpService.prototype, 'getJson'); expect(await svc.resolve('other', '8.10')).toBe('8.10'); @@ -291,6 +774,72 @@ describe('cli/install-tool/tool-version-resolver.service', () => { ); }); + describe.each([ + { + name: 'the embedded data is current', + setup: () => + writeFile( + `${dir}/../release.json`, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - 60_000).toISOString(), + }), + ), + note: '', + // a tool which only the online data has is looked up online + online: true, + }, + { + name: 'the online data is turned off', + setup: () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + return Promise.resolve(); + }, + // the embedded data may be months old then + note: ' (embedded, possibly outdated tool-versions data)', + online: false, + }, + ])('when $name', ({ setup, note, online }) => { + beforeEach(async () => { + await setup(); + }); + + test('resolves from the embedded data without the online lookup', async () => { + await embed('other', versions); + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('other', '20')).toBe('20.10.0'); + + expect(online).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + { tool: 'other', version: '20', resolved: '20.10.0' }, + 'version resolved from the embedded tool-versions data', + ); + }); + + test('warns when the embedded data has no match', async () => { + await embed('other', ['19.0.0']); + + expect(await svc.resolve('other', '20')).toBe('20'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `The tool-versions data has no other release matching 20, 20 is used as is${note}`, + ); + }); + + test('resolves a tool without embedded data online, unless it is turned off', async () => { + if (online) { + serve('other', 200, ['2.0.0']); + } + + // no interceptor when turned off, a request would fail the test + expect(await svc.resolve('other', 'latest')).toBe( + online ? '2.0.0' : 'latest', + ); + }); + }); + test('uses the embedded data at once when the network is down', async () => { await embed('other', versions); // no retry, a second request would find no interceptor @@ -316,22 +865,152 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('other', '3.12')).toBe('3.12.4'); }); - test('keeps the version when the online data has no match, without the embedded data', async () => { + test('warns and keeps the version when the online data has no match, without the embedded data', async () => { await embed('other', ['20.1.0']); serve('other', 200, ['19.0.0']); expect(await svc.resolve('other', '20')).toBe('20'); - expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'The tool-versions data has no other release matching 20, 20 is used as is', + ); }); - test('keeps the version when the embedded data has no match either', async () => { + test('warns and keeps the version when the embedded data has no match either', async () => { await embed('other', ['19.0.0']); serve('other', 404); expect(await svc.resolve('other', '20')).toBe('20'); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'The tool-versions data has no other release matching 20, 20 is used as is (embedded, possibly outdated tool-versions data)', + ); + }); + + test('warns and keeps a two-part version of another tool which the data does not list', async () => { + serve('other', 200, ['6.3.2', '6.3']); + + expect(await svc.resolve('other', '6.4')).toBe('6.4'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'The tool-versions data has no other release matching 6.4, 6.4 is used as is', + ); + }); + + test('keeps a two-part swift release which the data does not list yet without a warning', async () => { + serve('swift', 200, ['6.3.2', '6.3', '6.2']); + + expect(await svc.resolve('swift', '6.4')).toBe('6.4'); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + { tool: 'swift', version: '6.4' }, + 'two-part version not listed in the tool-versions data, used as is', + ); + }); + + test('warns about a listed two-part swift release which is not available for the host', async () => { + serve('swift', 200, [{ version: '6.3', archs: ['arm64'] }]); + + expect(await svc.resolve('swift', '6.3')).toBe('6.3'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No swift release matches 6.3 for amd64 on jammy, 6.3 is used as is', + ); + }); + + test('warns about a two-part swift line whose releases are not available for the host', async () => { + serve('swift', 200, [ + { version: '6.2.3', archs: ['arm64'] }, + { version: '6.2.1', archs: ['arm64'] }, + ]); + + expect(await svc.resolve('swift', '6.2')).toBe('6.2'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No swift release matches 6.2 for amd64 on jammy, 6.2 is used as is', + ); + }); + + test.each([ + { version: '6.3', resolved: '6.3' }, + { version: '6', resolved: '6.3.2' }, + ])( + 'resolves the swift version $version from the data', + async ({ version, resolved }) => { + serve('swift', 200, ['6.3.2', '6.3', '6.2']); + + expect(await svc.resolve('swift', version)).toBe(resolved); + }, + ); + + test('warns and keeps a one-part swift version without a match', async () => { + serve('swift', 200, ['6.3.2', '6.3']); + + expect(await svc.resolve('swift', '7')).toBe('7'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'The tool-versions data has no swift release matching 7, 7 is used as is', + ); + }); + + test('warns and keeps a partial version which only prereleases match', async () => { + serve('other', 200, versions); + + expect(await svc.resolve('other', '21')).toBe('21'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches 21, only prereleases like 21.0.0-rc1 do, 21 is used as is, install an exact prerelease version instead', + ); + }); + + test('warns and keeps a partial version whose release is not available for the host', async () => { + serve('other', 200, [ + '21.0.0-rc1', + { version: '21.0.0', archs: ['arm64'] }, + '20.0.0', + ]); + + expect(await svc.resolve('other', '21')).toBe('21'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches 21 for amd64 on jammy, 21 is used as is', + ); + }); + + test('warns and keeps latest when the data only has prereleases', async () => { + serve('other', 200, ['2.0.0-rc1']); + + expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches latest, only prereleases like 2.0.0-rc1 do, latest is used as is, install an exact prerelease version instead', + ); + }); + + test('warns when the data has no release at all', async () => { + serve('other', 200, { + tool: 'other', + updatedAt: '2026-10-16T00:00:00Z', + versions: [], + }); + + expect(await svc.resolve('other', undefined)).toBeUndefined(); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'The tool-versions data has no other release matching latest, no version is resolved', + ); + }); + + test('warns and keeps a partial version when the embedded data needs a newer containerbase', async () => { + await embed('other', ['3.12.5'], [{ version: '3.12.5', cb: '15.0.0' }]); + serve('other', 404); + + expect(await svc.resolve('other', '3.12')).toBe('3.12'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 3.12.5 needs containerbase 15.0.0 or newer, running 14.51.0, 3.12 is used as is (embedded, possibly outdated tool-versions data)', + ); }); test.each([undefined, 'latest', '2'])( @@ -422,6 +1101,10 @@ describe('cli/install-tool/tool-version-resolver.service', () => { ]); expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches latest for amd64 on jammy, latest is used as is', + ); }); test('uses the builds of an older distro for the releases without host distro builds', async () => { @@ -455,6 +1138,10 @@ describe('cli/install-tool/tool-version-resolver.service', () => { }); expect(await svc.resolve('other', '6.3')).toBe('6.3'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches 6.3 for amd64 on resolute, 6.3 is used as is', + ); }); test('picks a release without archs and distros lists', async () => { @@ -489,6 +1176,10 @@ describe('cli/install-tool/tool-version-resolver.service', () => { ]); expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches latest for amd64 on jammy, latest is used as is', + ); }, ); @@ -510,10 +1201,14 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('other', '8.10')).toBe('8.10.2'); }); - test('keeps the version when no release is available for the host', async () => { + test('warns and resolves no version for a missing version when no release is available for the host', async () => { serve('other', 200, [{ version: '3.0.0', archs: ['arm64'] }]); - expect(await svc.resolve('other', 'latest')).toBe('latest'); + expect(await svc.resolve('other', undefined)).toBeUndefined(); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'No other release matches latest for amd64 on jammy, no version is resolved', + ); }); test('skips the versions from the start of a range which needs a newer containerbase', async () => { @@ -625,7 +1320,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('other', '3.12')).toBe('3.12.4'); }); - test('keeps latest when every lts release needs a newer containerbase', async () => { + test('warns and keeps latest when every lts release needs a newer containerbase', async () => { serve( 'other', 200, @@ -635,6 +1330,76 @@ describe('cli/install-tool/tool-version-resolver.service', () => { // the range covers its version 24.1.0 and 25.0.0 listed above it expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 24.1.0 needs containerbase 15.0.0 or newer, running 14.51.0, latest is used as is', + ); + }); + + test('names the highest cb of the unmet ranges which cover the skipped release', async () => { + serve( + 'other', + 200, + ['3.0.0', '2.1.0', '2.0.0'], + [ + { version: '3.0.0', cb: '16.0.0' }, + { version: '2.1.0', cb: '15.0.0' }, + { version: '2.0.0', cb: '15.1.0' }, + ], + ); + + expect(await svc.resolve('other', '2')).toBe('2'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 2.1.0 needs containerbase 15.1.0 or newer, running 14.51.0, 2 is used as is', + ); + }); + + test('warns about a newer containerbase when no cb of the covering ranges is valid', async () => { + serve( + 'other', + 200, + ['2.0.0'], + [ + { version: '2.0.0', cb: '' }, + { version: '2.0.0', cb: 'new' }, + ], + ); + + expect(await svc.resolve('other', 'latest')).toBe('latest'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 2.0.0 needs a newer containerbase, running 14.51.0, latest is used as is', + ); + }); + + test('warns about a newer containerbase when an invalid cb covers the release next to a valid one', async () => { + serve( + 'other', + 200, + ['2.0.0'], + [ + { version: '2.0.0', cb: '15.0.0' }, + { version: '2.0.0', cb: 'new' }, + ], + ); + + expect(await svc.resolve('other', 'latest')).toBe('latest'); + + // an invalid cb is never met, so it wins over the valid one + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'other 2.0.0 needs a newer containerbase, running 14.51.0, latest is used as is', + ); + }); + + test('warns and keeps a listed two-part swift version which needs a newer containerbase', async () => { + serve('swift', 200, ['6.3'], [{ version: '6.3', cb: '15.0.0' }]); + + expect(await svc.resolve('swift', '6.3')).toBe('6.3'); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + 'swift 6.3 needs containerbase 15.0.0 or newer, running 14.51.0, 6.3 is used as is', + ); }); test('skips the versions of a range with an empty cb', async () => { @@ -656,11 +1421,18 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('other', 'latest')).toBe('1.0.0'); - expect(logger.debug).toHaveBeenCalledWith( - { tool: 'other', running: '14.51.0', needed: ['14.52.0', '14.51.1'] }, - 'skipping the versions which need a newer containerbase', - ); - expect(logger.debug).toHaveBeenCalledTimes(2); + const skipped = vi + .mocked(logger.debug) + .mock.calls.filter( + ([, msg]) => + msg === 'skipping the versions which need a newer containerbase', + ); + expect(skipped).toEqual([ + [ + { tool: 'other', running: '14.51.0', needed: ['14.52.0', '14.51.1'] }, + 'skipping the versions which need a newer containerbase', + ], + ]); }); test('does not log an unmet range which does not change the result', async () => { @@ -686,9 +1458,85 @@ describe('cli/install-tool/tool-version-resolver.service', () => { expect(await svc.resolve('other', '8.10')).toBe('8.10.2'); }); - test('makes no request and reads no data for an exact version', async () => { + test.each(['1.2.3', '21.0.0-rc1'])( + 'keeps the exact version %s the embedded data lists without the online lookup', + async (version) => { + await embed( + 'other', + ['21.0.0-rc1', '1.2.3', '1.0.0'], + [{ version: '1.0.0', cb: '14.0.0' }], + ); + // no online mock: a request would fail the test + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('other', version)).toBe(version); + + expect(online).not.toHaveBeenCalled(); + expect(getDistro).not.toHaveBeenCalled(); + }, + ); + + test('keeps an exact version which is only available for another host', async () => { + await embed('other', [{ version: '1.2.3', archs: ['arm64'] }]); + + expect(await svc.resolve('other', '1.2.3')).toBe('1.2.3'); + }); + + test.each(['1.2.3', '21.0.0-rc1'])( + 'warns and keeps the exact version %s the embedded data lists as needing a newer containerbase', + async (version) => { + await embed( + 'other', + ['21.0.0-rc1', '1.2.3', '1.0.0'], + [{ version: '1.2.3', cb: '15.0.0' }], + ); + + expect(await svc.resolve('other', version)).toBe(version); + + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `other ${version} needs containerbase 15.0.0 or newer, running 14.51.0, ${version} is used as is`, + ); + }, + ); + + test('keeps an exact version listed below an unmet range without a warning', async () => { + await embed( + 'other', + ['1.2.3', '1.0.0'], + [{ version: '1.2.3', cb: '15.0.0' }], + ); + + expect(await svc.resolve('other', '1.0.0')).toBe('1.0.0'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test.each(['1.2.4', '1.3.0-rc1'])( + 'keeps the exact version %s which the embedded data does not list without the online lookup', + async (version) => { + await embed('other', ['1.2.3'], [{ version: '1.2.3', cb: '15.0.0' }]); + // no online mock: a request would fail the test + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('other', version)).toBe(version); + + expect(online).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }, + ); + + test('keeps an exact version of a tool without embedded data without the online lookup', async () => { + const online = vi.spyOn(HttpService.prototype, 'getJson'); + + expect(await svc.resolve('other', '1.2.3')).toBe('1.2.3'); + + expect(online).not.toHaveBeenCalled(); + }); + + test('makes no request and reads no data for an exact version on a development build', async () => { + vi.stubGlobal('CONTAINERBASE_VERSION', '0.0.0-PLACEHOLDER'); const embedded = vi.spyOn(ToolVersionsService.prototype, 'getVersions'); - const online = vi.spyOn(ToolVersionsService.prototype, 'getOnlineVersions'); + const online = vi.spyOn(HttpService.prototype, 'getJson'); expect(await svc.resolve('other', '1.2.3')).toBe('1.2.3'); diff --git a/src/cli/install-tool/tool-version-resolver.service.ts b/src/cli/install-tool/tool-version-resolver.service.ts index 250f8c82b6..58693fd804 100644 --- a/src/cli/install-tool/tool-version-resolver.service.ts +++ b/src/cli/install-tool/tool-version-resolver.service.ts @@ -1,10 +1,18 @@ +import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { inject, injectable, multiInject } from 'inversify'; -import { filterByMinCb } from '../../../packages/base/src/min-cb.ts'; +import semverGte from 'semver/functions/gte'; +import semverValid from 'semver/functions/valid'; +import { + filterByMinCb, + minCbByVersion, +} from '../../../packages/base/src/min-cb.ts'; +import { isReleaseVersion } from '../../../packages/base/src/release-version.ts'; import { DistroCode } from '../../../packages/base/src/schema.ts'; import type { ToolVersions } from '../../../packages/base/src/tool-versions-types.ts'; import { EnvService, ToolVersionsService } from '../services/index.ts'; import { getDistro, logger } from '../utils/index.ts'; import { + type DataField, TOOL_VERSION_RESOLVER, type ToolVersionResolver, isPartialVersion, @@ -39,6 +47,66 @@ function hostDistros(data: ToolVersions, distro: string): string[] { return ubuntu.slice(0, ubuntu.indexOf(distro) + 1); } +/** + * The release picked from the data, and the one picked without the `minCb` + * filter when that differs, as it needs a newer containerbase. + */ +interface Picked { + version: string | undefined; + blocked: string | undefined; +} + +/** + * Checks if a version of the data matches a partial version, as the release + * itself or a release of its line. + */ +function matches(listed: string, version: string): boolean { + return listed === version || listed.startsWith(`${version}.`); +} + +/** + * The warning for a listed version which needs a newer containerbase. It names + * the `needed` containerbase version, see {@link minCbByVersion}, or a newer + * containerbase when it is not valid semver, eg. empty. + * @param needed - the containerbase version the tool version needs + * @param outcome - what happens instead, eg. `1.2.3 is used as is` + */ +function newerCbWarning( + tool: string, + version: string, + needed: string | undefined, + cb: string, + outcome: string, +): string { + const valid = semverValid(needed); + const newer = valid + ? `containerbase ${valid} or newer` + : 'a newer containerbase'; + return `${tool} ${version} needs ${newer}, running ${cb}, ${outcome}`; +} + +/** + * The warning for the release which the `minCb` filter skipped, see + * {@link newerCbWarning}. + * @param blocked - the skipped release, see {@link pick} + * @param outcome - what happens instead + */ +function blockedWarning( + tool: string, + data: ToolVersions, + blocked: string, + host: Host, + outcome: string, +): string { + return newerCbWarning( + tool, + blocked, + minCbByVersion(data).get(blocked), + host.cb, + outcome, + ); +} + /** * Picks a release from the data, prereleases and releases which are not * available for the host are skipped. A release is available when it has no @@ -50,52 +118,184 @@ function hostDistros(data: ToolVersions, distro: string): string[] { * release first, in the versioning of the tool. A missing version or `latest` * is the newest release, or the newest LTS release for a tool whose data has * LTS flags, even when none is available for the host, then there is no match. - * A partial version, a major (`20`) or major.minor (`3.12`) one, is the newest - * release of that line, unless it is a release itself, like `8.10`. - * @returns `undefined` when no release matches + * For an npm tool, whose data has the `latest` dist-tag, it is that release, + * also a prerelease, like npm installs it. When the dist-tag isn't available + * for the host or installable by the running containerbase, it is the newest + * release which is, and isn't deprecated, a deprecated one only when nothing + * else is. A partial version, a major (`20`) or major.minor (`3.12`) one, is the newest + * release of that line, unless it is a release itself, like `8.10`. For an npm + * tool it is the `latest` dist-tag when it matches and isn't deprecated, else + * the newest release which isn't deprecated, or the newest deprecated one when + * nothing else matches, like npm picks it. + * @returns the picked release, `undefined` when none matches, and the release + * which the `minCb` filter skipped instead, if any */ function pick( tool: string, data: ToolVersions, version: string | undefined, host: Host, -): string | undefined { +): Picked { const { versions: installable, needed } = filterByMinCb(data, host.cb); const stable = data.versions.filter((v) => !v.prerelease); const distros = hostDistros(data, host.distro); const forHost = (list: typeof data.versions): typeof data.versions => list.filter( (v) => - !v.prerelease && (!v.archs || v.archs.includes(host.arch)) && (!v.distros || v.distros.some((d) => distros.includes(d))), ); - /** Picks the requested release from the candidates, newest first. */ - const find = (releases: typeof data.versions): string | undefined => { + /** + * Picks the requested release from the candidates available for the host, + * newest first, which include the prereleases. + */ + const find = (candidates: typeof data.versions): string | undefined => { + const releases = candidates.filter((v) => !v.prerelease); if (version === undefined || version === 'latest') { + if (data.latest !== undefined) { + // an npm tool: the `latest` dist-tag, also a prerelease, like npm + // installs it, else, when it isn't installable, the newest release + // which isn't deprecated, a deprecated one only when nothing else is + return ( + candidates.find((v) => v.version === data.latest) ?? + releases.find((v) => !v.deprecated) ?? + releases[0] + )?.version; + } // the LTS flags are read before the host filter (arch, distro and // containerbase version), so a tool with LTS releases never falls back // to a release which isn't an LTS one return ( - stable.some((v) => v.lts) ? releases.find((v) => v.lts) : releases[0] + data.lts || stable.some((v) => v.lts) + ? releases.find((v) => v.lts) + : releases[0] )?.version; } if (releases.some((v) => v.version === version)) { return version; } - return releases.find((v) => v.version.startsWith(`${version}.`))?.version; + const line = releases.filter((v) => matches(v.version, version)); + if (data.latest !== undefined) { + // an npm tool, like npm: the `latest` dist-tag when it matches and isn't + // deprecated, else the newest release which isn't deprecated, a + // deprecated one only when nothing else matches + return ( + line.find((v) => v.version === data.latest && !v.deprecated) ?? + line.find((v) => !v.deprecated) ?? + line[0] + )?.version; + } + return line[0]?.version; }; const picked = find(forHost(installable)); + const unfiltered = needed.length ? find(forHost(data.versions)) : picked; // log only when the unmet ranges change the result of this request - if (needed.length && find(forHost(data.versions)) !== picked) { + if (unfiltered !== picked) { logger.debug( { tool, running: host.cb, needed }, 'skipping the versions which need a newer containerbase', ); + return { version: picked, blocked: unfiltered }; + } + return { version: picked, blocked: undefined }; +} + +/** + * The tools without a resolver which publish two-part (`x.y`) releases, like + * swift `6.2`, a two-part version whose line the data doesn't list is kept + * without a warning, as the data may not list a new release yet. + */ +// TODO: remove in the next major, when the data is required (#7962) +const twoPartReleaseTools = new Set(['protoc', 'swift']); + +/** + * Logs a warning about the tool-versions data, noting when it is the possibly + * outdated embedded data. + * @param outdated - whether the data is the possibly outdated embedded data, + * the fallback for online data which can't be loaded or is turned off, not + * current embedded data + * @param context - the tool and versions, for the log + */ +function warnData( + message: string, + outdated: boolean, + context?: Record, +): void { + const text = outdated + ? `${message} (embedded, possibly outdated tool-versions data)` + : message; + if (context) { + logger.warn(context, text); + } else { + logger.warn(text); + } +} + +/** + * Logs a missing version, `latest` or a partial version without a match in + * the data. A given version is used as is, a missing one resolves to none. It + * warns about a release which needs a newer containerbase, a request which + * only prereleases match, releases which aren't available for the host, or + * data without such a release. A two-part version of the + * {@link twoPartReleaseTools} whose line the data doesn't list at all is only + * logged at debug level. + * @param blocked - the release the `minCb` filter skipped, see {@link pick} + * @param outdated - whether the data is the possibly outdated embedded data, + * the fallback for online data which can't be loaded or is turned off, not + * current embedded data + */ +function logNoMatch( + tool: string, + data: ToolVersions, + version: string | undefined, + host: Host, + blocked: string | undefined, + outdated: boolean, +): void { + const requested = version ?? 'latest'; + if ( + blocked === undefined && + twoPartReleaseTools.has(tool) && + /^\d+\.\d+$/.test(requested) && + !data.versions.some((v) => matches(v.version, requested)) + ) { + logger.debug( + { tool, version }, + 'two-part version not listed in the tool-versions data, used as is', + ); + return; + } + + const outcome = + version === undefined + ? 'no version is resolved' + : `${version} is used as is`; + /** Logs the warning, noting when the data may be outdated. */ + const warn = (message: string): void => warnData(message, outdated); + + if (blocked !== undefined) { + warn(blockedWarning(tool, data, blocked, host, outcome)); + return; + } + const line = + version === undefined || version === 'latest' + ? data.versions + : data.versions.filter((v) => matches(v.version, version)); + if (line.some((v) => !v.prerelease)) { + warn( + `No ${tool} release matches ${requested} for ${host.arch} on ${host.distro}, ${outcome}`, + ); + } else if (line[0]) { + warn( + `No ${tool} release matches ${requested}, only prereleases like ${line[0].version} do, ${outcome}, install an exact prerelease version instead`, + ); + } else { + warn( + `The tool-versions data has no ${tool} release matching ${requested}, ${outcome}`, + ); } - return picked; } @injectable() @@ -113,55 +313,114 @@ export class ToolVersionResolverService { /** * Resolves the version with the tool's resolver. A tool without a resolver * is resolved from the tool-versions data, and keeps its version when the - * data can't answer. + * data can't answer. A resolver which only resolves `latest` (see + * {@link ToolVersionResolver.latestOnly}) gets a missing, empty or `latest` + * version, any other version is resolved from the data. A resolver with a + * {@link ToolVersionResolver.dataField} resolves a missing, empty or + * `latest` version and a partial version from the data first, when the + * embedded data has a file for the tool, so an arbitrary npm package makes + * no request for data which doesn't exist. It only gets them when the data + * can't answer, and gets any other version. The version it resolves for an + * exact one, like the java build `11.0.24+8` for `11.0.24`, is checked + * against the `minCb` ranges of the embedded data, which only warns, see + * {@link checkExact}. + * @param data - whether the tool-versions data may be read at all, + * `install-gem`, `install-npm` and `install-pip` turn it off, so they always + * ask their registry: the resolver then answers any version, also a + * latest-only one, and a tool without a resolver keeps its version */ async resolve( tool: string, version: string | undefined, + data = true, ): Promise { const resolver = this.resolver.find((r) => r.tool === tool); - if (resolver) { - return (await resolver.resolve(version)) ?? version; + const latest = + !isNonEmptyStringAndNotWhitespace(version) || version === 'latest'; + if ( + data && + resolver?.dataField && + (latest || isPartialVersion(version)) && + (await this.toolVersions.getVersions(tool)) + ) { + const resolved = await this.resolveFromData( + tool, + latest && version !== 'latest' ? undefined : version, + resolver.dataField, + ); + if (resolved !== undefined) { + return resolved; + } + } + // without the data, the resolver answers any version, also a latest-only + // one, and a tool without a resolver keeps its version + if (resolver && (latest || !resolver.latestOnly || !data)) { + const resolved = (await resolver.resolve(version)) ?? version; + if (data && resolver.dataField && !latest && !isPartialVersion(version)) { + // the version the resolver answers is the one the data lists, eg. the + // java build `11.0.24+8` for `11.0.24`, it is never undefined here + // TODO: fail in the next major instead of only warning (#7962) + await this.checkExact(tool, resolved!); + } + return resolved; + } + if (!data) { + return version; } return (await this.resolveFromData(tool, version)) ?? version; } /** * Resolves a missing version, `latest` or a partial version from the online - * tool-versions data, any other version is not checked. A partial version - * which the embedded data lists as a release, like `8.10`, is kept without - * the online lookup, so it doesn't wait for a timeout when offline. The - * embedded data is otherwise only used when the online data can't be loaded, - * as it may be outdated. Online data without a match is final, the embedded - * data is older and can't know more. A resolved version is logged at debug - * level, or with a warning when the embedded data replaces the online data. - * Only releases available for the host arch and distro, and installable by - * the running containerbase version (`minCb`), are considered, also for the - * embedded release check. Only a release version of containerbase filters by - * `minCb`, any other version, like `0.0.0-PLACEHOLDER`, is a development - * build and doesn't. - * @returns `undefined` when no data has a match + * tool-versions data, any other version is kept after the {@link checkExact} + * check. A partial version which the embedded data lists as a release, like + * `8.10`, is kept without the online lookup, so it doesn't wait for a + * timeout when offline. The data comes from + * {@link ToolVersionsService.getResolveVersions}: the online data, unless it + * is turned off or the embedded data is current, else the embedded data, + * which is only a possibly outdated fallback when the online data can't be + * loaded. A resolved version is logged at debug level, or with a warning + * when the embedded data replaces the online data, a version without a match + * is logged by {@link logNoMatch}. Only releases available for the host arch + * and distro, and installable by the running containerbase version + * (`minCb`), are considered, also for the embedded release check. Only a + * release version of containerbase filters by `minCb`, any other version, + * like `0.0.0-PLACEHOLDER`, is a development build and doesn't. + * @param required - the field the data needs for the resolver of the tool, + * see {@link ToolVersionResolver.dataField}: data without it, and the + * embedded fallback for online data which can't be loaded, are skipped, as + * the resolver asks its own source; no match is only logged at debug level + * then, matching releases which all need a newer containerbase with a + * warning, and the resolver answers like before + * @returns an exact version as given, else the resolved release, or + * `undefined` when the data has no match or the tool has no data */ private async resolveFromData( tool: string, version: string | undefined, + required?: DataField, ): Promise { - const exact = - version !== undefined && - version !== 'latest' && - !isPartialVersion(version); - if (exact) { - return undefined; + const given = version !== undefined && version !== 'latest'; + if (given && !isPartialVersion(version)) { + await this.checkExact(tool, version); + return version; } + /** Whether the data has the field the resolver of the tool needs. */ + const usable = (data: ToolVersions | undefined): data is ToolVersions => + !!data && (!required || data[required] !== undefined); + const host: Host = { arch: this.env.arch, distro: (await getDistro()).versionCode, cb: this.env.version, }; - if (version !== undefined && version !== 'latest') { + if (given) { const listed = await this.toolVersions.getVersions(tool); - if (listed && pick(tool, listed, version, host) === version) { + if ( + usable(listed) && + pick(tool, listed, version, host).version === version + ) { logger.debug( { tool, version, resolved: version }, 'version resolved from the embedded tool-versions data', @@ -170,26 +429,90 @@ export class ToolVersionResolverService { } } - const online = await this.toolVersions.getOnlineVersions(tool); - if (online) { - const resolved = pick(tool, online, version, host); - if (resolved !== undefined) { + // a tool with a resolver asks its own source when the online data can't + // be loaded, not the possibly outdated embedded data + const found = await this.toolVersions.getResolveVersions(tool, !required); + if (!found) { + return undefined; + } + const { data, source } = found; + if (!usable(data)) { + logger.debug( + { tool, required }, + 'tool-versions data without the field, using the resolver', + ); + return undefined; + } + const { version: resolved, blocked } = pick(tool, data, version, host); + if (resolved === undefined) { + // the embedded data may be outdated when the online data was looked up + // or is turned off, not when it is current + const outdated = source === 'fallback' || source === 'offline'; + if (!required) { + logNoMatch(tool, data, version, host, blocked, outdated); + } else if (blocked === undefined) { logger.debug( - { tool, version, resolved }, - 'version resolved from the online tool-versions data', + { tool, version }, + 'no match in the tool-versions data, using the resolver', + ); + } else { + // TODO: fail in the next major instead of using the resolver (#7962) + warnData( + blockedWarning( + tool, + data, + blocked, + host, + 'the own lookup of the tool is used', + ), + outdated, ); } - return resolved; + return undefined; } + if (source === 'fallback') { + warnData('online data not available', true, { + tool, + version, + resolved, + }); + } else { + logger.debug( + { tool, version, resolved }, + `version resolved from the ${source === 'online' ? 'online' : 'embedded'} tool-versions data`, + ); + } + return resolved; + } + /** + * Checks an exact version against the `minCb` ranges of the embedded data, + * and warns when the running containerbase is too old for it. There is no + * online lookup, so an offline install doesn't wait for a timeout, and a + * version which the embedded data doesn't list passes, as the data may not + * have a new release yet. Prereleases are checked like releases, and the + * host arch and distro are not checked. The needed containerbase version + * comes from {@link minCbByVersion}. A development build of containerbase + * skips the check, see {@link filterByMinCb}. + */ + private async checkExact(tool: string, version: string): Promise { + const cb = this.env.version; + if (!isReleaseVersion(cb)) { + return; + } const data = await this.toolVersions.getVersions(tool); - const resolved = data && pick(tool, data, version, host); - if (resolved !== undefined) { + if (!data?.versions.some((v) => v.version === version)) { + return; + } + const needed = minCbByVersion(data).get(version); + // a cb which is not valid semver is never met, like for the filter + if ( + needed !== undefined && + !(semverValid(needed) !== null && semverGte(cb, needed)) + ) { logger.warn( - { tool, version, resolved }, - 'online tool-versions data not available, using the embedded, possibly outdated data', + newerCbWarning(tool, version, needed, cb, `${version} is used as is`), ); } - return resolved; } } diff --git a/src/cli/install-tool/tool-version-resolver.ts b/src/cli/install-tool/tool-version-resolver.ts index f10db47705..f17ebd183e 100644 --- a/src/cli/install-tool/tool-version-resolver.ts +++ b/src/cli/install-tool/tool-version-resolver.ts @@ -5,6 +5,12 @@ import { EnvService, HttpService, lookupOptions } from '../services/index.ts'; export const TOOL_VERSION_RESOLVER = Symbol('TOOL_VERSION_RESOLVER'); +/** + * A tool-level field of the tool-versions data a resolver needs, see + * {@link ToolVersionResolver.dataField}. + */ +export type DataField = 'lts' | 'latest'; + /** The version note shared by the tools accepting major or major.minor versions. */ export const partialVersionHelp = 'A major or major.minor version installs the newest matching release.'; @@ -73,6 +79,24 @@ export abstract class ToolVersionResolver { */ readonly versionHelp: string | undefined = undefined; + /** + * Whether the resolver only resolves a missing version or `latest`, any + * other version is resolved from the tool-versions data, like for a tool + * without a resolver. + */ + // TODO: drop in the next major, when `latest` comes from the data too (#7962) + readonly latestOnly: boolean = false; + + /** + * The field of the tool-versions data the tool's rules for a missing + * version, `latest` and partial versions need, eg. `lts` for a tool whose + * `latest` is the newest LTS release, `latest` for an npm tool. When it is + * set, those versions are resolved from data with the field first, and the + * resolver only gets them when the data has no match. `undefined` resolves + * every version with the resolver. + */ + readonly dataField: DataField | undefined = undefined; + /** * Resolves the requested version, eg. a missing version or `latest` to the * latest release. diff --git a/src/cli/install-tool/version-resolvers.spec.ts b/src/cli/install-tool/version-resolvers.spec.ts index d93a280bd0..c969bd1aa1 100644 --- a/src/cli/install-tool/version-resolvers.spec.ts +++ b/src/cli/install-tool/version-resolvers.spec.ts @@ -1,19 +1,33 @@ import { codeBlock } from 'common-tags'; +import { injectFromHierarchy, injectable } from 'inversify'; import { describe, expect, test } from 'vitest'; -import { ComposerVersionResolver } from '../tools/php/composer.ts'; +import { ToolVersionResolver } from './tool-version-resolver.ts'; import { createGenericVersionResolver, getVersionHelp, } from './version-resolvers.ts'; +@injectable() +@injectFromHierarchy() +class NoteLessVersionResolver extends ToolVersionResolver { + readonly tool = 'noteless'; + + /** Keeps the version. */ + resolve(version: string | undefined): Promise { + return Promise.resolve(version); + } +} + describe('cli/install-tool/version-resolvers', () => { test('getVersionHelp', () => { expect(getVersionHelp()).toBe(codeBlock` Some tools accept partial versions: - - \`android-sdk-cmdline-tools\`, \`bundler\`, \`checkov\`, \`cocoapods\`, \`conan\`, \`copier\`, \`gradle\`, \`hashin\`, \`kas\`, \`maven\`, \`node\`, \`nuget\`, \`pdm\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release. + - \`android-sdk-cmdline-tools\`, \`bundler\`, \`checkov\`, \`cocoapods\`, \`composer\`, \`conan\`, \`copier\`, \`gradle\`, \`hashin\`, \`kas\`, \`maven\`, \`mise\`, \`node\`, \`nuget\`, \`pdm\`, \`php\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release. - \`corepack\`, \`npm\`, \`pnpm\`, \`yarn\`: A major or major.minor version installs the matching \`latest\` release, else the newest matching one. - \`java\`, \`java-jdk\`, \`java-jre\`: A major, major.minor or major.minor.patch version, like \`21\` or \`11.0\`, installs the newest matching release. + + Other tools resolve a missing version, \`latest\` or a major or major.minor version from the containerbase tool-versions data, any other version is used as is. `); }); @@ -28,15 +42,15 @@ describe('cli/install-tool/version-resolvers', () => { }); test('getVersionHelp without version notes', () => { - expect(getVersionHelp([ComposerVersionResolver])).toBe(''); + expect(getVersionHelp([NoteLessVersionResolver])).toBe(''); }); test('getVersionHelp lets a dedicated resolver without a note win', () => { // at runtime the dedicated resolver is used, so the generic note must not show expect( getVersionHelp([ - ComposerVersionResolver, - createGenericVersionResolver('pip', 'composer'), + NoteLessVersionResolver, + createGenericVersionResolver('pip', 'noteless'), ]), ).toBe(''); }); diff --git a/src/cli/install-tool/version-resolvers.ts b/src/cli/install-tool/version-resolvers.ts index 591bb062b9..e691a0bcbd 100644 --- a/src/cli/install-tool/version-resolvers.ts +++ b/src/cli/install-tool/version-resolvers.ts @@ -74,9 +74,15 @@ const dynamicResolvers: readonly VersionResolverClass[] = Object.entries( ResolverMap, ).map(([tool, type]) => createGenericVersionResolver(type, tool)); +/** The help note on the versions of the tools without their own note. */ +const dataVersionHelp = + 'Other tools resolve a missing version, `latest` or a major or major.minor version from the containerbase tool-versions data, any other version is used as is.'; + /** * Builds the install-tool help text about tool specific versions from the - * version notes of the resolvers. Tools with the same note are listed together. + * version notes of the resolvers, followed by the resolution of the other + * tools from the tool-versions data. Tools with the same note are listed + * together. * @param resolvers - the resolvers to read the notes from * @returns the help text, or an empty string if no resolver has a note */ @@ -118,5 +124,5 @@ export function getVersionHelp( ({ note, tools }) => `- ${tools.map((tool) => `\`${tool}\``).join(', ')}: ${note}`, ); - return `Some tools accept partial versions:\n\n${lines.join('\n')}`; + return `Some tools accept partial versions:\n\n${lines.join('\n')}\n\n${dataVersionHelp}`; } diff --git a/src/cli/services/env.service.spec.ts b/src/cli/services/env.service.spec.ts index 1a933c6ab2..78da14fbff 100644 --- a/src/cli/services/env.service.spec.ts +++ b/src/cli/services/env.service.spec.ts @@ -10,7 +10,7 @@ import { test, vi, } from 'vitest'; -import { logger } from '../utils/index.ts'; +import { logger, resetWarnings } from '../utils/index.ts'; import { EnvService } from './index.ts'; import { testContainer } from '~test/di.ts'; import { rootPath } from '~test/path.ts'; @@ -44,6 +44,8 @@ describe('cli/services/env.service', () => { beforeEach(async () => { globalThis.rootDir = rootDir; mocks.arch.mockReturnValue('x64'); + // the env warnings are logged once per process + resetWarnings(); await init(); }); @@ -133,6 +135,107 @@ describe('cli/services/env.service', () => { } }); + test.each([ + { value: undefined, expected: false }, + { value: '', expected: false }, + { value: 'false', expected: false }, + { value: 'FALSE', expected: false }, + { value: 'true', expected: true }, + { value: 'True', expected: true }, + ])('resolverOffline: $value', ({ value, expected }) => { + if (value === undefined) { + delete env.CONTAINERBASE_RESOLVER_OFFLINE; + } else { + env.CONTAINERBASE_RESOLVER_OFFLINE = value; + } + try { + expect(svc.resolverOffline).toBe(expected); + expect(logger.warn).not.toHaveBeenCalled(); + } finally { + delete env.CONTAINERBASE_RESOLVER_OFFLINE; + } + }); + + test('resolverOffline warns about an invalid value once', () => { + env.CONTAINERBASE_RESOLVER_OFFLINE = 'yes'; + try { + expect(svc.resolverOffline).toBe(false); + expect(svc.resolverOffline).toBe(false); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { value: 'yes' }, + 'Invalid CONTAINERBASE_RESOLVER_OFFLINE, use true or false', + ); + } finally { + delete env.CONTAINERBASE_RESOLVER_OFFLINE; + } + }); + + test.each([ + { value: undefined, expected: 24 * 3_600_000 }, + { value: '', expected: 24 * 3_600_000 }, + { value: '2h', expected: 2 * 3_600_000 }, + { value: '1 day 2h', expected: 26 * 3_600_000 }, + // the parts are split case insensitive, `M` is a month like for Renovate + { value: '1H 30MIN', expected: 90 * 60_000 }, + { value: '1 month', expected: 30 * 24 * 3_600_000 }, + // the month word in any case, a lowercase `m` is a minute + { value: '1 Month', expected: 30 * 24 * 3_600_000 }, + { value: '2 MONTHS', expected: 60 * 24 * 3_600_000 }, + { value: '2M', expected: 60 * 24 * 3_600_000 }, + { value: '2m', expected: 2 * 60_000 }, + ])('resolverMaxAge: $value', ({ value, expected }) => { + if (value === undefined) { + delete env.CONTAINERBASE_RESOLVER_MAX_AGE; + } else { + env.CONTAINERBASE_RESOLVER_MAX_AGE = value; + } + try { + expect(svc.resolverMaxAge).toBe(expected); + expect(logger.warn).not.toHaveBeenCalled(); + } finally { + delete env.CONTAINERBASE_RESOLVER_MAX_AGE; + } + }); + + test.each(['soon', ' ', '-1h', '1h -2h', '2h -1h'])( + 'resolverMaxAge warns about the invalid value %j once and uses the default', + (value) => { + env.CONTAINERBASE_RESOLVER_MAX_AGE = value; + try { + expect(svc.resolverMaxAge).toBe(24 * 3_600_000); + expect(svc.resolverMaxAge).toBe(24 * 3_600_000); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { value }, + 'Invalid CONTAINERBASE_RESOLVER_MAX_AGE, using the default of 24 hours', + ); + } finally { + delete env.CONTAINERBASE_RESOLVER_MAX_AGE; + } + }, + ); + + test.each([ + // a plain number is milliseconds, like for Renovate + { value: '24', expected: 24 }, + // also a part of a compound value + { value: '1h30', expected: 3_600_000 + 30 }, + ])( + 'resolverMaxAge warns once that a part of $value without a unit is milliseconds', + ({ value, expected }) => { + env.CONTAINERBASE_RESOLVER_MAX_AGE = value; + try { + expect(svc.resolverMaxAge).toBe(expected); + expect(svc.resolverMaxAge).toBe(expected); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { value }, + 'CONTAINERBASE_RESOLVER_MAX_AGE has a number without a unit, it is read as milliseconds', + ); + } finally { + delete env.CONTAINERBASE_RESOLVER_MAX_AGE; + } + }, + ); + describe('containerbaseTmpDir', () => { // `node:process` is mocked with a plain `env` object, which `vi.stubEnv` doesn't reach afterEach(() => { diff --git a/src/cli/services/env.service.ts b/src/cli/services/env.service.ts index 0b7649cf74..7a9e442203 100644 --- a/src/cli/services/env.service.ts +++ b/src/cli/services/env.service.ts @@ -8,7 +8,17 @@ import { getUrlReplacements, replaceUrl, } from '../../../packages/base/src/url.ts'; -import { type Arch, isInside, logger } from '../utils/index.ts'; +import { + type Arch, + hasUnitlessPart, + isInside, + logger, + toMs, + warnOnce, +} from '../utils/index.ts'; + +/** The default max age of the embedded tool-versions data, 24 hours. */ +const defaultMaxAge = 24 * 60 * 60 * 1000; @injectable(bindingScopeValues.Singleton) export class EnvService { @@ -163,6 +173,58 @@ export class EnvService { return globalThis.CONTAINERBASE_VERSION ?? '0.0.0-PLACEHOLDER'; } + /** + * Whether the online tool-versions data is never fetched, set by + * `CONTAINERBASE_RESOLVER_OFFLINE=true`, in any case. Any other value but + * `false` or an empty one is logged as a warning, once, and doesn't turn it + * off. + */ + get resolverOffline(): boolean { + const value = env.CONTAINERBASE_RESOLVER_OFFLINE ?? ''; + const lower = value.toLowerCase(); + if (lower === 'true') { + return true; + } + if (lower !== '' && lower !== 'false') { + warnOnce('Invalid CONTAINERBASE_RESOLVER_OFFLINE, use true or false', { + value, + }); + } + return false; + } + + /** + * The age in milliseconds up to which the embedded tool-versions data is + * used without looking up the online data, from + * `CONTAINERBASE_RESOLVER_MAX_AGE`, like `24h`, `2 days` or `1 day 2h`, a + * month counts as 30 days. Defaults to 24 hours when unset, an invalid + * value or one with a negative part is logged as a warning and uses the + * default. A part without a unit, like `24` or the `30` of `1h30`, is + * milliseconds, like for Renovate, which is logged as a warning too. Each + * warning is logged once. + */ + get resolverMaxAge(): number { + const value = env.CONTAINERBASE_RESOLVER_MAX_AGE; + if (!isNonEmptyString(value)) { + return defaultMaxAge; + } + const age = toMs(value); + if (age === null) { + warnOnce( + 'Invalid CONTAINERBASE_RESOLVER_MAX_AGE, using the default of 24 hours', + { value }, + ); + return defaultMaxAge; + } + if (hasUnitlessPart(value)) { + warnOnce( + 'CONTAINERBASE_RESOLVER_MAX_AGE has a number without a unit, it is read as milliseconds', + { value }, + ); + } + return age; + } + /** Whether tool tests are skipped, set by `SKIP_VERSION`. */ get skipTests(): boolean { return !!env.SKIP_VERSION; diff --git a/src/cli/services/index.ts b/src/cli/services/index.ts index 5a50f7445e..9d2d2d7491 100644 --- a/src/cli/services/index.ts +++ b/src/cli/services/index.ts @@ -16,7 +16,10 @@ import { type ShellWrapperConfig, } from './link-tool.service.ts'; import { PathService } from './path.service.ts'; -import { ToolVersionsService } from './tool-versions.service.ts'; +import { + type ResolveVersions, + ToolVersionsService, +} from './tool-versions.service.ts'; import { V2ToolService } from './v2-tool.service.ts'; import { type InstalledTool, InstalledTools } from './version.schema.ts'; import { VersionService } from './version.service.ts'; @@ -34,6 +37,7 @@ export { lookupTimeout, toolVersionsTimeout, PathService, + type ResolveVersions, ToolVersionsService, V2ToolService, VersionService, diff --git a/src/cli/services/tool-versions.service.spec.ts b/src/cli/services/tool-versions.service.spec.ts index b92c63ed0e..f5a4727d16 100644 --- a/src/cli/services/tool-versions.service.spec.ts +++ b/src/cli/services/tool-versions.service.spec.ts @@ -1,7 +1,15 @@ import { createHash } from 'node:crypto'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; -import { logger } from '../utils/index.ts'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest'; +import { logger, resetWarnings } from '../utils/index.ts'; import { HttpService, ToolVersionsService, @@ -159,16 +167,23 @@ describe('cli/services/tool-versions.service', () => { ); }); - describe('getOnlineVersions', () => { + describe('the online data', () => { + // a tool without embedded data is resolved from the online data const baseUrl = 'https://containerbase.github.io'; test('fetches the published file once per instance', async () => { const online = data('online'); scope(baseUrl).get('/tool-versions/v1/online.json').reply(200, online); - expect(await svc.getOnlineVersions('online')).toEqual(online); + expect(await svc.getResolveVersions('online')).toEqual({ + data: online, + source: 'online', + }); // cached, a second request would fail without an interceptor - expect(await svc.getOnlineVersions('online')).toEqual(online); + expect(await svc.getResolveVersions('online')).toEqual({ + data: online, + source: 'online', + }); }); test('uses a timeout and no retries', async () => { @@ -177,7 +192,7 @@ describe('cli/services/tool-versions.service', () => { .get('/tool-versions/v1/opts.json') .reply(200, data('opts')); - await svc.getOnlineVersions('opts'); + await svc.getResolveVersions('opts'); expect(spy).toHaveBeenCalledExactlyOnceWith( 'https://containerbase.github.io/tool-versions/v1/opts.json', @@ -193,14 +208,14 @@ describe('cli/services/tool-versions.service', () => { .once() .replyWithError('connection reset'); - expect(await svc.getOnlineVersions('down')).toBeUndefined(); + expect(await svc.getResolveVersions('down')).toBeUndefined(); expect(logger.error).not.toHaveBeenCalled(); }); test('has no versions for an unreachable file', async () => { scope(baseUrl).get('/tool-versions/v1/gone.json').reply(404); - expect(await svc.getOnlineVersions('gone')).toBeUndefined(); + expect(await svc.getResolveVersions('gone')).toBeUndefined(); expect(logger.error).not.toHaveBeenCalled(); }); @@ -209,7 +224,7 @@ describe('cli/services/tool-versions.service', () => { .get('/tool-versions/v1/mixed.json') .reply(200, data('other')); - expect(await svc.getOnlineVersions('mixed')).toBeUndefined(); + expect(await svc.getResolveVersions('mixed')).toBeUndefined(); expect(logger.debug).toHaveBeenCalledWith( expect.objectContaining({ tool: 'mixed', dataTool: 'other' }), 'online tool-versions data of another tool', @@ -221,7 +236,7 @@ describe('cli/services/tool-versions.service', () => { .get('/tool-versions/v1/bad.json') .reply(200, { ...data('bad'), versions: undefined }); - expect(await svc.getOnlineVersions('bad')).toBeUndefined(); + expect(await svc.getResolveVersions('bad')).toBeUndefined(); expect(logger.debug).toHaveBeenCalledWith( expect.objectContaining({ tool: 'bad', @@ -232,6 +247,269 @@ describe('cli/services/tool-versions.service', () => { }); }); + describe('the age of the embedded data', () => { + const baseUrl = 'https://containerbase.github.io'; + /** The embedded `release.json`. */ + let release!: string; + + /** + * Writes the embedded `release.json`. + * @param age - how long ago the data was built, in milliseconds + */ + async function built(age: number): Promise { + await writeFile( + release, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - age).toISOString(), + }), + ); + } + + /** The embedded data of the tool `age`. */ + const embedded = data('age'); + + /** + * Resolves the tool `age`, the source is `embedded` while the embedded + * data is current, else `fallback`, as the online data is not available. + * @param current - whether the embedded data is expected to be current, + * else the online lookup is mocked + */ + async function resolveAge(current: boolean): Promise { + if (!current) { + scope(baseUrl).get('/tool-versions/v1/age.json').reply(404); + } + // without an interceptor a request would fail the test + return (await svc.getResolveVersions('age'))?.source; + } + + beforeEach(async () => { + release = rootPath('usr/local/containerbase/tool-versions/release.json'); + await rm(release, { force: true }); + await writeFile(`${dir}/age.json`, JSON.stringify(embedded)); + }); + + afterAll(async () => { + // the later tests run without an embedded release + await rm(release, { force: true }); + }); + + test('is not current without an embedded release', async () => { + expect(await resolveAge(false)).toBe('fallback'); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ file: release }), + 'no embedded tool-versions release', + ); + }); + + test('is not current with an invalid embedded release', async () => { + await writeFile(release, JSON.stringify({ version: '1.8.0' })); + + expect(await resolveAge(false)).toBe('fallback'); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ file: release }), + 'invalid embedded tool-versions release', + ); + }); + + test('is current within the max age', async () => { + await built(60_000); + + expect(await resolveAge(true)).toBe('embedded'); + }); + + test('checks the age once per instance', async () => { + await built(60_000); + + expect(await resolveAge(true)).toBe('embedded'); + expect(await resolveAge(true)).toBe('embedded'); + expect(logger.debug).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ release: '1.8.0', current: true }), + 'embedded tool-versions data age', + ); + }); + + test('is not current when the embedded data was built in the future', async () => { + await built(-3_600_000); + + expect(await resolveAge(false)).toBe('fallback'); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ release: '1.8.0' }), + 'embedded tool-versions data built in the future, using the online data', + ); + }); + + test('is not current when the embedded data is older than 24 hours', async () => { + await built(25 * 3_600_000); + + expect(await resolveAge(false)).toBe('fallback'); + }); + + test('uses the max age of CONTAINERBASE_RESOLVER_MAX_AGE', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_MAX_AGE', '1 week'); + await built(25 * 3_600_000); + + expect(await resolveAge(true)).toBe('embedded'); + }); + + test.each([ + // current data isn't outdated, also with the online data turned off + { age: 60_000, source: 'embedded' }, + { age: 25 * 3_600_000, source: 'offline' }, + ])( + 'tells $source data built $age ms ago when the online data is turned off', + async ({ age, source }) => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + await built(age); + + // no interceptor, a request would fail the test + expect(await resolveAge(true)).toBe(source); + }, + ); + }); + + describe('getResolveVersions', () => { + const baseUrl = 'https://containerbase.github.io'; + + test('prefers the online data', async () => { + await writeFile(`${dir}/resolve1.json`, JSON.stringify(data('resolve1'))); + const online = data('resolve1', [{ version: '2.0.0' }]); + scope(baseUrl).get('/tool-versions/v1/resolve1.json').reply(200, online); + + expect(await svc.getResolveVersions('resolve1')).toEqual({ + data: online, + source: 'online', + }); + }); + + test('uses the embedded data when the online data is turned off', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + const embedded = data('resolve2'); + await writeFile(`${dir}/resolve2.json`, JSON.stringify(embedded)); + + // no interceptor, a request would fail the test + expect(await svc.getResolveVersions('resolve2')).toEqual({ + data: embedded, + source: 'offline', + }); + }); + + test('uses the embedded data while it is current', async () => { + const release = rootPath( + 'usr/local/containerbase/tool-versions/release.json', + ); + await writeFile( + release, + JSON.stringify({ + version: '1.8.0', + buildTime: new Date(Date.now() - 60_000).toISOString(), + }), + ); + const embedded = data('resolve7'); + await writeFile(`${dir}/resolve7.json`, JSON.stringify(embedded)); + + try { + // no interceptor, a request would fail the test + expect(await svc.getResolveVersions('resolve7')).toEqual({ + data: embedded, + source: 'embedded', + }); + + // a tool which only the online data has is looked up online + const online = data('resolve8'); + scope(baseUrl) + .get('/tool-versions/v1/resolve8.json') + .reply(200, online); + expect(await svc.getResolveVersions('resolve8')).toEqual({ + data: online, + source: 'online', + }); + } finally { + await rm(release, { force: true }); + } + }); + + test('has nothing for a tool without embedded data when the online data is turned off', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + + // no interceptor, a request would fail the test + expect(await svc.getResolveVersions('resolve9')).toBeUndefined(); + }); + + test('falls back to the embedded data when the online data is not available', async () => { + const embedded = data('resolve3'); + await writeFile(`${dir}/resolve3.json`, JSON.stringify(embedded)); + scope(baseUrl).get('/tool-versions/v1/resolve3.json').reply(404); + + expect(await svc.getResolveVersions('resolve3')).toEqual({ + data: embedded, + source: 'fallback', + }); + }); + + test('has nothing without the fallback when the online data is not available', async () => { + await writeFile(`${dir}/resolve5.json`, JSON.stringify(data('resolve5'))); + scope(baseUrl).get('/tool-versions/v1/resolve5.json').reply(404); + + expect(await svc.getResolveVersions('resolve5', false)).toBeUndefined(); + }); + + test('uses the embedded data without the fallback when the online data is skipped', async () => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + const embedded = data('resolve6'); + await writeFile(`${dir}/resolve6.json`, JSON.stringify(embedded)); + + expect(await svc.getResolveVersions('resolve6', false)).toEqual({ + data: embedded, + source: 'offline', + }); + }); + + test('has nothing for a tool without data', async () => { + scope(baseUrl).get('/tool-versions/v1/resolve4.json').reply(404); + + expect(await svc.getResolveVersions('resolve4')).toBeUndefined(); + }); + }); + + describe('turned off online data', () => { + beforeEach(() => { + vi.stubEnv('CONTAINERBASE_RESOLVER_OFFLINE', 'true'); + // the warnings are logged once per process + resetWarnings(); + }); + + test('fetches no versions and warns once about offline mode', async () => { + // no interceptor, a request would fail the test + expect(await svc.getResolveVersions('off')).toBeUndefined(); + expect(await svc.getResolveVersions('off')).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + { tool: 'off' }, + 'No embedded tool-versions data, CONTAINERBASE_RESOLVER_OFFLINE turns the online data off', + ); + }); + + test('does not warn for a tool with its own resolver', async () => { + // no interceptor, a request would fail the test + expect(await svc.getResolveVersions('offres', false)).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + { tool: 'offres' }, + 'No embedded tool-versions data, CONTAINERBASE_RESOLVER_OFFLINE turns the online data off', + ); + }); + + test('fetches no version files, but reads the embedded ones', async () => { + const embedded = files('offemb', '1.0.0'); + await mkdir(`${dir}/offemb`, { recursive: true }); + await writeFile(`${dir}/offemb/1.0.0.json`, JSON.stringify(embedded)); + + expect(await svc.getVersionFiles('offemb', '1.0.0')).toEqual(embedded); + // no interceptor, a request would fail the test + expect(await svc.getVersionFiles('offemb', '2.0.0')).toBeUndefined(); + }); + }); + describe('getVersionFiles', () => { const baseUrl = 'https://containerbase.github.io'; diff --git a/src/cli/services/tool-versions.service.ts b/src/cli/services/tool-versions.service.ts index 4f482f835d..24f21eefc3 100644 --- a/src/cli/services/tool-versions.service.ts +++ b/src/cli/services/tool-versions.service.ts @@ -4,16 +4,19 @@ import { join } from 'node:path'; import { inject, injectable } from 'inversify'; import type { z } from 'zod'; import { + ReleaseInfo, ToolVersions, VersionFiles, VersionIntegrity, } from '../../../packages/base/src/tool-versions-schema.ts'; import type { + ReleaseInfo as ReleaseInfoData, ToolVersions as ToolVersionsData, VersionFiles as VersionFilesData, VersionIntegrity as VersionIntegrityData, } from '../../../packages/base/src/tool-versions-types.ts'; -import { logger } from '../utils/index.ts'; +import { logger, warnOnce } from '../utils/index.ts'; +import { EnvService } from './env.service.ts'; import { HttpService, toolVersionsTimeout } from './http.service.ts'; import { PathService } from './path.service.ts'; @@ -29,6 +32,18 @@ const publishedVersion = /^[0-9A-Za-z.+_-]+$/; type Cache = Map>; +/** + * The versions of a tool to resolve from, and where they come from: `online`, + * `embedded` when the embedded data is current, also with the online data + * turned off, `offline` when the online data is turned off and the embedded + * data may be outdated, or `fallback` when the online data can't be loaded + * and the possibly outdated embedded data replaces it. + */ +export interface ResolveVersions { + data: ToolVersionsData; + source: 'online' | 'embedded' | 'offline' | 'fallback'; +} + /** * Reads the tool-versions data of a tool, from the image or online. * @@ -46,6 +61,10 @@ export class ToolVersionsService { @inject(HttpService) private readonly http!: HttpService; + @inject(EnvService) + private readonly env!: EnvService; + + private current: Promise | undefined; private readonly embedded: Cache = new Map(); private readonly online: Cache = new Map(); private readonly files: Cache = new Map(); @@ -59,12 +78,100 @@ export class ToolVersionsService { return this.cached(this.embedded, tool, () => this.read(tool)); } + /** + * The versions of a tool to resolve from: the online data, unless it is + * turned off, see {@link EnvService.resolverOffline}, or the embedded data + * has the tool and is current, see {@link isEmbeddedCurrent}, or can't be + * loaded, else the embedded data. A tool which only the online data has is + * looked up online, unless it is turned off. Online data is final, the + * embedded data is older and can't know more. The source tells whether the + * embedded data is current, see {@link ResolveVersions}. A tool without its + * own resolver and without embedded data while the online data is turned + * off is logged as a warning, once per tool. + * @param fallback - whether the embedded data replaces online data which + * can't be loaded, a tool with its own resolver turns it off, so the + * resolver asks its own source instead; the embedded data is still used when + * the online data is turned off or the embedded data is current + * @returns `undefined` when the tool has no data, or none to use + */ + async getResolveVersions( + tool: string, + fallback = true, + ): Promise { + const offline = this.env.resolverOffline; + const data = await this.getVersions(tool); + if (offline) { + if (!data) { + const message = + 'No embedded tool-versions data, CONTAINERBASE_RESOLVER_OFFLINE turns the online data off'; + if (fallback) { + warnOnce(message, { tool }); + } else { + // the own resolver of the tool looks it up + logger.debug({ tool }, message); + } + return undefined; + } + // only embedded data older than the max age may be outdated + return { + data, + source: (await this.isEmbeddedCurrent()) ? 'embedded' : 'offline', + }; + } + if (data && (await this.isEmbeddedCurrent())) { + return { data, source: 'embedded' }; + } + const online = await this.getOnlineVersions(tool); + if (online) { + return { data: online, source: 'online' }; + } + return fallback && data ? { data, source: 'fallback' } : undefined; + } + + /** + * Whether the embedded data is current: it was built within the max age, + * see {@link EnvService.resolverMaxAge}. Embedded data without a readable + * `release.json` is not current, so the online data is used like before, + * and neither is data built in the future, as the clock of the container is + * behind then. Checked once per instance, so the `release.json` and the max + * age are read and logged once. + */ + private isEmbeddedCurrent(): Promise { + return (this.current ??= this.checkEmbeddedCurrent()); + } + + /** Checks whether the embedded data is current, see {@link isEmbeddedCurrent}. */ + private async checkEmbeddedCurrent(): Promise { + const release = await this.readRelease(); + if (!release) { + return false; + } + const age = Date.now() - Date.parse(release.buildTime); + if (age < 0) { + // the clock of the container is behind, the age can't be told + logger.debug( + { release: release.version, buildTime: release.buildTime }, + 'embedded tool-versions data built in the future, using the online data', + ); + return false; + } + const current = age < this.env.resolverMaxAge; + logger.debug( + { release: release.version, buildTime: release.buildTime, current }, + 'embedded tool-versions data age', + ); + return current; + } + /** * The published versions of a tool, fetched once per instance through the - * http service, so proxies and url replacements apply. + * http service, so proxies and url replacements apply. Only called when the + * online data isn't turned off, see {@link getResolveVersions}. * @returns `undefined` when the file can't be fetched or is invalid */ - getOnlineVersions(tool: string): Promise { + private getOnlineVersions( + tool: string, + ): Promise { return this.cached(this.online, tool, () => this.fetch(tool)); } @@ -121,6 +228,29 @@ export class ToolVersionsService { } } + /** + * Reads the `release.json` of the embedded data and validates it, never + * throws. + */ + private async readRelease(): Promise { + const file = join(this.pathSvc.usrPath, 'tool-versions/release.json'); + try { + const result = ReleaseInfo.safeParse( + JSON.parse(await readFile(file, { encoding: 'utf8' })), + ); + if (result.success) { + return result.data; + } + logger.debug( + { file, issue: result.error.issues.slice(0, 1) }, + 'invalid embedded tool-versions release', + ); + } catch (err) { + logger.debug({ file, err }, 'no embedded tool-versions release'); + } + return undefined; + } + /** Fetches the published `.json` and validates it, never throws. */ private async fetch(tool: string): Promise { const url = `${onlineToolVersionsUrl}${tool}.json`; @@ -178,11 +308,17 @@ export class ToolVersionsService { /** * Fetches the published `/.json`, verifies its bytes against * the published `.integrity.json` and validates it, never throws. + * Nothing is fetched when the online data is turned off, the installer then + * downloads from its own source. */ private async fetchFiles( tool: string, version: string, ): Promise { + if (this.env.resolverOffline) { + logger.debug({ tool, version }, 'online tool-versions files turned off'); + return undefined; + } // the version is the file name, a `+` must be url encoded const url = `${onlineToolVersionsUrl}${tool}/${encodeURIComponent(version)}.json`; try { diff --git a/src/cli/tools/dotnet/mono.spec.ts b/src/cli/tools/dotnet/mono.spec.ts index db9749e565..7324901f2f 100644 --- a/src/cli/tools/dotnet/mono.spec.ts +++ b/src/cli/tools/dotnet/mono.spec.ts @@ -9,11 +9,7 @@ import { ToolVersionsService, } from '../../services/index.ts'; import { getDistro, logger } from '../../utils/index.ts'; -import { - MonoInstallService, - MonoPrepareService, - MonoVersionResolver, -} from './mono.ts'; +import { MonoInstallService, MonoPrepareService } from './mono.ts'; import { scope } from '~test/http-mock.ts'; import { ensurePaths } from '~test/path.ts'; import { checksum, toolContext } from '~test/tool.ts'; @@ -174,15 +170,4 @@ describe('cli/tools/dotnet/mono', () => { ); }); }); - - describe('MonoVersionResolver', () => { - test('resolves latest', async () => { - scope(baseUrl) - .get('/containerbase/mono-prebuild/releases/latest/download/version') - .reply(200, '6.12.0'); - const { svc } = await toolContext(MonoVersionResolver); - - expect(await svc.resolve('latest')).toBe('6.12.0'); - }); - }); }); diff --git a/src/cli/tools/dotnet/mono.ts b/src/cli/tools/dotnet/mono.ts index 60a4f3a776..4840306cc3 100644 --- a/src/cli/tools/dotnet/mono.ts +++ b/src/cli/tools/dotnet/mono.ts @@ -3,10 +3,7 @@ import { join } from 'node:path'; import { injectFromHierarchy, injectable } from 'inversify'; import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; import { fileContent, logger, pathExists } from '../../utils/index.ts'; -import { - PrebuildInstallService, - PrebuildVersionResolver, -} from '../utils/prebuild.ts'; +import { PrebuildInstallService } from '../utils/prebuild.ts'; @injectable() @injectFromHierarchy() @@ -117,9 +114,3 @@ export class MonoInstallService extends PrebuildInstallService { await this.shellwrapper({ srcDir: src, name: 'cert-sync' }); } } - -@injectable() -@injectFromHierarchy() -export class MonoVersionResolver extends PrebuildVersionResolver { - readonly tool = 'mono'; -} diff --git a/src/cli/tools/java/resolver.ts b/src/cli/tools/java/resolver.ts index 63ddc08022..181e8c0d7a 100644 --- a/src/cli/tools/java/resolver.ts +++ b/src/cli/tools/java/resolver.ts @@ -30,9 +30,16 @@ export class JavaVersionResolver extends ToolVersionResolver { override readonly versionHelp = 'A major, major.minor or major.minor.patch version, like `21` or `11.0`, installs the newest matching release.'; + /** + * `latest` is the newest LTS release of the tool-versions data, a + * major.minor.patch version always uses the adoptium lookup. + */ + override readonly dataField = 'lts'; + /** * Resolves a missing version or `latest` to the newest adoptium lts and a - * partial version (`11`, `11.0` or `11.0.24`) to its newest ga release. + * partial version (`11`, `11.0` or `11.0.24`) to its newest ga release, + * when the tool-versions data can't answer, see {@link dataField}. * Any other version, like a full version with a build such as `17.0.12+7`, * is returned unchanged. * diff --git a/src/cli/tools/mise.spec.ts b/src/cli/tools/mise.spec.ts index ccf91740f0..67a2cb69ca 100644 --- a/src/cli/tools/mise.spec.ts +++ b/src/cli/tools/mise.spec.ts @@ -127,10 +127,11 @@ describe('cli/tools/mise', () => { }, ); - test('keeps a pinned version', async () => { + test('keeps a pinned version, which the tool-versions data resolves', async () => { const { svc } = await toolContext(MiseVersionResolver); expect(await svc.resolve('2026.2.13')).toBe('2026.2.13'); + expect(svc.latestOnly).toBe(true); }); }); }); diff --git a/src/cli/tools/mise.ts b/src/cli/tools/mise.ts index 61a3618746..11b4a8d861 100644 --- a/src/cli/tools/mise.ts +++ b/src/cli/tools/mise.ts @@ -2,7 +2,10 @@ import { join } from 'node:path'; import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../install-tool/tool-version-resolver.ts'; +import { + ToolVersionResolver, + partialVersionHelp, +} from '../install-tool/tool-version-resolver.ts'; @injectable() @injectFromHierarchy() @@ -72,6 +75,13 @@ export class MiseInstallService extends BaseInstallService { export class MiseVersionResolver extends ToolVersionResolver { readonly tool = 'mise'; + /** Other versions are resolved from the tool-versions data. */ + // TODO: drop in the next major, when `latest` comes from the data too (#7962) + override readonly latestOnly = true; + + /** Partial versions are resolved from the tool-versions data. */ + override readonly versionHelp = partialVersionHelp; + /** Resolves a missing version or `latest` from mise.jdx.dev. */ async resolve(version: string | undefined): Promise { if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { diff --git a/src/cli/tools/node/resolver.spec.ts b/src/cli/tools/node/resolver.spec.ts index 21af309eb8..838b73343a 100644 --- a/src/cli/tools/node/resolver.spec.ts +++ b/src/cli/tools/node/resolver.spec.ts @@ -289,6 +289,22 @@ describe('cli/tools/node/resolver', () => { expect(svc.tool).toBe('del-cli'); expect(await svc.resolve(undefined)).toBe('6.0.0'); }); + + test.each([ + // typed npm tools read the tool-versions data first + { tool: 'pnpm', dataField: 'latest' }, + { tool: 'npm', dataField: 'latest' }, + // any other package always uses the npm registry + { tool: 'del-cli', dataField: undefined }, + { tool: 'flux', dataField: undefined }, + ])( + 'reads the tool-versions data for $tool: $dataField', + ({ tool, dataField }) => { + const Resolver = createNpmVersionResolver(tool); + + expect(new Resolver().dataField).toBe(dataField); + }, + ); }); describe('YarnVersionResolver', () => { diff --git a/src/cli/tools/node/resolver.ts b/src/cli/tools/node/resolver.ts index 140148bc1c..ad2a165052 100644 --- a/src/cli/tools/node/resolver.ts +++ b/src/cli/tools/node/resolver.ts @@ -6,6 +6,7 @@ import { partialVersionHelp, } from '../../install-tool/tool-version-resolver.ts'; import { semverMaxSatisfying, semverSatisfies } from '../../utils/index.ts'; +import { ResolverMap, getToolType } from '../index.ts'; import { yarnPackage } from './npm.ts'; import { type NodeVersionMeta, @@ -32,8 +33,12 @@ export class NodeVersionResolver extends ToolVersionResolver { override readonly versionHelp = partialVersionHelp; + /** `latest` is the newest LTS release of the tool-versions data. */ + override readonly dataField = 'lts'; + /** - * Resolves a version from nodejs.org. + * Resolves a version from nodejs.org, when the tool-versions data can't + * answer, see {@link dataField}. * * - A missing version or `latest` resolves to the newest lts release. * - A major (`20`) or major.minor (`20.11`) version resolves to the newest @@ -74,7 +79,8 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { override readonly versionHelp = npmPartialVersionHelp; /** - * Resolves a version from the npm registry. + * Resolves a version from the npm registry, for a typed npm tool when the + * tool-versions data can't answer, see {@link dataField}. * * - A missing version or `latest` resolves to the npm `latest` dist tag. * - A major (`9`) or major.minor (`9.15`) version resolves to the `latest` @@ -145,16 +151,23 @@ export abstract class NpmVersionResolver extends ToolVersionResolver { } /** - * Creates a version resolver for a tool which is a plain npm package. + * Creates a version resolver for a tool which is a plain npm package. Only a + * typed npm tool of the {@link ResolverMap}, like `npm` or `pnpm`, reads the + * tool-versions data first, any other package installed with `install-npm` + * always uses the npm registry, as the data has no file for it. * @param tool - the tool and npm package name */ export function createNpmVersionResolver( tool: string, ): new () => NpmVersionResolver { + const typed = getToolType(ResolverMap, tool) === 'npm'; @injectable() @injectFromHierarchy() class GenericVersionResolver extends NpmVersionResolver { override readonly tool: string = tool; + + /** The `latest` dist-tag of the data, only for a typed npm tool. */ + override readonly dataField = typed ? ('latest' as const) : undefined; } return GenericVersionResolver; } @@ -164,6 +177,12 @@ export function createNpmVersionResolver( export class YarnVersionResolver extends NpmVersionResolver { readonly tool = 'yarn'; + /** + * `latest` is the `latest` dist-tag of the tool-versions data, which also + * flags the deprecated versions. + */ + override readonly dataField = 'latest'; + /** * The npm package of the requested yarn major version, see * {@link yarnPackage}. `latest` uses the `@yarnpkg/cli-dist` dist tags. diff --git a/src/cli/tools/php/composer.spec.ts b/src/cli/tools/php/composer.spec.ts index 60e295a5bf..a7c41eb36f 100644 --- a/src/cli/tools/php/composer.spec.ts +++ b/src/cli/tools/php/composer.spec.ts @@ -96,10 +96,11 @@ describe('cli/tools/php/composer', () => { expect(await svc.resolve('latest')).toBe('2.8.3'); }); - test('keeps a pinned version', async () => { + test('keeps a pinned version, which the tool-versions data resolves', async () => { const { svc } = await toolContext(ComposerVersionResolver); expect(await svc.resolve('2.8.3')).toBe('2.8.3'); + expect(svc.latestOnly).toBe(true); }); }); }); diff --git a/src/cli/tools/php/composer.ts b/src/cli/tools/php/composer.ts index 34ff06beb0..bad03ed8ed 100644 --- a/src/cli/tools/php/composer.ts +++ b/src/cli/tools/php/composer.ts @@ -1,8 +1,7 @@ import { join } from 'node:path'; -import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../../install-tool/tool-version-resolver.ts'; +import { PrebuildVersionResolver } from '../utils/prebuild.ts'; @injectable() @injectFromHierarchy() @@ -43,16 +42,6 @@ export class ComposerInstallService extends BaseInstallService { @injectable() @injectFromHierarchy() -export class ComposerVersionResolver extends ToolVersionResolver { +export class ComposerVersionResolver extends PrebuildVersionResolver { readonly tool = 'composer'; - - /** Resolves a missing version or `latest` to the latest composer prebuild. */ - async resolve(version: string | undefined): Promise { - if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { - return await this.get( - `https://github.com/containerbase/${this.tool}-prebuild/releases/latest/download/version`, - ); - } - return version; - } } diff --git a/src/cli/tools/php/index.spec.ts b/src/cli/tools/php/index.spec.ts index 7d1041466c..1bc9af1447 100644 --- a/src/cli/tools/php/index.spec.ts +++ b/src/cli/tools/php/index.spec.ts @@ -284,10 +284,11 @@ describe('cli/tools/php/index', () => { expect(await svc.resolve('latest')).toBe('8.3.13'); }); - test('keeps a pinned version', async () => { + test('keeps a pinned version, which the tool-versions data resolves', async () => { const { svc } = await toolContext(PhpVersionResolver); expect(await svc.resolve('8.3.13')).toBe('8.3.13'); + expect(svc.latestOnly).toBe(true); }); }); }); diff --git a/src/cli/tools/php/index.ts b/src/cli/tools/php/index.ts index 72fd4b262c..61b6660b64 100644 --- a/src/cli/tools/php/index.ts +++ b/src/cli/tools/php/index.ts @@ -1,10 +1,9 @@ import { join } from 'node:path'; -import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../../install-tool/tool-version-resolver.ts'; import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; import { getDistro } from '../../utils/index.ts'; +import { PrebuildVersionResolver } from '../utils/prebuild.ts'; @injectable() @injectFromHierarchy() @@ -130,16 +129,6 @@ export class PhpInstallService extends BaseInstallService { @injectable() @injectFromHierarchy() -export class PhpVersionResolver extends ToolVersionResolver { +export class PhpVersionResolver extends PrebuildVersionResolver { readonly tool = 'php'; - - /** Resolves a missing version or `latest` to the latest php prebuild. */ - async resolve(version: string | undefined): Promise { - if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { - return await this.get( - `https://github.com/containerbase/${this.tool}-prebuild/releases/latest/download/version`, - ); - } - return version; - } } diff --git a/src/cli/tools/utils/prebuild.ts b/src/cli/tools/utils/prebuild.ts index a0903b7b13..535d4809cb 100644 --- a/src/cli/tools/utils/prebuild.ts +++ b/src/cli/tools/utils/prebuild.ts @@ -2,7 +2,10 @@ import { join } from 'node:path'; import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../../install-tool/tool-version-resolver.ts'; +import { + ToolVersionResolver, + partialVersionHelp, +} from '../../install-tool/tool-version-resolver.ts'; import { getDistro } from '../../utils/index.ts'; @injectable() @@ -85,6 +88,13 @@ export abstract class PrebuildInstallService extends BaseInstallService { @injectable() @injectFromHierarchy() export abstract class PrebuildVersionResolver extends ToolVersionResolver { + /** Other versions are resolved from the tool-versions data. */ + // TODO: drop in the next major, when `latest` comes from the data too (#7962) + override readonly latestOnly = true; + + /** Partial versions are resolved from the tool-versions data. */ + override readonly versionHelp = partialVersionHelp; + /** Resolves a missing version or `latest` to the latest prebuild. */ async resolve(version: string | undefined): Promise { if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { diff --git a/src/cli/utils/index.ts b/src/cli/utils/index.ts index 5bc094db21..daf2c4bf7a 100644 --- a/src/cli/utils/index.ts +++ b/src/cli/utils/index.ts @@ -7,6 +7,8 @@ export * from './versions.ts'; export * from './logger.ts'; export * from './common.ts'; export * from './tags.ts'; +export * from './time.ts'; +export * from './warn.ts'; export type { Options as SpawnOptions, Result as SpawnResult, diff --git a/src/cli/utils/time.ts b/src/cli/utils/time.ts new file mode 100644 index 0000000000..1336589e57 --- /dev/null +++ b/src/cli/utils/time.ts @@ -0,0 +1,66 @@ +import { isNonEmptyString, isNumber } from '@sindresorhus/is'; +import ms from 'ms'; +import { logger } from './logger.ts'; + +/** Splits a compound time like `1 day 2h` or `1H 30MIN` into its parts. */ +const splitRegex = /(?.*?[a-z]+)/i; + +/** + * A month in a time, which `ms` doesn't know: the word in any case, or an + * uppercase `M` like for Renovate, a lowercase `m` is a minute. + */ +const monthRegex = /^(?\d+)\s*(?:[Mm][Oo][Nn][Tt][Hh][Ss]?|M)$/; + +/** A part of a time without a unit, which `ms` reads as milliseconds. */ +const unitlessRegex = /^[+-]?\d+(?:\.\d+)?$/; + +/** Splits a compound time like `1 day 2h` or `1h30` into its parts. */ +function splitTime(time: string): string[] { + return time + .split(splitRegex) + .map((part) => part.trim()) + .filter(isNonEmptyString); +} + +/** + * Checks if a part of a time like `24` or `1h30` has no unit, so it is read + * as milliseconds. + */ +export function hasUnitlessPart(time: string): boolean { + return splitTime(time).some((part) => unitlessRegex.test(part)); +} + +/** + * Converts a time like `24h`, `2 days` or `1 day 2h` to milliseconds, like + * Renovate does. The parts of a compound time are summed, a month counts as + * 30 days. + * @returns `null` for an invalid time or a negative part, which is logged at + * debug level + */ +export function toMs(time: string): number | null { + const specs = splitTime(time).map((spec) => + spec.replace( + monthRegex, + (_, months: string) => `${Number(months) * 30} days`, + ), + ); + if (!specs.length) { + logger.debug({ time }, `Invalid time specifier: '${time}'`); + return null; + } + + let total = 0; + for (const spec of specs) { + const millis: unknown = ms(spec as ms.StringValue); + if (!isNumber(millis)) { + logger.debug({ time }, `Invalid time specifier: '${spec}'`); + return null; + } + if (millis < 0) { + logger.debug({ time }, `Negative time specifier: '${spec}'`); + return null; + } + total += millis; + } + return total; +} diff --git a/src/cli/utils/warn.ts b/src/cli/utils/warn.ts new file mode 100644 index 0000000000..c2d379cbbf --- /dev/null +++ b/src/cli/utils/warn.ts @@ -0,0 +1,30 @@ +import { logger } from './logger.ts'; + +/** + * The warnings already logged by this process, by message and context, as the + * env and the tool-versions data are read on every resolve. A module-level set, + * so the warnings don't depend on a service instance. + */ +const warned = new Set(); + +/** + * Logs a warning once per process, like an invalid env value or a tool + * without data, which would else repeat on every resolve. + * @param context - the logged context, like the env value or the tool, also + * part of the key, so another value or tool warns + */ +export function warnOnce( + message: string, + context: Record, +): void { + const key = `${message}\0${JSON.stringify(context)}`; + if (!warned.has(key)) { + warned.add(key); + logger.warn(context, message); + } +} + +/** Forgets the warnings already logged by {@link warnOnce}, for tests. */ +export function resetWarnings(): void { + warned.clear(); +}