From ea0ebb3a7bf2f88da8a974a367283a85e5703f21 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 13:36:40 +0200 Subject: [PATCH 1/6] feat: convert the ruby tool to a typescript installer Replace the v2 shell script with prepare and install services. The installer extends the shared prebuild installer and adds the system wide gemrc, the gem wrapper and the gem checks. Co-Authored-By: Claude Opus 5.5 --- docs/custom-registries.md | 8 +- src/cli/tools/ruby/index.spec.ts | 237 +++++++++++++++++++ src/cli/tools/ruby/index.ts | 114 ++++++++- src/usr/local/containerbase/tools/v2/ruby.sh | 144 ----------- 4 files changed, 349 insertions(+), 154 deletions(-) create mode 100644 src/cli/tools/ruby/index.spec.ts delete mode 100644 src/usr/local/containerbase/tools/v2/ruby.sh diff --git a/docs/custom-registries.md b/docs/custom-registries.md index e99870c60d..5864f0644c 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -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 `.sha512` checksum is used when the release has one, older releases are installed without it. + ### `gem` tools Gem tools are downloaded from: diff --git a/src/cli/tools/ruby/index.spec.ts b/src/cli/tools/ruby/index.spec.ts new file mode 100644 index 0000000000..97e518aab2 --- /dev/null +++ b/src/cli/tools/ruby/index.spec.ts @@ -0,0 +1,237 @@ +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()), + arch: vi.fn(() => 'x64'), +})); +vi.mock('../../utils/index.ts', async (importActual) => ({ + ...(await importActual()), + 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) + .head(`${path}.sha512`) + .reply(200) + .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 without checksum', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'noble', + versionId: '24.04', + }); + const version = '2.7.6'; + const { svc, pathSvc } = await toolContext(RubyInstallService); + const path = prebuildPath(version, 'jammy', 'x86_64'); + scope(githubUrl) + .head(`${path}.sha512`) + .reply(404) + .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), + ); + }); + }); +}); diff --git a/src/cli/tools/ruby/index.ts b/src/cli/tools/ruby/index.ts index cc129e1dae..d90be3a7d2 100644 --- a/src/cli/tools/ruby/index.ts +++ b/src/cli/tools/ruby/index.ts @@ -1,18 +1,116 @@ -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 { join } from 'node:path'; +import { inject, injectFromHierarchy, injectable } from 'inversify'; +import { BasePrepareService } from '../../prepare-tool/base-prepare.service.ts'; +import { AptService } from '../../services/index.ts'; +import { fileContent, getDistro } from '../../utils/index.ts'; +import { PrebuildInstallService } from '../utils/prebuild.ts'; + +/** The ubuntu releases ruby can be installed on. */ +const supportedDistros = ['jammy', 'noble', 'resolute']; + +/** The user home entries redirected into the containerbase cache. */ +const cacheDirs = ['.gem', '.cocoapods', 'Library']; @injectable() @injectFromHierarchy() -@v2Tool('ruby') -export class RubyPrepareService extends V2ToolPrepareService { +export class RubyPrepareService extends BasePrepareService { + @inject(AptService) + private readonly aptSvc!: AptService; + override readonly name = 'ruby'; + + /** + * Installs the apt packages ruby needs, initializes the cache, links the + * user's gem and cocoapods folders to it, and links `/usr/local/ruby` to the + * tool path, a workaround for ruby's hard-coded paths. + * + * @throws on an unsupported distro + */ + override async prepare(): Promise { + const { versionCode } = await getDistro(); + if (!supportedDistros.includes(versionCode)) { + throw new Error( + `Tool '${this.name}' not supported on: ${versionCode}! Please use ubuntu 'jammy', 'noble' or 'resolute'.`, + ); + } + + await this.aptSvc.install('g++', 'libffi-dev', 'libyaml-0-2', 'make'); + + await this.initialize(); + + for (const entry of ['.gemrc', ...cacheDirs]) { + await this.pathSvc.createSymlink( + join(this.pathSvc.cachePath, entry), + join(this.envSvc.userHome, entry), + ); + } + + await this.pathSvc.createSymlink( + this.pathSvc.toolPath(this.name), + join(this.envSvc.rootDir, 'usr/local/ruby'), + ); + } + + /** + * Creates the `.gemrc` and the gem, cocoapods and `Library` folders in the + * containerbase cache, unless the `.gemrc` already exists. + */ + override async initialize(): Promise { + const gemrc = join(this.pathSvc.cachePath, '.gemrc'); + if (await this.pathSvc.fileExists(gemrc)) { + return; + } + + await this.pathSvc.writeFile( + gemrc, + fileContent` + gem: --no-document + `, + ); + + for (const dir of cacheDirs) { + await this.pathSvc.createDir(join(this.pathSvc.cachePath, dir)); + } + } } @injectable() @injectFromHierarchy() -@v2Tool('ruby') -export class RubyInstallService extends V2ToolInstallService { +export class RubyInstallService extends PrebuildInstallService { override readonly name = 'ruby'; + + /** Installs the ruby prebuild and writes its system wide `gemrc`. */ + override async install(version: string): Promise { + await super.install(version); + + const etc = await this.pathSvc.createVersionedToolPath( + this.name, + version, + 'etc', + ); + await this.pathSvc.writeFile( + join(etc, 'gemrc'), + fileContent` + gem: --no-document + :benchmark: false + :verbose: true + :update_sources: true + :backtrace: false + `, + ); + } + + /** Links the `ruby` and `gem` binaries into the global bin folder. */ + override async link(version: string): Promise { + await super.link(version); + const src = join(this.pathSvc.versionedToolPath(this.name, version), 'bin'); + await this.shellwrapper({ srcDir: src, name: 'gem' }); + } + + /** Checks that `ruby` and `gem` run and prints the gem environment. */ + override async test(version: string): Promise { + await super.test(version); + await this._spawn('gem', ['--version']); + await this._spawn('gem', ['env']); + } } diff --git a/src/usr/local/containerbase/tools/v2/ruby.sh b/src/usr/local/containerbase/tools/v2/ruby.sh deleted file mode 100644 index bb93f45ace..0000000000 --- a/src/usr/local/containerbase/tools/v2/ruby.sh +++ /dev/null @@ -1,144 +0,0 @@ -#!/bin/bash - -function prepare_tool() { - local version_codename - local path - - version_codename="$(get_distro)" - case "${version_codename}" in - "jammy");; - "noble");; - "resolute");; - *) - echo "Tool '${TOOL_NAME}' not supported on: ${version_codename}! Please use ubuntu 'noble' or 'resolute'." >&2 - exit 1 - ;; - esac - apt_install \ - g++ \ - libffi-dev \ - libyaml-0-2 \ - make \ - ; - - init_tool - - # Redirect gemrc - path="$(get_cache_path)/.gemrc" - ln -sf "${path}" "${USER_HOME}/.gemrc" - - # Redirect gem home - path="$(get_cache_path)/.gem" - ln -sf "${path}" "${USER_HOME}/.gem" - - # Redirect cocoapods home - path="$(get_cache_path)/.cocoapods" - ln -sf "${path}" "${USER_HOME}/.cocoapods" - - # Redirect Library home - path="$(get_cache_path)/Library" - ln -sf "${path}" "${USER_HOME}/Library" - - # Workaround for compatibillity for Ruby hardcoded paths - path=$(find_tool_path) - if [ "${path}" != "${ROOT_DIR_LEGACY}/ruby" ]; then - ln -sf "${path}" /usr/local/ruby - fi -} - -function init_tool () { - local path - path="$(get_cache_path)/.gemrc" - - if [ -f "${path}" ]; then - return - fi - - # Init gemrc - { - printf -- "gem: --no-document\n" - } > "${path}" - chown "${USER_ID}" "${path}" - chmod g+w "${path}" - - # Init gem home - path="$(get_cache_path)/.gem" - create_folder "${path}" 775 - chown "${USER_ID}" "${path}" - - # Init cocoapods home - path="$(get_cache_path)/.cocoapods" - create_folder "${path}" 775 - chown "${USER_ID}" "${path}" - - # Init Library home - path="$(get_cache_path)/Library" - create_folder "${path}" 775 - chown "${USER_ID}" "${path}" -} - -function install_tool () { - local arch=${ARCHITECTURE} - local base_url - local checksum_file - local expected_checksum - local file - local name=${TOOL_NAME} - local tool_path - local version=${TOOL_VERSION} - local version_codename - local versioned_tool_path - - tool_path=$(find_tool_path) - - base_url="https://github.com/containerbase/${name}-prebuild/releases/download" - version_codename=$(get_distro) - - if [[ "${version_codename}" == "noble" || "${version_codename}" == "resolute" ]]; then - version_codename="jammy" - fi - - # not all releases have checksums - checksum_exists=$(file_exists "${base_url}/${version}/${name}-${version}-${version_codename}-${arch}.tar.xz.sha512") - if [[ "${checksum_exists}" == "200" ]]; then - checksum_file=$(get_from_url "${base_url}/${version}/${name}-${version}-${version_codename}-${arch}.tar.xz.sha512") - # get checksum from file - expected_checksum=$(cat "${checksum_file}") - fi - - file=$(get_from_url \ - "${base_url}/${version}/${name}-${version}-${version_codename}-${arch}.tar.xz" \ - "${name}-${version}-${version_codename}-${arch}.tar.xz" \ - "${expected_checksum}" \ - sha512sum - ) - - bsdtar -C "${tool_path}" -xf "${file}" - - versioned_tool_path=$(find_versioned_tool_path) - # System settings - mkdir -p "$versioned_tool_path/etc" - { - printf -- "gem: --no-document\n" - printf -- ":benchmark: false\n" - printf -- ":verbose: true\n" - printf -- ":update_sources: true\n" - printf -- ":backtrace: false\n" - } > "$versioned_tool_path/etc/gemrc" - -} - -function link_tool () { - local versioned_tool_path - - versioned_tool_path=$(find_versioned_tool_path) - - shell_wrapper ruby "${versioned_tool_path}/bin" - shell_wrapper gem "${versioned_tool_path}/bin" -} - -function test_tool () { - ruby --version - echo "gem $(gem --version)" - gem env -} From f92cd10e1b2190476295771f62bae3204a5d2a3d Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 14:59:34 +0200 Subject: [PATCH 2/6] test(ruby): always expect the checksum The prebuild installer now always verifies the checksum. Co-Authored-By: Claude Opus 5.5 --- docs/custom-registries.md | 2 +- src/cli/tools/ruby/index.spec.ts | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/custom-registries.md b/docs/custom-registries.md index 5864f0644c..ea03a3d890 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -718,7 +718,7 @@ https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4 https://github.com/containerbase/ruby-prebuild/releases/download/3.4.11/ruby-3.4.11-jammy-aarch64.tar.xz ``` -The `.sha512` checksum is used when the release has one, older releases are installed without it. +The download is always verified against the `.sha512` checksum, so a mirror needs to provide both files. ### `gem` tools diff --git a/src/cli/tools/ruby/index.spec.ts b/src/cli/tools/ruby/index.spec.ts index 97e518aab2..a238244099 100644 --- a/src/cli/tools/ruby/index.spec.ts +++ b/src/cli/tools/ruby/index.spec.ts @@ -137,8 +137,6 @@ describe('cli/tools/ruby/index', () => { const { svc, pathSvc } = await toolContext(RubyInstallService); const path = prebuildPath(version, 'jammy', ghArch); scope(githubUrl) - .head(`${path}.sha512`) - .reply(200) .get(`${path}.sha512`) .reply(200, `${checksum(archive, 'sha512')}\n`) .get(path) @@ -170,18 +168,18 @@ describe('cli/tools/ruby/index', () => { }, ); - test('install without checksum', async () => { + test('install on noble uses the jammy prebuild', async () => { vi.mocked(getDistro).mockResolvedValue({ name: 'Ubuntu', versionCode: 'noble', versionId: '24.04', }); - const version = '2.7.6'; + const version = '3.3.0'; const { svc, pathSvc } = await toolContext(RubyInstallService); const path = prebuildPath(version, 'jammy', 'x86_64'); scope(githubUrl) - .head(`${path}.sha512`) - .reply(404) + .get(`${path}.sha512`) + .reply(200, `${checksum(archive, 'sha512')}\n`) .get(path) .reply(200, archive); const extract = vi.spyOn(CompressionService.prototype, 'extract'); From 0dcaf8a29ddb609063af8ab68eb189a0db1eab50 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 16:00:47 +0200 Subject: [PATCH 3/6] docs: drop the links to the removed v2 tools folder Co-Authored-By: Claude Opus 5.5 --- docs/new-tool.md | 2 +- docs/tool-installer-best-practices.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/new-tool.md b/docs/new-tool.md index 02b88cd0d6..862b3fe422 100644 --- a/docs/new-tool.md +++ b/docs/new-tool.md @@ -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. diff --git a/docs/tool-installer-best-practices.md b/docs/tool-installer-best-practices.md index 17d438b125..1edd3090c3 100644 --- a/docs/tool-installer-best-practices.md +++ b/docs/tool-installer-best-practices.md @@ -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. From 58f6cb47b80da4c67b1e1bd6346b1382ed4e7e9b Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 16:11:08 +0200 Subject: [PATCH 4/6] test(latest): install dummy v1 and v2 shell tools No v1 or v2 tool script is left in the repo, so check that the image still installs, prepares and links the shell tools custom images ship. Co-Authored-By: Claude Opus 5.5 --- test/latest/Dockerfile | 33 +++++++++++++++++++++++++ test/latest/Dockerfile.arm64 | 33 +++++++++++++++++++++++++ test/latest/legacy-tools/dummy-v1.sh | 15 +++++++++++ test/latest/legacy-tools/v2/dummy-v2.sh | 29 ++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 test/latest/legacy-tools/dummy-v1.sh create mode 100644 test/latest/legacy-tools/v2/dummy-v2.sh diff --git a/test/latest/Dockerfile b/test/latest/Dockerfile index afdbf5fed1..cbbaf68b81 100644 --- a/test/latest/Dockerfile +++ b/test/latest/Dockerfile @@ -386,6 +386,38 @@ RUN install-tool nuget 7.9.0 RUN nuget search nuget.commandline -Take 1 +#-------------------------------------- +# test: v1 and v2 shell tools of custom images +#-------------------------------------- +FROM base AS test-legacy + +COPY test/latest/legacy-tools/ /usr/local/containerbase/tools/ + +RUN install-tool dummy-v1 1.2.3 + +RUN prepare-tool dummy-v2 +RUN set -ex; [ -f /opt/containerbase/tools/dummy-v2/prepared ] + +RUN install-tool dummy-v2 1.2.3 +RUN set -ex; [ -f /opt/containerbase/tools/dummy-v2/initialized ] + +RUN set -ex; \ + dummy-v1 --version | grep '^dummy-v1 1.2.3$'; \ + dummy-v2 --version | grep '^dummy-v2 1.2.3$'; \ + containerbase-cli list tools | grep -E '^dummy-v1 +1\.2\.3$'; \ + containerbase-cli list tools | grep -E '^dummy-v2 +1\.2\.3$'; \ + true + +USER 12021 + +RUN install-tool dummy-v2 2.0.0 + +RUN set -ex; \ + dummy-v1 --version | grep '^dummy-v1 1.2.3$'; \ + dummy-v2 --version | grep '^dummy-v2 2.0.0$'; \ + containerbase-cli list tools | grep -E '^dummy-v2 +2\.0\.0 +1\.2\.3$'; \ + true + #-------------------------------------- # final #-------------------------------------- @@ -399,3 +431,4 @@ COPY --link --from=teste /.dummy /.dummy COPY --link --from=testf /.dummy /.dummy COPY --link --from=test-docker /.dummy /.dummy COPY --link --from=test-mono /.dummy /.dummy +COPY --link --from=test-legacy /.dummy /.dummy diff --git a/test/latest/Dockerfile.arm64 b/test/latest/Dockerfile.arm64 index 014fe02642..720e53bc41 100644 --- a/test/latest/Dockerfile.arm64 +++ b/test/latest/Dockerfile.arm64 @@ -213,6 +213,38 @@ RUN install-tool protoc 36.2 # renovate: datasource=github-releases packageName=prefix-dev/pixi RUN install-tool pixi v0.81.0 +#-------------------------------------- +# Image: v1 and v2 shell tools of custom images +#-------------------------------------- +FROM base AS test-legacy + +COPY test/latest/legacy-tools/ /usr/local/containerbase/tools/ + +RUN install-tool dummy-v1 1.2.3 + +RUN prepare-tool dummy-v2 +RUN set -ex; [ -f /opt/containerbase/tools/dummy-v2/prepared ] + +RUN install-tool dummy-v2 1.2.3 +RUN set -ex; [ -f /opt/containerbase/tools/dummy-v2/initialized ] + +RUN set -ex; \ + dummy-v1 --version | grep '^dummy-v1 1.2.3$'; \ + dummy-v2 --version | grep '^dummy-v2 1.2.3$'; \ + containerbase-cli list tools | grep -E '^dummy-v1 +1\.2\.3$'; \ + containerbase-cli list tools | grep -E '^dummy-v2 +1\.2\.3$'; \ + true + +USER 12021 + +RUN install-tool dummy-v2 2.0.0 + +RUN set -ex; \ + dummy-v1 --version | grep '^dummy-v1 1.2.3$'; \ + dummy-v2 --version | grep '^dummy-v2 2.0.0$'; \ + containerbase-cli list tools | grep -E '^dummy-v2 +2\.0\.0 +1\.2\.3$'; \ + true + #-------------------------------------- # Image: final #-------------------------------------- @@ -237,3 +269,4 @@ COPY --from=test-tofu /.dummy /.dummy COPY --from=test-vendir /.dummy /.dummy COPY --from=test-others /.dummy /.dummy +COPY --from=test-legacy /.dummy /.dummy diff --git a/test/latest/legacy-tools/dummy-v1.sh b/test/latest/legacy-tools/dummy-v1.sh new file mode 100644 index 0000000000..74d2fe8348 --- /dev/null +++ b/test/latest/legacy-tools/dummy-v1.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# v1 shell tool like custom images ship them, sourced by `v1-install-tool.sh` +# installs a small script which prints its name and version + +require_root + +versioned_tool_path=$(create_versioned_tool_path) +create_folder "${versioned_tool_path}/bin" +printf '#!/bin/bash\necho "%s %s"\n' "${TOOL_NAME}" "${TOOL_VERSION}" > "${versioned_tool_path}/bin/${TOOL_NAME}" +chmod +x "${versioned_tool_path}/bin/${TOOL_NAME}" + +shell_wrapper "${TOOL_NAME}" "${versioned_tool_path}/bin" + +[[ -n $SKIP_VERSION ]] || dummy-v1 --version diff --git a/test/latest/legacy-tools/v2/dummy-v2.sh b/test/latest/legacy-tools/v2/dummy-v2.sh new file mode 100644 index 0000000000..bc469abb41 --- /dev/null +++ b/test/latest/legacy-tools/v2/dummy-v2.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# v2 shell tool like custom images ship them, sourced by `v2-install-tool.sh` +# installs a small script which prints its name and version + +function prepare_tool() { + touch "$(find_tool_path)/prepared" +} + +function init_tool () { + touch "$(find_tool_path)/initialized" +} + +function install_tool () { + local versioned_tool_path + + versioned_tool_path=$(create_versioned_tool_path) + create_folder "${versioned_tool_path}/bin" + printf '#!/bin/bash\necho "%s %s"\n' "${TOOL_NAME}" "${TOOL_VERSION}" > "${versioned_tool_path}/bin/${TOOL_NAME}" + chmod +x "${versioned_tool_path}/bin/${TOOL_NAME}" +} + +function link_tool () { + shell_wrapper "${TOOL_NAME}" "$(find_versioned_tool_path)/bin" +} + +function test_tool () { + dummy-v2 --version +} From a97020bf9ab87d2bbe2184d7c84fe90e9ed24099 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Wed, 30 Sep 2026 16:23:53 +0200 Subject: [PATCH 5/6] refactor: drop the unused v2Tool decorator No tool service uses it after the python and ruby conversion, so every v2 shell tool of a custom image already gets a generic service. The v2 runtime support stays. Co-Authored-By: Claude Opus 5.5 --- src/cli/install-tool/index.ts | 3 +-- src/cli/prepare-tool/index.ts | 5 ++--- src/cli/utils/types.ts | 2 -- src/cli/utils/v2-tool.spec.ts | 19 ------------------- src/cli/utils/v2-tool.ts | 29 ----------------------------- 5 files changed, 3 insertions(+), 55 deletions(-) delete mode 100644 src/cli/utils/v2-tool.spec.ts delete mode 100644 src/cli/utils/v2-tool.ts diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index d1e5f18650..78213d1d51 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -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, @@ -205,7 +204,7 @@ async function prepareInstallContainer(): Promise { // 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) { @injectable() @injectFromHierarchy() class GenericInstallService extends V2ToolInstallService { diff --git a/src/cli/prepare-tool/index.ts b/src/cli/prepare-tool/index.ts index 0443a37696..6203a8a49e 100644 --- a/src/cli/prepare-tool/index.ts +++ b/src/cli/prepare-tool/index.ts @@ -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, @@ -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 { logger.trace('preparing container'); @@ -47,7 +46,7 @@ async function prepareContainer(): Promise { // v2 tool services const pathSvc = await container.getAsync(PathService); const v2Tools = await pathSvc.findLegacyTools(); - for (const tool of v2Tools.filter(isNotKnownV2Tool)) { + for (const tool of v2Tools) { @injectable() @injectFromHierarchy() class GenericV2ToolPrepareService extends V2ToolPrepareService { diff --git a/src/cli/utils/types.ts b/src/cli/utils/types.ts index 6b345378d9..1223c958fa 100644 --- a/src/cli/utils/types.ts +++ b/src/cli/utils/types.ts @@ -21,8 +21,6 @@ export type CliMode = (typeof cliModes)[number]; export type Arch = 'arm64' | 'amd64'; -export type ClazzDecorator = (target: V) => V | void; - export const installToolTypes = ['gem', 'npm', 'pip'] as const; export type InstallToolType = (typeof installToolTypes)[number]; diff --git a/src/cli/utils/v2-tool.spec.ts b/src/cli/utils/v2-tool.spec.ts deleted file mode 100644 index e8b4b830f0..0000000000 --- a/src/cli/utils/v2-tool.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { isKnownV2Tool, isNotKnownV2Tool, v2Tool } from './v2-tool.ts'; - -describe('cli/utils/v2-tool', () => { - @v2Tool('test-tool') - class TestTool { - readonly name = 'test-tool'; - } - - const tool = new TestTool().name; - - test('isKnownV2Tool', () => { - expect(isKnownV2Tool(tool)).toBe(true); - }); - - test('isNotKnownV2Tool', () => { - expect(isNotKnownV2Tool(tool)).toBe(false); - }); -}); diff --git a/src/cli/utils/v2-tool.ts b/src/cli/utils/v2-tool.ts deleted file mode 100644 index cfd9f33951..0000000000 --- a/src/cli/utils/v2-tool.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ClazzDecorator } from './types'; - -const knownV2Tools = new Set(); - -/** Whether a v2 shell tool has its own service, see `v2Tool`. */ -export function isKnownV2Tool(tool: string): boolean { - return knownV2Tools.has(tool); -} - -/** Whether a v2 shell tool has no own service and needs a generic one. */ -export function isNotKnownV2Tool(tool: string): boolean { - return !knownV2Tools.has(tool); -} - -interface V2ToolInstallerService { - prototype: { name: string }; -} - -/** - * Class decorator which marks a v2 shell tool as having its own service, so - * no generic one is registered for it. - */ -export function v2Tool(tool: string): ClazzDecorator { - return (target: T): T | void => { - knownV2Tools.add(tool); - - return target; - }; -} From af0ddab60c966202e0bc1f2a731e7e2a2419ef31 Mon Sep 17 00:00:00 2001 From: Michael Kriese Date: Thu, 1 Oct 2026 12:28:10 +0200 Subject: [PATCH 6/6] fix(prepare): register the v2 shell tools after the modern services A custom v2 shell tool with the name of a modern tool no longer replaces its prepare step, the same order install already uses. Co-Authored-By: Claude Opus 5.5 --- src/cli/prepare-tool/index.spec.ts | 20 ++++++++++++++++++++ src/cli/prepare-tool/index.ts | 25 +++++++++++++------------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/cli/prepare-tool/index.spec.ts b/src/cli/prepare-tool/index.spec.ts index 681e6fa473..d46e93cc5e 100644 --- a/src/cli/prepare-tool/index.spec.ts +++ b/src/cli/prepare-tool/index.spec.ts @@ -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'); @@ -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(); diff --git a/src/cli/prepare-tool/index.ts b/src/cli/prepare-tool/index.ts index 6203a8a49e..772859b063 100644 --- a/src/cli/prepare-tool/index.ts +++ b/src/cli/prepare-tool/index.ts @@ -43,18 +43,6 @@ async function prepareContainer(): Promise { // 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) { - @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); @@ -80,6 +68,19 @@ async function prepareContainer(): Promise { 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; }