diff --git a/src/cli/install-tool/base-install.service.ts b/src/cli/install-tool/base-install.service.ts index feb3909207..42bbe982be 100644 --- a/src/cli/install-tool/base-install.service.ts +++ b/src/cli/install-tool/base-install.service.ts @@ -48,6 +48,18 @@ export abstract class BaseInstallService { */ readonly parent?: string; + /** + * Some tools can only be installed as root, so they are only available at + * image build time. Eg. git is installed via apt. + */ + readonly needsRoot: boolean = false; + + /** + * Tools which are not installed into a versioned tool path can't be + * uninstalled, eg. git is installed system wide via apt. + */ + readonly canUninstall: boolean = true; + /** * Optional tool type for dynamic uninstallation support. * Currently `npm`, `gem` or `pip`. diff --git a/src/cli/install-tool/index.ts b/src/cli/install-tool/index.ts index 80ea5d39b7..6f9860e93f 100644 --- a/src/cli/install-tool/index.ts +++ b/src/cli/install-tool/index.ts @@ -31,6 +31,7 @@ import { ErlangInstallService } from '../tools/erlang/index.ts'; import { FlutterInstallService } from '../tools/flutter.ts'; import { FluxInstallService } from '../tools/flux.ts'; import { GhInstallService } from '../tools/gh.ts'; +import { GitInstallService } from '../tools/git/index.ts'; import { GitLfsInstallService } from '../tools/git/lfs.ts'; import { GleamInstallService } from '../tools/gleam.ts'; import { GolangInstallService } from '../tools/golang.ts'; @@ -156,6 +157,7 @@ async function prepareInstallContainer(): Promise { container.bind(INSTALL_TOOL_TOKEN).to(FlutterInstallService); container.bind(INSTALL_TOOL_TOKEN).to(FluxInstallService); container.bind(INSTALL_TOOL_TOKEN).to(GhInstallService); + container.bind(INSTALL_TOOL_TOKEN).to(GitInstallService); container.bind(INSTALL_TOOL_TOKEN).to(GitLfsInstallService); container.bind(INSTALL_TOOL_TOKEN).to(GhcInstallService); container.bind(INSTALL_TOOL_TOKEN).to(GleamInstallService); diff --git a/src/cli/install-tool/install-tool.service.spec.ts b/src/cli/install-tool/install-tool.service.spec.ts index 30096e57d8..87849d97d1 100644 --- a/src/cli/install-tool/install-tool.service.spec.ts +++ b/src/cli/install-tool/install-tool.service.spec.ts @@ -1,6 +1,6 @@ import fs from 'node:fs/promises'; import { execa } from 'execa'; -import type { Container } from 'inversify'; +import { type Container, injectFromHierarchy, injectable } from 'inversify'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { initializeTools, prepareTools } from '../prepare-tool/index.ts'; import { @@ -10,8 +10,9 @@ import { createContainer, } from '../services/index.ts'; import { BunInstallService } from '../tools/bun.ts'; -import { BlockingChild, NotSupported } from '../utils/codes.ts'; +import { BlockingChild, NotRoot, NotSupported } from '../utils/codes.ts'; import { isDockerBuild, logger } from '../utils/index.ts'; +import { BaseInstallService } from './base-install.service.ts'; import { V1ToolInstallService } from './install-legacy-tool.service.ts'; import { INSTALL_TOOL_TOKEN, @@ -29,11 +30,43 @@ vi.mock('../utils/index.ts', async (importActual) => ({ isDockerBuild: vi.fn(), })); +@injectable() +@injectFromHierarchy() +abstract class TestInstallService extends BaseInstallService { + override install(_version: string): Promise { + return Promise.resolve(); + } + + override link(_version: string): Promise { + return Promise.resolve(); + } +} + +/** a tool which can only be installed at image build time, like `git` */ +@injectable() +@injectFromHierarchy() +class RootOnlyInstallService extends TestInstallService { + override readonly name = 'root-only'; + + override readonly needsRoot = true; +} + +/** a tool which is installed system wide, like `git` */ +@injectable() +@injectFromHierarchy() +class NoUninstallInstallService extends TestInstallService { + override readonly name = 'no-uninstall'; + + override readonly canUninstall = false; +} + describe('cli/install-tool/install-tool.service', () => { const parent = createContainer(); parent.bind(InstallToolService).toSelf(); parent.bind(V1ToolInstallService).toSelf(); parent.bind(INSTALL_TOOL_TOKEN).to(BunInstallService); + parent.bind(INSTALL_TOOL_TOKEN).to(RootOnlyInstallService); + parent.bind(INSTALL_TOOL_TOKEN).to(NoUninstallInstallService); let child: Container; let install: InstallToolService; @@ -57,9 +90,12 @@ describe('cli/install-tool/install-tool.service', () => { describe('install', () => { test('writes version if tool is not installed', async () => { const ver = await child.getAsync(VersionService); - const bun = await child.getAsync(INSTALL_TOOL_TOKEN); - vi.mocked(bun).needsInitialize.mockReturnValueOnce(true); - vi.mocked(bun).needsPrepare.mockReturnValueOnce(true); + vi.mocked( + BunInstallService.prototype, + ).needsInitialize.mockReturnValueOnce(true); + vi.mocked(BunInstallService.prototype).needsPrepare.mockReturnValueOnce( + true, + ); expect(await install.install('bun', '1.0.0')).toBeUndefined(); expect(await ver.getCurrent('bun')).toMatchObject({ name: 'bun', @@ -67,10 +103,19 @@ describe('cli/install-tool/install-tool.service', () => { }); }); + test('fails if the tool needs root', async () => { + expect(await install.install('root-only', '1.0.0')).toBe(NotRoot); + expect(logger.fatal).toHaveBeenCalledExactlyOnceWith( + { tool: 'root-only' }, + 'tool must be installed as root', + ); + }); + test('writes version even if tool is installed', async () => { const ver = await child.getAsync(VersionService); - const bun = await child.getAsync(INSTALL_TOOL_TOKEN); - vi.mocked(bun).isInstalled.mockResolvedValueOnce(true); + vi.mocked(BunInstallService.prototype).isInstalled.mockResolvedValueOnce( + true, + ); expect(await install.install('bun', '1.0.1')).toBeUndefined(); expect(await ver.getCurrent('bun')).toMatchObject({ name: 'bun', @@ -160,16 +205,18 @@ describe('cli/install-tool/install-tool.service', () => { }); test('aborts when the tool cannot be prepared', async () => { - const bun = await child.getAsync(INSTALL_TOOL_TOKEN); - vi.mocked(bun).needsPrepare.mockReturnValueOnce(true); + vi.mocked(BunInstallService.prototype).needsPrepare.mockReturnValueOnce( + true, + ); vi.mocked(prepareTools).mockResolvedValueOnce(1); expect(await install.install('bun', '1.1.0')).toBe(1); }); test('aborts when the tool cannot be initialized', async () => { - const bun = await child.getAsync(INSTALL_TOOL_TOKEN); - vi.mocked(bun).needsInitialize.mockReturnValueOnce(true); + vi.mocked( + BunInstallService.prototype, + ).needsInitialize.mockReturnValueOnce(true); vi.mocked(initializeTools).mockResolvedValueOnce(1); expect(await install.install('bun', '1.1.1')).toBe(1); @@ -216,6 +263,30 @@ describe('cli/install-tool/install-tool.service', () => { ); }); + test('fails if the tool cannot be uninstalled', async () => { + const ver = await child.getAsync(VersionService); + await ver.addInstalled({ name: 'no-uninstall', version: '1.0.0' }); + + expect(await install.uninstall('no-uninstall', '1.0.0')).toBe( + NotSupported, + ); + expect(logger.fatal).toHaveBeenCalledExactlyOnceWith( + { tool: 'no-uninstall' }, + 'tool cannot be uninstalled', + ); + }); + + test('fails if the tool needs root', async () => { + const ver = await child.getAsync(VersionService); + await ver.addInstalled({ name: 'root-only', version: '1.0.0' }); + + expect(await install.uninstall('root-only', '1.0.0')).toBe(NotRoot); + expect(logger.fatal).toHaveBeenCalledExactlyOnceWith( + { tool: 'root-only' }, + 'tool must be uninstalled as root', + ); + }); + test('dry run', async () => { expect(await install.install('bun', '3.0.0')).toBeUndefined(); diff --git a/src/cli/install-tool/install-tool.service.ts b/src/cli/install-tool/install-tool.service.ts index 1e6dad6f3d..78fb08370d 100644 --- a/src/cli/install-tool/install-tool.service.ts +++ b/src/cli/install-tool/install-tool.service.ts @@ -12,7 +12,12 @@ import { VersionService, } from '../services/index.ts'; import type { ToolState } from '../services/version.service'; -import { BlockingChild, MissingParent, NotSupported } from '../utils/codes.ts'; +import { + BlockingChild, + MissingParent, + NotRoot, + NotSupported, +} from '../utils/codes.ts'; import { cleanAptFiles, cleanTmpFiles, @@ -62,6 +67,11 @@ export class InstallToolService { await this.ipc.start(); this._link.clear(); if (toolSvc) { + if (toolSvc.needsRoot && !this.envSvc.isRoot) { + logger.fatal({ tool }, 'tool must be installed as root'); + return NotRoot; + } + let parent: ToolState | null = null; if (toolSvc.parent) { @@ -237,6 +247,16 @@ export class InstallToolService { const toolSvc = this.toolSvcs.find((t) => t.name === tool); if (toolSvc) { + if (!toolSvc.canUninstall) { + logger.fatal({ tool }, 'tool cannot be uninstalled'); + return NotSupported; + } + + if (toolSvc.needsRoot && !this.envSvc.isRoot) { + logger.fatal({ tool }, 'tool must be uninstalled as root'); + return NotRoot; + } + logger.debug({ tool }, 'validate tool'); const childs = await this.versionSvc.getChilds({ name: tool, version }); if (childs.length) { diff --git a/src/cli/prepare-tool/index.ts b/src/cli/prepare-tool/index.ts index c2b49ddbea..e0aec9562d 100644 --- a/src/cli/prepare-tool/index.ts +++ b/src/cli/prepare-tool/index.ts @@ -8,6 +8,7 @@ import { PowershellPrepareService } from '../tools/dotnet/powershell.ts'; import { ElixirPrepareService } from '../tools/erlang/elixir.ts'; import { ErlangPrepareService } from '../tools/erlang/index.ts'; import { FlutterPrepareService } from '../tools/flutter.ts'; +import { GitPrepareService } from '../tools/git/index.ts'; import { GolangPrepareService } from '../tools/golang.ts'; import { CabalPrepareService } from '../tools/haskell/cabal.ts'; import { GhcPrepareService } from '../tools/haskell/ghc.ts'; @@ -61,6 +62,7 @@ async function prepareContainer(): Promise { container.bind(PREPARE_TOOL_TOKEN).to(ErlangPrepareService); container.bind(PREPARE_TOOL_TOKEN).to(FlutterPrepareService); container.bind(PREPARE_TOOL_TOKEN).to(GhcPrepareService); + container.bind(PREPARE_TOOL_TOKEN).to(GitPrepareService); container.bind(PREPARE_TOOL_TOKEN).to(GolangPrepareService); container.bind(PREPARE_TOOL_TOKEN).to(JavaPrepareService); container.bind(PREPARE_TOOL_TOKEN).to(JavaJrePrepareService); diff --git a/src/cli/tools/git/index.spec.ts b/src/cli/tools/git/index.spec.ts new file mode 100644 index 0000000000..2070b60d28 --- /dev/null +++ b/src/cli/tools/git/index.spec.ts @@ -0,0 +1,148 @@ +import { readFile } from 'node:fs/promises'; +import { codeBlock } from 'common-tags'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { getDistro } from '../../utils/index.ts'; +import { GitInstallService, GitPrepareService } from './index.ts'; +import { scope } from '~test/http-mock.ts'; +import { ensurePaths, rootPath } from '~test/path.ts'; +import { toolContext } from '~test/tool.ts'; + +const { execaMock } = vi.hoisted(() => ({ execaMock: vi.fn() })); +vi.mock('execa', () => ({ execa: execaMock })); +vi.mock('../../utils/index.ts', async (importActual) => ({ + ...(await importActual()), + getDistro: vi.fn(), +})); + +describe('cli/tools/git/index', () => { + beforeAll(async () => { + // `etc/apt/sources.list.d` ships with the image + await ensurePaths([ + 'tmp', + 'etc/apt/sources.list.d', + 'opt/containerbase/bin', + ]); + }); + + beforeEach(() => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'noble', + versionId: '24.04', + }); + // CI configures an apt proxy, which `AptService` would write to `/etc` + vi.stubEnv('APT_HTTP_PROXY', undefined); + execaMock.mockResolvedValue({ + failed: false, + stdout: 'git version 2.55.0', + }); + }); + + describe('GitPrepareService', () => { + test('adds the ppa', async () => { + const { svc } = await toolContext(GitPrepareService); + scope('http://keyserver.ubuntu.com') + .get('/pks/lookup') + .query(true) + .reply(200, 'public key'); + + await expect(svc.prepare()).resolves.toBeUndefined(); + + expect(await readFile(rootPath('etc/apt/keyrings/git.asc'), 'utf8')).toBe( + 'public key', + ); + expect( + await readFile(rootPath('etc/apt/sources.list.d/git.sources'), 'utf8'), + ).toBe(codeBlock` + Types: deb + URIs: https://ppa.launchpadcontent.net/git-core/ppa/ubuntu + Suites: noble + Components: main + Architectures: amd64 + Signed-By: /etc/apt/keyrings/git.asc + `); + }); + }); + + describe('GitInstallService', () => { + test('install', async () => { + const { svc } = await toolContext(GitInstallService); + + await expect(svc.install('2.55.0')).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith('apt-get', [ + '-qq', + 'install', + '-y', + 'git', + ]); + }); + + test('install: coerces a vendor version suffix', async () => { + const { svc } = await toolContext(GitInstallService); + execaMock.mockResolvedValue({ + failed: false, + stdout: 'git version 2.55.0-1ubuntu1', + }); + + await expect(svc.install('2.55.0')).resolves.toBeUndefined(); + }); + + test('install: throws on an unparsable version', async () => { + const { svc } = await toolContext(GitInstallService); + execaMock.mockResolvedValue({ failed: false, stdout: 'git version foo' }); + + await expect(svc.install('2.55.0')).rejects.toThrow( + 'Could not parse the git version: git version foo', + ); + }); + + test('install: rejects a version below the minimum', async () => { + const { svc } = await toolContext(GitInstallService); + execaMock.mockResolvedValue({ + failed: false, + stdout: 'git version 2.32.0', + }); + + await expect(svc.install('2.32.0')).rejects.toThrow( + 'Git version mismatch! Expected: 2.33.0, got: 2.32.0', + ); + }); + + test('link does nothing', async () => { + const { svc } = await toolContext(GitInstallService); + + await expect(svc.link('2.55.0')).resolves.toBeUndefined(); + }); + + test('allows all safe directories', async () => { + const { svc } = await toolContext(GitInstallService); + + await expect(svc.postInstall('2.55.0')).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'git', + ['config', '--system', 'safe.directory', '*'], + expect.objectContaining({ stdio: ['inherit', 'inherit', 1] }), + ); + }); + + test('prints the version', async () => { + const { svc } = await toolContext(GitInstallService); + + await expect(svc.test('2.55.0')).resolves.toBeUndefined(); + + expect(execaMock).toHaveBeenCalledWith( + 'git', + ['--version'], + expect.objectContaining({ stdio: ['inherit', 'inherit', 1] }), + ); + }); + + test('cannot be uninstalled', async () => { + const { svc } = await toolContext(GitInstallService); + + expect(svc.canUninstall).toBe(false); + }); + }); +}); diff --git a/src/cli/tools/git/index.ts b/src/cli/tools/git/index.ts new file mode 100644 index 0000000000..1df15693dc --- /dev/null +++ b/src/cli/tools/git/index.ts @@ -0,0 +1,108 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { codeBlock } from 'common-tags'; +import { execa } from 'execa'; +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, HttpService } from '../../services/index.ts'; +import { getDistro, semverCoerce, semverGte } from '../../utils/index.ts'; + +/** + * Keep in sync with the minimum git version renovate needs. + * https://github.com/renovatebot/renovate/blob/main/lib/util/git/index.ts#L180 + */ +const minVersion = '2.33.0'; + +const keyUrl = + 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xF911AB184317630C59970973E363C90F8F1B6217'; +const keyPath = 'etc/apt/keyrings/git.asc'; + +@injectable() +@injectFromHierarchy() +export class GitPrepareService extends BasePrepareService { + @inject(HttpService) + private readonly http!: HttpService; + + override readonly name = 'git'; + + /** + * Adds the `git-core` ppa, ubuntu ships a git version which is too old. + */ + override async prepare(): Promise { + const distro = await getDistro(); + const key = await this.http.get(keyUrl); + + await mkdir(join(this.envSvc.rootDir, 'etc/apt/keyrings'), { + recursive: true, + mode: 0o755, + }); + + await writeFile(join(this.envSvc.rootDir, keyPath), key, { mode: 0o644 }); + await writeFile( + join(this.envSvc.rootDir, 'etc/apt/sources.list.d/git.sources'), + codeBlock` + Types: deb + URIs: https://ppa.launchpadcontent.net/git-core/ppa/ubuntu + Suites: ${distro.versionCode} + Components: main + Architectures: ${this.envSvc.arch} + Signed-By: /${keyPath} + `, + ); + } +} + +@injectable() +@injectFromHierarchy() +export class GitInstallService extends BaseInstallService { + @inject(AptService) + private readonly aptSvc!: AptService; + + override readonly name = 'git'; + + override readonly needsRoot = true; + + /** git is installed system wide by apt, other tools depend on it */ + override readonly canUninstall = false; + + override async install(_version: string): Promise { + // TODO: the ppa only serves the latest version, so the requested version is ignored + await this.aptSvc.install(this.name); + + const version = await this.installedVersion(); + if (!semverGte(version, minVersion)) { + throw new Error( + `Git version mismatch! Expected: ${minVersion}, got: ${version}`, + ); + } + } + + /** + * git is installed system wide by apt, so there is nothing to link. + */ + override link(_version: string): Promise { + return Promise.resolve(); + } + + override async postInstall(_version: string): Promise { + // flutter workaround + // allow all, so it works in older git versions when the ppa is not working + await this._spawn(this.name, ['config', '--system', 'safe.directory', '*']); + } + + override async test(_version: string): Promise { + await this._spawn(this.name, ['--version']); + } + + private async installedVersion(): Promise { + const res = await execa(this.name, ['--version']); + // `git --version` prints eg. `git version 2.55.0`, but the vendor may add a + // suffix like `2.55.0-1ubuntu1`, which semver can't parse + const coerced = semverCoerce(res.stdout); + if (!coerced) { + throw new Error(`Could not parse the git version: ${res.stdout}`); + } + return coerced.version; + } +} diff --git a/src/cli/utils/codes.ts b/src/cli/utils/codes.ts index 78c935d2df..cd50a5358b 100644 --- a/src/cli/utils/codes.ts +++ b/src/cli/utils/codes.ts @@ -22,3 +22,8 @@ export const CurrentVersion = 17; * A child dependency blocks removal of parent. */ export const BlockingChild = 18; + +/** + * The tool can only be installed or uninstalled as root. + */ +export const NotRoot = 19; diff --git a/src/usr/local/containerbase/tools/git.sh b/src/usr/local/containerbase/tools/git.sh deleted file mode 100644 index acb2da7822..0000000000 --- a/src/usr/local/containerbase/tools/git.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -require_root - -version_codename=$(get_distro) - -install -m 0755 -d /etc/apt/keyrings -curl --retry 3 -fsSL -o /etc/apt/keyrings/git.asc \ - 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xF911AB184317630C59970973E363C90F8F1B6217' -chmod a+r /etc/apt/keyrings/git.asc - -echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/git.asc] https://ppa.launchpadcontent.net/git-core/ppa/ubuntu ${version_codename} main" | tee /etc/apt/sources.list.d/git.list - - -# TODO: Only latest version available on launchpad :-/ -#apt_install git=1:${TOOL_VERSION}* - -apt_install git - -# Keep in sync renovate minimum git version -# https://github.com/renovatebot/renovate/blob/main/lib/util/git/index.ts#L180 -function validate_git_version() { - local required_version="2.33.0" - local current_version - current_version=$(git --version | awk '{print $3}') - - if dpkg --compare-versions "${current_version}" lt "${required_version}"; then - echo "Git version mismatch! Expected: ${required_version}, got: ${current_version}" - exit 1 - fi -} - -validate_git_version - -# flutter workaround -# allow all, so it works in older git versions when ppa is not working -git config --system safe.directory "*" - -[[ -n $SKIP_VERSION ]] || git --version