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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 88 additions & 21 deletions src/cli/install-tool/tool-version-resolver.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -148,6 +149,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');
Expand Down Expand Up @@ -181,7 +184,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);

Expand All @@ -195,10 +198,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => {
test.each([undefined, '', ' ', 'latest'])(
'resolves %j with a resolver which only resolves latest',
async (version) => {
const online = vi.spyOn(
ToolVersionsService.prototype,
'getOnlineVersions',
);
const online = vi.spyOn(HttpService.prototype, 'getJson');

expect(await svc.resolve('latest-only', version)).toBe('9.9.9');

Expand Down Expand Up @@ -271,7 +271,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');

Expand Down Expand Up @@ -328,6 +328,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
Expand Down Expand Up @@ -909,11 +975,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 () => {
Expand Down Expand Up @@ -948,10 +1021,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => {
[{ version: '1.0.0', cb: '14.0.0' }],
);
// 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', version)).toBe(version);

Expand Down Expand Up @@ -1000,10 +1070,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => {
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(
ToolVersionsService.prototype,
'getOnlineVersions',
);
const online = vi.spyOn(HttpService.prototype, 'getJson');

expect(await svc.resolve('other', version)).toBe(version);

Expand All @@ -1013,7 +1080,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => {
);

test('keeps an exact version of a tool without embedded data without the online lookup', async () => {
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');

Expand All @@ -1023,7 +1090,7 @@ describe('cli/install-tool/tool-version-resolver.service', () => {
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');

Expand Down
99 changes: 61 additions & 38 deletions src/cli/install-tool/tool-version-resolver.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ function pick(
// 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 embedded 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<string, unknown>,
): 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
Expand All @@ -163,7 +185,8 @@ const twoPartReleaseTools = new Set(['protoc', 'swift']);
* {@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 embedded - whether the data is the embedded fallback
* @param embedded - whether the data is the possibly outdated embedded data,
* the fallback for online data which can't be loaded or used offline
*/
function logNoMatch(
tool: string,
Expand Down Expand Up @@ -192,12 +215,7 @@ function logNoMatch(
? 'no version is resolved'
: `${version} is used as is`;
/** Logs the warning, naming the embedded data as its source. */
const warn = (message: string): void =>
logger.warn(
embedded
? `${message} (embedded, possibly outdated tool-versions data)`
: message,
);
const warn = (message: string): void => warnData(message, embedded);

if (blocked !== undefined) {
warn(
Expand Down Expand Up @@ -267,16 +285,17 @@ export class ToolVersionResolverService {
* 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 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, 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.
* 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.
* @returns an exact version as given, else the resolved release, or
* `undefined` when the data has no match or the tool has no data
*/
Expand Down Expand Up @@ -306,33 +325,37 @@ export class ToolVersionResolverService {
}
}

const online = await this.toolVersions.getOnlineVersions(tool);
if (online) {
const { version: resolved, blocked } = pick(tool, online, version, host);
if (resolved === undefined) {
logNoMatch(tool, online, version, host, blocked, false);
return undefined;
}
logger.debug(
{ tool, version, resolved },
'version resolved from the online tool-versions data',
);
return resolved;
}

const data = await this.toolVersions.getVersions(tool);
if (!data) {
const found = await this.toolVersions.getResolveVersions(tool);
if (!found) {
return undefined;
}
const { data, source } = found;
const { version: resolved, blocked } = pick(tool, data, version, host);
if (resolved === undefined) {
logNoMatch(tool, data, version, host, blocked, true);
// the embedded data may be outdated when the online data was looked up
// or is turned off, not when it is current
logNoMatch(
tool,
data,
version,
host,
blocked,
source === 'fallback' || source === 'offline',
);
return undefined;
}
logger.warn(
{ tool, version, resolved },
'online tool-versions data not available, using the embedded, possibly outdated data',
);
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;
}

Expand Down
Loading
Loading