diff --git a/src/cli/tools/dotnet/powershell.spec.ts b/src/cli/tools/dotnet/powershell.spec.ts new file mode 100644 index 0000000000..9023891e5e --- /dev/null +++ b/src/cli/tools/dotnet/powershell.spec.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs/promises'; +import { arch } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CompressionService, LinkToolService } from '../../services/index.ts'; +import { getDistro } from '../../utils/index.ts'; +import { + PowershellInstallService, + PowershellPrepareService, +} from './powershell.ts'; +import { scope } from '~test/http-mock.ts'; +import { ensurePaths } from '~test/path.ts'; +import { checksum, toolContext } from '~test/tool.ts'; + +const { execaMock } = vi.hoisted(() => ({ execaMock: vi.fn() })); +vi.mock('execa', () => ({ execa: execaMock })); +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + arch: vi.fn(() => 'x64'), +})); +vi.mock('../../utils/index.ts', async (importActual) => ({ + ...(await importActual()), + getDistro: vi.fn(), +})); + +const baseUrl = 'https://github.com'; +const releaseUrl = '/PowerShell/PowerShell/releases/download'; +const archive = 'powershell archive'; +const bom = String.fromCharCode(0xfeff); + +describe('cli/tools/dotnet/powershell', () => { + beforeAll(async () => { + await ensurePaths(['tmp', 'opt/containerbase/bin']); + }); + + beforeEach(() => { + vi.mocked(arch).mockReturnValue('x64'); + // CI configures an apt proxy, which `AptService` would write to `/etc` + vi.stubEnv('APT_HTTP_PROXY', undefined); + execaMock.mockResolvedValue({ failed: false }); + }); + + describe('PowershellPrepareService', () => { + test.each([ + { code: 'jammy', pkgs: ['libicu70', 'libssl3'] }, + { code: 'noble', pkgs: ['libicu74', 'libssl3t64'] }, + { code: 'resolute', pkgs: ['libbrotli1', 'libicu78', 'libssl3t64'] }, + ])('prepare on $code', async ({ code, pkgs }) => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: code, + versionId: '24.04', + }); + const { svc } = await toolContext(PowershellPrepareService); + + await expect(svc.prepare()).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'apt-get', + expect.arrayContaining(['libc6', 'zlib1g', ...pkgs]), + { env: { DEBIAN_FRONTEND: 'noninteractive' } }, + ); + }); + + test('prepare: throws on an unsupported distro', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'focal', + versionId: '20.04', + }); + const { svc } = await toolContext(PowershellPrepareService); + + await expect(svc.prepare()).rejects.toThrow( + "Tool 'powershell' not supported on: focal!", + ); + }); + }); + + describe('PowershellInstallService', () => { + test.each([ + { + hostArch: 'x64', + toolArch: 'x64', + version: '7.6.6', + encoding: 'utf16le', + }, + { + hostArch: 'arm64', + toolArch: 'arm64', + version: '7.2.8', + encoding: 'utf8', + }, + ] as const)( + 'install $version on $toolArch', + async ({ hostArch, toolArch, version, encoding }) => { + vi.mocked(arch).mockReturnValue(hostArch); + const { svc, pathSvc } = await toolContext(PowershellInstallService); + const filename = `powershell-${version}-linux-${toolArch}.tar.gz`; + const hashes = `${bom}${checksum('other')} *powershell-${version}-osx-${toolArch}.tar.gz\r\n${checksum(archive)} *${filename}\r\n`; + scope(baseUrl) + .get(`${releaseUrl}/v${version}/hashes.sha256`) + .reply(200, Buffer.from(hashes, encoding)) + .get(`${releaseUrl}/v${version}/${filename}`) + .reply(200, archive); + const path = pathSvc.versionedToolPath('powershell', version); + const extract = vi + .spyOn(CompressionService.prototype, 'extract') + .mockImplementationOnce(({ cwd }) => + fs.writeFile(join(cwd, 'pwsh'), 'pwsh', { mode: 0o644 }), + ); + + await expect(svc.install(version)).resolves.toBeUndefined(); + + expect(extract).toHaveBeenCalledExactlyOnceWith({ + file: expect.stringContaining(filename), + cwd: path, + }); + expect((await fs.stat(join(path, 'pwsh'))).mode & 0o777).toBe(0o775); + }, + ); + + test('install: downloads without a checksum before v7.2.0', async () => { + const { svc, pathSvc } = await toolContext(PowershellInstallService); + const version = '7.1.3'; + const filename = `powershell-${version}-linux-x64.tar.gz`; + scope(baseUrl) + .get(`${releaseUrl}/v${version}/${filename}`) + .reply(200, archive); + const path = pathSvc.versionedToolPath('powershell', version); + const extract = vi + .spyOn(CompressionService.prototype, 'extract') + .mockImplementationOnce(({ cwd }) => + fs.writeFile(join(cwd, 'pwsh'), 'pwsh', { mode: 0o644 }), + ); + + await expect(svc.install(version)).resolves.toBeUndefined(); + + expect(extract).toHaveBeenCalledExactlyOnceWith({ + file: expect.stringContaining(filename), + cwd: path, + }); + }); + + test('install: rejects a missing checksum', async () => { + const { svc } = await toolContext(PowershellInstallService); + scope(baseUrl) + .get(`${releaseUrl}/v7.6.4/hashes.sha256`) + .reply( + 200, + `${checksum('other')} *powershell-7.6.4-linux-arm64.tar.gz\n`, + ); + + await expect(svc.install('7.6.4')).rejects.toThrow( + `Checksum not found in ${baseUrl}${releaseUrl}/v7.6.4/hashes.sha256 for powershell-7.6.4-linux-x64.tar.gz`, + ); + }); + + test('install: rejects a checksum mismatch', async () => { + const { svc } = await toolContext(PowershellInstallService); + const filename = 'powershell-7.6.5-linux-x64.tar.gz'; + scope(baseUrl) + .get(`${releaseUrl}/v7.6.5/hashes.sha256`) + .reply(200, `${checksum('other')} *${filename}\n`) + .get(`${releaseUrl}/v7.6.5/${filename}`) + .times(3) + .reply(200, archive); + + await expect(svc.install('7.6.5')).rejects.toThrow('download failed'); + }); + + test('link', async () => { + const { svc, pathSvc } = await toolContext(PowershellInstallService); + const spy = vi.spyOn(LinkToolService.prototype, 'shellwrapper'); + + await expect(svc.link('7.6.6')).resolves.toBeUndefined(); + + expect(spy).toHaveBeenCalledExactlyOnceWith('powershell', { + name: 'pwsh', + srcDir: pathSvc.versionedToolPath('powershell', '7.6.6'), + }); + }); + + test('runs the tool test', async () => { + const { svc } = await toolContext(PowershellInstallService); + + await expect(svc.test('7.6.6')).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'pwsh', + ['-version'], + expect.any(Object), + ); + }); + }); +}); diff --git a/src/cli/tools/dotnet/powershell.ts b/src/cli/tools/dotnet/powershell.ts index 8bf3d9db78..e6435ab0e0 100644 --- a/src/cli/tools/dotnet/powershell.ts +++ b/src/cli/tools/dotnet/powershell.ts @@ -1,18 +1,104 @@ -import { injectFromHierarchy, injectable } from 'inversify'; -import { V2ToolInstallService } from '../../install-tool/install-legacy-tool.service.ts'; -import { V2ToolPrepareService } from '../../prepare-tool/prepare-legacy-tools.service.ts'; -import { v2Tool } from '../../utils/v2-tool.ts'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { inject, injectFromHierarchy, injectable } from 'inversify'; +import { BaseInstallService } from '../../install-tool/base-install.service.ts'; +import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; +import { AptService } from '../../services/index.ts'; +import { getDistro, semverGte } from '../../utils/index.ts'; + +/** + * The distro specific dependencies. + * @see {@link https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2204#dependencies-4} + */ +const distroPackages: Record = { + jammy: ['libicu70', 'libssl3'], + noble: ['libicu74', 'libssl3t64'], + resolute: ['libbrotli1', 'libicu78', 'libssl3t64'], +}; @injectable() @injectFromHierarchy() -@v2Tool('powershell') -export class PowershellPrepareService extends V2ToolPrepareService { +export class PowershellPrepareService extends BasePrepareService { + @inject(AptService) + private readonly aptSvc!: AptService; + override readonly name = 'powershell'; + + /** + * Installs the apt packages powershell needs on the current ubuntu release. + * + * @throws on an unsupported distro + */ + override async prepare(): Promise { + const distro = await getDistro(); + const packages = distroPackages[distro.versionCode]; + if (!packages) { + throw new Error( + `Tool '${this.name}' not supported on: ${distro.versionCode}! Please use ubuntu 'jammy', 'noble' or 'resolute'.`, + ); + } + + await this.aptSvc.install( + 'libc6', + 'libgcc-s1', + 'libgssapi-krb5-2', + 'libstdc++6', + 'tzdata', + 'zlib1g', + ...packages, + ); + } } @injectable() @injectFromHierarchy() -@v2Tool('powershell') -export class PowershellInstallService extends V2ToolInstallService { +export class PowershellInstallService extends BaseInstallService { override readonly name = 'powershell'; + + /** The architecture name used by the powershell release assets. */ + private get ghArch(): string { + return this.envSvc.arch === 'arm64' ? 'arm64' : 'x64'; + } + + /** + * Downloads the powershell archive from GitHub, verified against the + * release's `hashes.sha256` since v7.2.0, and extracts it into the + * versioned tool path. + */ + override async install(version: string): Promise { + const baseUrl = `https://github.com/PowerShell/PowerShell/releases/download/v${version}/`; + const filename = `${this.name}-${version}-linux-${this.ghArch}.tar.gz`; + + // powershell only publishes a `hashes.sha256` checksum file since v7.2.0. + const expectedChecksum = semverGte(version, '7.2.0') + ? await this.findChecksum(`${baseUrl}hashes.sha256`, filename) + : undefined; + + const file = await this.http.download({ + url: `${baseUrl}${filename}`, + checksumType: 'sha256', + expectedChecksum, + }); + + await this.pathSvc.ensureToolPath(this.name); + + const path = await this.pathSvc.createVersionedToolPath(this.name, version); + await this.compress.extract({ file, cwd: path }); + + // Happened on v7.3.0 + await fs.chmod(join(path, 'pwsh'), this.envSvc.umask); + } + + /** Links the `pwsh` binary into the global bin folder. */ + override async link(version: string): Promise { + await this.shellwrapper({ + name: 'pwsh', + srcDir: this.pathSvc.versionedToolPath(this.name, version), + }); + } + + /** Checks that `pwsh -version` runs. */ + override async test(_version: string): Promise { + await this._spawn('pwsh', ['-version']); + } } diff --git a/src/usr/local/containerbase/tools/v2/powershell.sh b/src/usr/local/containerbase/tools/v2/powershell.sh deleted file mode 100644 index 3bf1c20e0a..0000000000 --- a/src/usr/local/containerbase/tools/v2/powershell.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -function prepare_tool() { - local version_codename - - version_codename="$(get_distro)" - case "${version_codename}" in - # https://learn.microsoft.com/en-us/dotnet/core/install/linux-ubuntu-install?tabs=dotnet10&pivots=os-linux-ubuntu-2204#dependencies-4 - "jammy") apt_install libc6 libgcc-s1 libgssapi-krb5-2 libicu70 libssl3 libstdc++6 tzdata zlib1g;; - "noble") apt_install libc6 libgcc-s1 libgssapi-krb5-2 libicu74 libssl3t64 libstdc++6 tzdata zlib1g;; - "resolute") apt_install libbrotli1 libc6 libgcc-s1 libgssapi-krb5-2 libicu78 libssl3t64 libstdc++6 tzdata zlib1g;; - *) - echo "Tool '${TOOL_NAME}' not supported on: ${version_codename}! Please use ubuntu 'noble' or 'resolute'." >&2 - exit 1 - ;; - esac -} - -function install_tool () { - local file - local versioned_tool_path - local arch=linux-x64 - - if [[ "${ARCHITECTURE}" = "aarch64" ]]; then - arch=linux-arm64 - fi - - file=$(get_from_url "https://github.com/PowerShell/PowerShell/releases/download/v${TOOL_VERSION}/powershell-${TOOL_VERSION}-${arch}.tar.gz") - - versioned_tool_path=$(create_versioned_tool_path) - bsdtar -C "${versioned_tool_path}" -xzf "${file}" - # Happened on v7.3.0 - if [[ ! -x "${versioned_tool_path}/pwsh" ]]; then - chmod +x "${versioned_tool_path}/pwsh" - fi -} - -function link_tool () { - shell_wrapper pwsh "$(find_versioned_tool_path)" -} - -function test_tool () { - pwsh -version -}