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..6990f3aaa4 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` + +Release archives and their checksum manifest follow these paths: + +```txt +https://github.com/voidzero-dev/vite-plus/releases/download/v/vp-x86_64-unknown-linux-gnu.tar.gz +https://github.com/voidzero-dev/vite-plus/releases/download/v/vp-aarch64-unknown-linux-gnu.tar.gz +https://github.com/voidzero-dev/vite-plus/releases/download/v/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..1b7d1ce338 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -111,6 +111,7 @@ import { SwiftInstallService } from '../tools/swift.ts'; import { TerraformInstallService } from '../tools/terraform.ts'; import { TofuInstallService } from '../tools/tofu.ts'; import { VendirInstallService } from '../tools/vendir.ts'; +import { VpInstallService } from '../tools/vp.ts'; import { WallyInstallService } from '../tools/wally.ts'; import { type InstallToolType, logger } from '../utils/index.ts'; import { isNotKnownV2Tool } from '../utils/v2-tool.ts'; @@ -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/vp.spec.ts b/src/cli/tools/vp.spec.ts new file mode 100644 index 0000000000..f5289bd817 --- /dev/null +++ b/src/cli/tools/vp.spec.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs/promises'; +import { arch } from 'node:os'; +import { join } from 'node:path'; +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/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 expect(service.test('0.4.0')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/cli/tools/vp.ts b/src/cli/tools/vp.ts new file mode 100644 index 0000000000..9e48ad4dfa --- /dev/null +++ b/src/cli/tools/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']); + } +}