Skip to content
Open
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
172 changes: 172 additions & 0 deletions src/cli/tools/dotnet/powershell.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
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<typeof import('node:os')>()),
arch: vi.fn(() => 'x64'),
}));
vi.mock('../../utils/index.ts', async (importActual) => ({
...(await importActual<typeof import('../../utils/index.ts')>()),
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]),
);
});

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: 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),
);
});
});
});
101 changes: 93 additions & 8 deletions src/cli/tools/dotnet/powershell.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,103 @@
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 } 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<string, string[] | undefined> = {
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<void> {
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`, and extracts it into the versioned tool path.
*/
override async install(version: string): Promise<void> {
const baseUrl = `https://github.com/PowerShell/PowerShell/releases/download/v${version}/`;
const filename = `${this.name}-${version}-linux-${this.ghArch}.tar.gz`;

const expectedChecksum = await this.findChecksum(
`${baseUrl}hashes.sha256`,
filename,
);

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<void> {
await this.shellwrapper({
name: 'pwsh',
srcDir: this.pathSvc.versionedToolPath(this.name, version),
});
}

/** Checks that `pwsh -version` runs. */
override async test(_version: string): Promise<void> {
await this._spawn('pwsh', ['-version']);
}
}
44 changes: 0 additions & 44 deletions src/usr/local/containerbase/tools/v2/powershell.sh

This file was deleted.

Loading