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
8 changes: 6 additions & 2 deletions docs/custom-registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,10 +712,14 @@ Ruby releases are downloaded from:
Samples:

```txt
https://github.com/containerbase/ruby-prebuild/releases/download/3.0.3/ruby-3.0.3-jammy-x86_x64.tar.xz.sha512
https://github.com/containerbase/ruby-prebuild/releases/download/3.0.3/ruby-3.0.3-jammy-x86_x64.tar.xz
https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4.11-jammy-x86_64.tar.xz.sha512
https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4.11-jammy-x86_64.tar.xz
https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4.11-jammy-aarch64.tar.xz.sha512
https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4.11-jammy-aarch64.tar.xz
```

The download is always verified against the `.sha512` checksum, so a mirror needs to provide both files.

### `gem` tools

Gem tools are downloaded from:
Expand Down
2 changes: 1 addition & 1 deletion docs/new-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ Expect more discussion on the Issue for tools in this category.
#### Legacy shell installers

> [!NOTE]
> Do not add a `.sh` file into [`src/usr/local/containerbase/tools/v2`](../src/usr/local/containerbase/tools/v2/).
> Do not add a `.sh` file into `src/usr/local/containerbase/tools/v2`.
>
> These are the legacy installer formats that we are in the process of migrating away from.
> When converting an existing shell tool to a TypeScript install service, see the [tool installer best practices](./tool-installer-best-practices.md) for what to keep and what not to change.
Expand Down
2 changes: 1 addition & 1 deletion docs/tool-installer-best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ See [`new-tool.md`](./new-tool.md) for the full walkthrough of adding a tool; th

Every tool is a TypeScript install service extending `BaseInstallService` ([`src/cli/install-tool/base-install.service.ts`](../src/cli/install-tool/base-install.service.ts)), living under [`src/cli/tools/`](../src/cli/tools/).

Do not add new `.sh` files under [`src/usr/local/containerbase/tools/v2`](../src/usr/local/containerbase/tools/v2/).
Do not add new `.sh` files under `src/usr/local/containerbase/tools/v2`.
Both the v1 and v2 shell tool formats are deprecated and being migrated to TypeScript install services.

When converting a legacy shell tool, keep installing every version the shell script could install.
Expand Down
3 changes: 1 addition & 2 deletions src/cli/install-tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ import { TofuInstallService } from '../tools/tofu.ts';
import { VendirInstallService } from '../tools/vendir.ts';
import { WallyInstallService } from '../tools/wally.ts';
import { type InstallToolType, logger } from '../utils/index.ts';
import { isNotKnownV2Tool } from '../utils/v2-tool.ts';
import {
V1ToolInstallService,
V2ToolInstallService,
Expand Down Expand Up @@ -205,7 +204,7 @@ async function prepareInstallContainer(): Promise<Container> {
// v2 tool services
const pathSvc = await container.getAsync(PathService);
const legacyTools = await pathSvc.findLegacyTools();
for (const tool of legacyTools.filter(isNotKnownV2Tool)) {
for (const tool of legacyTools) {
Comment thread
viceice marked this conversation as resolved.
@injectable()
@injectFromHierarchy()
class GenericInstallService extends V2ToolInstallService {
Expand Down
20 changes: 20 additions & 0 deletions src/cli/prepare-tool/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import fs from 'node:fs/promises';
import { beforeAll, describe, expect, test, vi } from 'vitest';
import { PathService, createContainer } from '../services/index.ts';
import { RubyPrepareService } from '../tools/ruby/index.ts';
import { initializeTools, prepareTools } from './index.ts';
import { V2ToolPrepareService } from './prepare-legacy-tools.service.ts';
import { ensurePaths, rootPath } from '~test/path.ts';

vi.mock('del');
Expand Down Expand Up @@ -37,6 +39,24 @@ describe('cli/prepare-tool/index', () => {
expect(await prepareTools(['not-exist'])).toBe(1);
});

test('prefers a modern service over a v2 shell tool with the same name', async () => {
const script = rootPath('usr/local/containerbase/tools/v2/ruby.sh');
await fs.writeFile(script, '');
const ruby = vi
.spyOn(RubyPrepareService.prototype, 'prepare')
.mockResolvedValue();
const v2 = vi.spyOn(V2ToolPrepareService.prototype, 'prepare');

try {
expect(await prepareTools(['ruby'])).toBeUndefined();
} finally {
await fs.rm(script);
}

expect(ruby).toHaveBeenCalledOnce();
expect(v2).not.toHaveBeenCalled();
});

test('initializeTools', async () => {
expect(await initializeTools(['bun', 'dummy'])).toBeUndefined();
expect(await initializeTools(['not-exist'])).toBeUndefined();
Expand Down
28 changes: 14 additions & 14 deletions src/cli/prepare-tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import { RubyPrepareService } from '../tools/ruby/index.ts';
import { RustPrepareService } from '../tools/rust.ts';
import { SwiftPrepareService } from '../tools/swift.ts';
import { logger } from '../utils/index.ts';
import { isNotKnownV2Tool } from '../utils/v2-tool.ts';
import { V2ToolPrepareService } from './prepare-legacy-tools.service.ts';
import {
PREPARE_TOOL_TOKEN,
Expand All @@ -35,7 +34,7 @@ import {

/**
* Creates a container with all prepare services, including a generic one for
* every v2 shell tool without its own service.
* every v2 shell tool.
*/
async function prepareContainer(): Promise<Container> {
logger.trace('preparing container');
Expand All @@ -44,18 +43,6 @@ async function prepareContainer(): Promise<Container> {
// core services
container.bind(PrepareToolService).toSelf();

// v2 tool services
const pathSvc = await container.getAsync(PathService);
const v2Tools = await pathSvc.findLegacyTools();
for (const tool of v2Tools.filter(isNotKnownV2Tool)) {
@injectable()
@injectFromHierarchy()
class GenericV2ToolPrepareService extends V2ToolPrepareService {
override readonly name: string = tool;
}
container.bind(PREPARE_TOOL_TOKEN).to(GenericV2ToolPrepareService);
}

// modern tool services
container.bind(PREPARE_TOOL_TOKEN).to(CabalPrepareService);
container.bind(PREPARE_TOOL_TOKEN).to(ConanPrepareService);
Expand All @@ -81,6 +68,19 @@ async function prepareContainer(): Promise<Container> {
container.bind(PREPARE_TOOL_TOKEN).to(SbtPrepareService);
container.bind(PREPARE_TOOL_TOKEN).to(SwiftPrepareService);

// v2 tool services, after the modern ones so a custom v2 shell tool with the
// same name doesn't replace a modern tool's prepare, like on install
const pathSvc = await container.getAsync(PathService);
const v2Tools = await pathSvc.findLegacyTools();
for (const tool of v2Tools) {
@injectable()
@injectFromHierarchy()
class GenericV2ToolPrepareService extends V2ToolPrepareService {
override readonly name: string = tool;
}
container.bind(PREPARE_TOOL_TOKEN).to(GenericV2ToolPrepareService);
}

logger.trace('preparing container done');
return container;
}
Expand Down
235 changes: 235 additions & 0 deletions src/cli/tools/ruby/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import fs from 'node:fs/promises';
import { arch } from 'node:os';
import { join } from 'node:path';
import { codeBlock } from 'common-tags';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import {
CompressionService,
EnvService,
LinkToolService,
} from '../../services/index.ts';
import { getDistro } from '../../utils/index.ts';
import { RubyInstallService, RubyPrepareService } from './index.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 githubUrl = 'https://github.com';
const archive = 'ruby archive';

/** The prebuild download path for `version`, `codename` and `ghArch`. */
function prebuildPath(
version: string,
codename: string,
ghArch: string,
): string {
return `/containerbase/ruby-prebuild/releases/download/${version}/ruby-${version}-${codename}-${ghArch}.tar.xz`;
}

describe('cli/tools/ruby/index', () => {
beforeAll(async () => {
await ensurePaths([
'tmp/containerbase/cache',
'home/ubuntu',
'opt/containerbase/bin',
'usr/local',
]);
});

beforeEach(() => {
vi.mocked(arch).mockReturnValue('x64');
vi.mocked(getDistro).mockResolvedValue({
name: 'Ubuntu',
versionCode: 'jammy',
versionId: '22.04',
});
// CI configures an apt proxy, which `AptService` would write to `/etc`
vi.stubEnv('APT_HTTP_PROXY', undefined);
execaMock.mockResolvedValue({ failed: false });
});

describe('RubyPrepareService', () => {
test.each(['jammy', 'noble', 'resolute'])('prepare on %s', async (code) => {
vi.mocked(getDistro).mockResolvedValue({
name: 'Ubuntu',
versionCode: code,
versionId: '24.04',
});
const { svc, child, pathSvc } = await toolContext(RubyPrepareService);
const envSvc = await child.getAsync(EnvService);

await expect(svc.prepare()).resolves.toBeUndefined();

expect(execaMock).toHaveBeenCalledWith(
'apt-get',
expect.arrayContaining(['g++', 'libffi-dev', 'libyaml-0-2', 'make']),
{ env: { DEBIAN_FRONTEND: 'noninteractive' } },
);
for (const entry of ['.gemrc', '.gem', '.cocoapods', 'Library']) {
expect(await fs.readlink(join(envSvc.userHome, entry))).toBe(
join(pathSvc.cachePath, entry),
);
}
expect(await fs.readlink(join(envSvc.rootDir, 'usr/local/ruby'))).toBe(
pathSvc.toolPath('ruby'),
);
});

test('prepare: throws on an unsupported distro', async () => {
vi.mocked(getDistro).mockResolvedValue({
name: 'Ubuntu',
versionCode: 'focal',
versionId: '20.04',
});
const { svc } = await toolContext(RubyPrepareService);

await expect(svc.prepare()).rejects.toThrow(
"Tool 'ruby' not supported on: focal!",
);
});

test('initialize creates the gemrc and folders', async () => {
const { svc, pathSvc } = await toolContext(RubyPrepareService);
const gemrc = join(pathSvc.cachePath, '.gemrc');
await fs.rm(gemrc, { force: true });

await expect(svc.initialize()).resolves.toBeUndefined();

expect(await fs.readFile(gemrc, 'utf8')).toBe('gem: --no-document\n');
expect((await fs.stat(gemrc)).mode & 0o777).toBe(0o664);
for (const dir of ['.gem', '.cocoapods', 'Library']) {
const stats = await fs.stat(join(pathSvc.cachePath, dir));
expect(stats.isDirectory()).toBe(true);
expect(stats.mode & 0o777).toBe(0o775);
}
});

test('initialize keeps an existing gemrc', async () => {
const { svc, pathSvc } = await toolContext(RubyPrepareService);
const gemrc = join(pathSvc.cachePath, '.gemrc');
await fs.writeFile(gemrc, '# existing\n');

await expect(svc.initialize()).resolves.toBeUndefined();

expect(await fs.readFile(gemrc, 'utf8')).toBe('# existing\n');
});
});

describe('RubyInstallService', () => {
test.each([
{ hostArch: 'x64', ghArch: 'x86_64', version: '3.4.11' },
{ hostArch: 'arm64', ghArch: 'aarch64', version: '4.0.7' },
] as const)(
'install $version on $ghArch with checksum',
async ({ hostArch, ghArch, version }) => {
vi.mocked(arch).mockReturnValue(hostArch);
const { svc, pathSvc } = await toolContext(RubyInstallService);
const path = prebuildPath(version, 'jammy', ghArch);
scope(githubUrl)
.get(`${path}.sha512`)
.reply(200, `${checksum(archive, 'sha512')}\n`)
.get(path)
.reply(200, archive);
const extract = vi.spyOn(CompressionService.prototype, 'extract');

await expect(svc.install(version)).resolves.toBeUndefined();

expect(extract).toHaveBeenCalledExactlyOnceWith({
file: expect.stringContaining(
`ruby-${version}-jammy-${ghArch}.tar.xz`,
),
cwd: pathSvc.toolPath('ruby'),
});
expect(
await fs.readFile(
join(pathSvc.versionedToolPath('ruby', version), 'etc/gemrc'),
'utf8',
),
).toBe(
`${codeBlock`
gem: --no-document
:benchmark: false
:verbose: true
:update_sources: true
:backtrace: false
`}\n`,
);
},
);

test('install on noble uses the jammy prebuild', async () => {
vi.mocked(getDistro).mockResolvedValue({
name: 'Ubuntu',
versionCode: 'noble',
versionId: '24.04',
});
const version = '3.3.0';
const { svc, pathSvc } = await toolContext(RubyInstallService);
const path = prebuildPath(version, 'jammy', 'x86_64');
scope(githubUrl)
.get(`${path}.sha512`)
.reply(200, `${checksum(archive, 'sha512')}\n`)
.get(path)
.reply(200, archive);
const extract = vi.spyOn(CompressionService.prototype, 'extract');

await expect(svc.install(version)).resolves.toBeUndefined();

expect(extract).toHaveBeenCalledExactlyOnceWith({
file: expect.stringContaining(`ruby-${version}-jammy-x86_64.tar.xz`),
cwd: pathSvc.toolPath('ruby'),
});
expect(
await fs.readFile(
join(pathSvc.versionedToolPath('ruby', version), 'etc/gemrc'),
'utf8',
),
).toContain(':backtrace: false');
});

test('link', async () => {
const { svc, pathSvc } = await toolContext(RubyInstallService);
const spy = vi.spyOn(LinkToolService.prototype, 'shellwrapper');
const src = join(pathSvc.versionedToolPath('ruby', '3.4.11'), 'bin');

await expect(svc.link('3.4.11')).resolves.toBeUndefined();

expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenCalledWith('ruby', { srcDir: src });
expect(spy).toHaveBeenCalledWith('ruby', { srcDir: src, name: 'gem' });
});

test('runs the tool test', async () => {
const { svc } = await toolContext(RubyInstallService);

await expect(svc.test('3.4.11')).resolves.toBeUndefined();

expect(execaMock).toHaveBeenCalledWith(
'ruby',
['--version'],
expect.any(Object),
);
expect(execaMock).toHaveBeenCalledWith(
'gem',
['--version'],
expect.any(Object),
);
expect(execaMock).toHaveBeenCalledWith(
'gem',
['env'],
expect.any(Object),
);
});
});
});
Loading