diff --git a/.dockerignore b/.dockerignore index d01839e765..c6d22dad49 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ !test !dist/docker !dist/cli +!dist/test diff --git a/.github/renovate.json b/.github/renovate.json index 512eb455ad..7cf9a94565 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -105,6 +105,7 @@ "tofu", "uv", "vendir", + "vp", "wally", "yarn" ], @@ -160,6 +161,7 @@ "tofu", "uv", "vendir", + "vp", "wally", "yarn" ], diff --git a/docs/custom-registries.md b/docs/custom-registries.md index d915a42ad4..2b2f7983c5 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -776,6 +776,20 @@ Samples: https://github.com/vmware-tanzu/carvel-vendir/releases/download/v0.22.0/vendir-linux-amd64 ``` +## `vp` + +Vite+ releases are downloaded from: + +- `https://github.com/voidzero-dev/vite-plus/releases` + +Samples: + +```txt +https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.1/vp-x86_64-unknown-linux-gnu.tar.gz +https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.1/vp-aarch64-unknown-linux-gnu.tar.gz +https://github.com/voidzero-dev/vite-plus/releases/download/v0.3.1/vp-checksums.txt +``` + ## `wally` Wally releases are downloaded from: diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 80ea5d39b7..efd7710ee6 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -80,6 +80,7 @@ import { YarnVersionResolver, } from '../tools/node/resolver.ts'; import { NpmBaseInstallService } from '../tools/node/utils.ts'; +import { VpInstallService } from '../tools/node/vp.ts'; import { ComposerInstallService, ComposerVersionResolver, @@ -192,6 +193,7 @@ async function prepareInstallContainer(): Promise { container.bind(INSTALL_TOOL_TOKEN).to(TerraformInstallService); container.bind(INSTALL_TOOL_TOKEN).to(TofuInstallService); container.bind(INSTALL_TOOL_TOKEN).to(VendirInstallService); + container.bind(INSTALL_TOOL_TOKEN).to(VpInstallService); container.bind(INSTALL_TOOL_TOKEN).to(WallyInstallService); container.bind(INSTALL_TOOL_TOKEN).to(YarnInstallService); container.bind(INSTALL_TOOL_TOKEN).to(YarnSlimInstallService); diff --git a/src/cli/tools/index.ts b/src/cli/tools/index.ts index 7e0b817fc6..4f481aa858 100644 --- a/src/cli/tools/index.ts +++ b/src/cli/tools/index.ts @@ -51,6 +51,7 @@ export const NoPrepareTools = [ 'tofu', 'uv', 'vendir', + 'vp', 'wally', 'yarn', 'yarn-slim', diff --git a/src/cli/tools/node/vp.spec.ts b/src/cli/tools/node/vp.spec.ts new file mode 100644 index 0000000000..ab0f1c4912 --- /dev/null +++ b/src/cli/tools/node/vp.spec.ts @@ -0,0 +1,149 @@ +import fs from 'node:fs/promises'; +import { arch } from 'node:os'; +import { join } from 'node:path'; +import { execa } from 'execa'; +import type { Container } from 'inversify'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { + CompressionService, + HttpService, + LinkToolService, +} from '../../services/index.ts'; +import { + VP_SYNC_VERSIONS_UNAVAILABLE, + VpInstallService, + parseVitePlusChecksum, + vitePlusAssetName, +} from './vp.ts'; +import { testContainer } from '~test/di.ts'; +import { ensurePaths } from '~test/path.ts'; + +vi.mock('execa'); + +describe('cli/tools/node/vp', () => { + describe('release assets', () => { + test.each([ + ['amd64', 'vp-x86_64-unknown-linux-gnu.tar.gz'], + ['arm64', 'vp-aarch64-unknown-linux-gnu.tar.gz'], + ] as const)('maps %s to the official release asset', (arch, expected) => { + expect(vitePlusAssetName(arch)).toBe(expected); + }); + + test('selects an exact release asset checksum', () => { + expect( + parseVitePlusChecksum( + [ + 'a'.repeat(64) + ' vp-aarch64-unknown-linux-gnu.tar.gz', + 'b'.repeat(64) + ' vp-x86_64-unknown-linux-gnu.tar.gz', + '', + ].join('\n'), + 'vp-x86_64-unknown-linux-gnu.tar.gz', + ), + ).toBe('b'.repeat(64)); + }); + + test('rejects missing or malformed checksums', () => { + expect(() => + parseVitePlusChecksum('', 'vp-x86_64-unknown-linux-gnu.tar.gz'), + ).toThrow('Cannot find checksum'); + expect(() => + parseVitePlusChecksum( + 'not-a-checksum vp-x86_64-unknown-linux-gnu.tar.gz', + 'vp-x86_64-unknown-linux-gnu.tar.gz', + ), + ).toThrow('Cannot find checksum'); + }); + }); + + describe('VpInstallService', () => { + let child: Container; + let service: VpInstallService; + + beforeAll(async () => { + await ensurePaths([ + 'opt/containerbase/bin', + 'opt/containerbase/tools', + 'tmp/containerbase', + 'var/lib/containerbase', + ]); + }); + + beforeEach(async () => { + child = await testContainer(); + child.bind(HttpService).toSelf(); + child.bind(CompressionService).toSelf(); + child.bind(LinkToolService).toSelf(); + child.bind(VpInstallService).toSelf(); + service = await child.getAsync(VpInstallService); + }); + + test('downloads, verifies, and extracts the exact prebuilt release', async () => { + const filename = vitePlusAssetName( + arch() === 'arm64' ? 'arm64' : 'amd64', + ); + const checksum = 'c'.repeat(64); + const checksumFile = join(globalThis.cacheDir, 'vp-checksums.txt'); + const archiveFile = join(globalThis.cacheDir, filename); + await fs.writeFile(checksumFile, `${checksum} ${filename}\n`); + await fs.writeFile(archiveFile, 'archive'); + + const download = vi + .spyOn(HttpService.prototype, 'download') + .mockResolvedValueOnce(checksumFile) + .mockResolvedValueOnce(archiveFile); + vi.spyOn(HttpService.prototype, 'exists').mockResolvedValueOnce(true); + const extract = vi + .spyOn(CompressionService.prototype, 'extract') + .mockResolvedValueOnce(); + + await service.install('0.4.0'); + + expect(download).toHaveBeenNthCalledWith(1, { + url: 'https://github.com/voidzero-dev/vite-plus/releases/download/v0.4.0/vp-checksums.txt', + }); + expect(download).toHaveBeenNthCalledWith(2, { + url: `https://github.com/voidzero-dev/vite-plus/releases/download/v0.4.0/${filename}`, + checksumType: 'sha256', + expectedChecksum: checksum, + }); + expect(extract).toHaveBeenCalledWith({ + file: archiveFile, + cwd: expect.stringMatching(/\/vp\/0\.4\.0\/bin$/), + }); + }); + + test('rejects releases that predate the bundled planner', async () => { + vi.spyOn(HttpService.prototype, 'exists').mockResolvedValueOnce(false); + const download = vi.spyOn(HttpService.prototype, 'download'); + + await expect(service.install('0.3.0')).rejects.toThrow( + `${VP_SYNC_VERSIONS_UNAVAILABLE}:0.3.0`, + ); + expect(download).not.toHaveBeenCalled(); + }); + + test('links vp with the Node runtime needed by the bundled planner', async () => { + const shellwrapper = vi + .spyOn(LinkToolService.prototype, 'shellwrapper') + .mockResolvedValueOnce(); + + await service.link('0.4.0'); + + expect(shellwrapper).toHaveBeenCalledWith('vp', { + srcDir: expect.stringMatching(/\/vp\/0\.4\.0\/bin$/), + extraToolEnvs: ['node'], + }); + }); + + test('checks the installed vp version', async () => { + await service.test('0.4.0'); + + expect(execa).toHaveBeenCalledExactlyOnceWith( + 'vp', + ['--version'], + expect.any(Object), + ); + }); + }); +}); diff --git a/src/cli/tools/node/vp.ts b/src/cli/tools/node/vp.ts new file mode 100644 index 0000000000..b0d401e761 --- /dev/null +++ b/src/cli/tools/node/vp.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { injectFromHierarchy, injectable } from 'inversify'; +import { BaseInstallService } from '../../install-tool/base-install.service.ts'; +import type { Arch } from '../../utils/index.ts'; + +// Stable machine-readable marker consumed by Renovate. Do not reword it. +export const VP_SYNC_VERSIONS_UNAVAILABLE = + 'CONTAINERBASE_VP_SYNC_VERSIONS_UNAVAILABLE'; + +export function vitePlusAssetName(arch: Arch): string { + const target = arch === 'arm64' ? 'aarch64' : 'x86_64'; + return `vp-${target}-unknown-linux-gnu.tar.gz`; +} + +export function parseVitePlusChecksum( + checksums: string, + filename: string, +): string { + for (const line of checksums.split('\n')) { + const match = /^([a-f\d]{64})\s+\*?(.+)$/i.exec(line.trim()); + const checksum = match?.[1]; + if (checksum && match?.[2] === filename) { + return checksum.toLowerCase(); + } + } + throw new Error(`Cannot find checksum for '${filename}' in vp-checksums.txt`); +} + +@injectable() +@injectFromHierarchy() +export class VpInstallService extends BaseInstallService { + readonly name = 'vp'; + override readonly parent = 'node'; + + override async install(version: string): Promise { + const baseUrl = `https://github.com/voidzero-dev/vite-plus/releases/download/v${version}/`; + const filename = vitePlusAssetName(this.envSvc.arch); + const checksumUrl = `${baseUrl}vp-checksums.txt`; + + if (!(await this.http.exists(checksumUrl))) { + throw new Error( + `${VP_SYNC_VERSIONS_UNAVAILABLE}:${version}: Vite+ release does not provide the sync-versions planner`, + ); + } + + const checksumFile = await this.http.download({ + url: checksumUrl, + }); + const expectedChecksum = parseVitePlusChecksum( + await fs.readFile(checksumFile, 'utf8'), + filename, + ); + const file = await this.http.download({ + url: `${baseUrl}${filename}`, + checksumType: 'sha256', + expectedChecksum, + }); + + await this.pathSvc.ensureToolPath(this.name); + const path = join( + await this.pathSvc.createVersionedToolPath(this.name, version), + 'bin', + ); + await fs.mkdir(path); + await this.compress.extract({ file, cwd: path }); + } + + override async link(version: string): Promise { + const src = join(this.pathSvc.versionedToolPath(this.name, version), 'bin'); + await this.shellwrapper({ srcDir: src, extraToolEnvs: ['node'] }); + } + + override async test(_version: string): Promise { + await this._spawn(this.name, ['--version']); + } +} diff --git a/test/Dockerfile.distro b/test/Dockerfile.distro index 8b58714225..9e069f582b 100644 --- a/test/Dockerfile.distro +++ b/test/Dockerfile.distro @@ -211,6 +211,9 @@ RUN install-tool pnpm 10.34.5 # renovate: datasource=npm packageName=@yarnpkg/cli-dist RUN install-tool yarn 4.18.0 +# renovate: datasource=github-releases packageName=voidzero-dev/vite-plus +RUN install-tool vp 0.3.1 + #-------------------------------------- # Image: test-php #-------------------------------------- diff --git a/test/node/Dockerfile b/test/node/Dockerfile index c7392984d5..1cac43745d 100644 --- a/test/node/Dockerfile +++ b/test/node/Dockerfile @@ -575,6 +575,24 @@ RUN set -ex; \ if [ -n "$(ls -A /usr/local/bin/)" ]; then echo "tools not uninstalled" >&2; exit 1; fi;\ true +#-------------------------------------- +# test: vp +#-------------------------------------- +FROM build AS test-vp + +USER root + +# renovate: datasource=github-releases depName=vp packageName=voidzero-dev/vite-plus +ARG VP_VERSION=0.3.1 +RUN install-tool vp "${VP_VERSION}" + +COPY dist/test/vp-sync-versions.mjs /test/vp-sync-versions.mjs + +USER 12021 +SHELL ["/bin/sh", "-c"] + +RUN node /test/vp-sync-versions.mjs "${VP_VERSION}" + #-------------------------------------- # final #-------------------------------------- @@ -596,6 +614,7 @@ COPY --from=testn /.dummy /.dummy COPY --from=testo /.dummy /.dummy COPY --from=testp /.dummy /.dummy COPY --from=testq /.dummy /.dummy +COPY --from=test-vp /.dummy /.dummy COPY --from=test-v20 /.dummy /.dummy COPY --from=test-v22 /.dummy /.dummy diff --git a/test/node/Dockerfile.arm64 b/test/node/Dockerfile.arm64 index 5c25f4919a..b5cf0f383a 100644 --- a/test/node/Dockerfile.arm64 +++ b/test/node/Dockerfile.arm64 @@ -76,6 +76,22 @@ RUN install-tool renovate 44.17.1 # # renovate: datasource=npm # RUN npm install -g re2@1.20.9 +#-------------------------------------- +# Image: vp +#-------------------------------------- +FROM test-node AS test-vp + +# renovate: datasource=github-releases depName=vp packageName=voidzero-dev/vite-plus +ARG VP_VERSION=0.3.1 +RUN install-tool vp "${VP_VERSION}" + +COPY dist/test/vp-sync-versions.mjs /test/vp-sync-versions.mjs + +USER 12021 +SHELL ["/bin/sh", "-c"] + +RUN node /test/vp-sync-versions.mjs "${VP_VERSION}" + #-------------------------------------- # Image: final #-------------------------------------- @@ -85,3 +101,4 @@ COPY --from=test-node /.dummy /.dummy COPY --from=test-pnpm /.dummy /.dummy COPY --from=test-yarn /.dummy /.dummy COPY --from=test-renovate /.dummy /.dummy +COPY --from=test-vp /.dummy /.dummy diff --git a/test/node/vp/sync-versions.mjs b/test/node/vp/sync-versions.mjs new file mode 100644 index 0000000000..db8c6a756f --- /dev/null +++ b/test/node/vp/sync-versions.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execa } from 'execa'; + +const version = process.argv[2]; +assert.ok(version, 'Expected the installed Vite+ version'); + +const cwd = await mkdtemp(join(tmpdir(), 'containerbase-vp-')); +const manifest = { + name: 'vp-install-test', + private: true, + scripts: { postinstall: 'exit 91' }, + devDependencies: { + 'vite-plus': '0.0.0', + vite: 'npm:@voidzero-dev/vite-plus-core@0.0.0', + vitest: '0.0.0', + '@vitest/coverage-v8': '0.0.0', + typescript: '5.8.0', + }, +}; +const contents = JSON.stringify(manifest, null, 2) + '\n'; +const config = 'throw new Error("Project configuration must not be loaded");\n'; + +async function plan(manifestContents) { + const { stdout } = await execa( + process.env.VP_TEST_BIN ?? 'vp', + ['sync-versions', '--json'], + { + cwd, + encoding: 'utf8', + input: JSON.stringify({ + schemaVersion: 1, + workspace: '.', + manifests: [ + { + path: 'package.json', + kind: 'packageJson', + contents: manifestContents, + }, + ], + }), + }, + ); + return JSON.parse(stdout); +} + +try { + await writeFile(join(cwd, 'package.json'), contents); + await writeFile(join(cwd, 'vite.config.mjs'), config); + + const result = await plan(contents); + assert.equal(result.schemaVersion, 1); + assert.deepEqual(result.tool, { name: 'vite-plus', version }); + assert.equal(result.workspace, '.'); + assert.equal(result.replacements.length, 1); + + const replacement = result.replacements[0]; + assert.equal(replacement.path, 'package.json'); + assert.equal(replacement.kind, 'packageJson'); + assert.equal(replacement.before, contents); + + const updated = JSON.parse(replacement.after); + const vitestVersion = updated.devDependencies.vitest; + assert.match(vitestVersion, /^\d+\.\d+\.\d+/); + assert.notEqual(vitestVersion, '0.0.0'); + assert.deepEqual(updated, { + ...manifest, + devDependencies: { + ...manifest.devDependencies, + 'vite-plus': version, + vite: `npm:@voidzero-dev/vite-plus-core@${version}`, + vitest: vitestVersion, + '@vitest/coverage-v8': vitestVersion, + }, + }); + + assert.deepEqual(await plan(replacement.after), { + schemaVersion: 1, + tool: { name: 'vite-plus', version }, + workspace: '.', + replacements: [], + }); + assert.equal(await readFile(join(cwd, 'package.json'), 'utf8'), contents); + assert.equal(await readFile(join(cwd, 'vite.config.mjs'), 'utf8'), config); +} finally { + await rm(cwd, { recursive: true, force: true }); +}