diff --git a/Dockerfile b/Dockerfile index 47e56e494a..e32cdeadb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,8 @@ COPY dist/cli/containerbase-cli-${TARGETARCH} /usr/local/containerbase/bin/conta RUN install-containerbase -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 LABEL org.opencontainers.image.version="${CONTAINERBASE_VERSION}" diff --git a/docs/custom-base-image.md b/docs/custom-base-image.md index 15eb824485..8fcd35317f 100644 --- a/docs/custom-base-image.md +++ b/docs/custom-base-image.md @@ -40,8 +40,8 @@ COPY --from=containerbase /usr/local/containerbase/ /usr/local/containerbase/ RUN install-containerbase -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.30.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 # renovate: datasource=github-releases packageName=containerbase/node-prebuild versioning=node RUN install-tool node 20.9.0 # renovate: datasource=npm versioning=npm @@ -86,8 +86,8 @@ COPY --from=containerbase /usr/local/containerbase/ /usr/local/containerbase/ RUN install-containerbase -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.30.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 # renovate: datasource=github-releases packageName=containerbase/node-prebuild versioning=node RUN install-tool node 20.9.0 # renovate: datasource=npm versioning=npm @@ -130,8 +130,19 @@ COPY --from=containerbase /usr/local/containerbase/ /usr/local/containerbase/ RUN install-containerbase -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.30.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 USER $USER_NAME ``` + +## git + +`install-tool git` installs git from the containerbase prebuild into `/usr/local/bin`, not from Ubuntu. +To stay compatible with images built on Ubuntu's git, it also: + +- installs the apt packages Ubuntu's git pulled in: `git-man`, `perl`, `liberror-perl` and `libcurl3-gnutls` (`libcurl3t64-gnutls` on noble and newer) +- links `git`, `git-receive-pack`, `git-shell`, `git-upload-archive`, `git-upload-pack` and `scalar` into `/usr/bin`, where Ubuntu's git put them, unless a file already exists there + +Both go away in the next major release, see [#7821](https://github.com/containerbase/base/issues/7821) and [#7822](https://github.com/containerbase/base/issues/7822). +If your image needs any of these packages, install them yourself, e.g. `install-apt perl git-man libcurl3-gnutls` on jammy or `install-apt perl git-man libcurl3t64-gnutls` on noble and newer. diff --git a/docs/custom-registries.md b/docs/custom-registries.md index 00fdcc1858..e62afe7370 100644 --- a/docs/custom-registries.md +++ b/docs/custom-registries.md @@ -298,11 +298,27 @@ https://github.com/cli/cli/releases/download/v2.97.0/gh_2.97.0_checksums.txt ## `git` -Git is downloaded from: +Git releases are downloaded from: -- `http://ppa.launchpad.net/git-core/ppa/ubuntu` +- `https://github.com/containerbase/git-prebuild/releases` -Git is installed via `apt` package manager. +The last url is used to find the latest version, if no version or no exact `X.Y.Z` version was provided. + +Samples: + +```txt +https://github.com/containerbase/git-prebuild/releases/download/2.56.0/git-2.56.0-jammy-x86_64.tar.xz.sha512 +https://github.com/containerbase/git-prebuild/releases/download/2.56.0/git-2.56.0-jammy-x86_64.tar.xz +https://github.com/containerbase/git-prebuild/releases/download/2.56.0/git-2.56.0-jammy-aarch64.tar.xz.sha512 +https://github.com/containerbase/git-prebuild/releases/download/2.56.0/git-2.56.0-jammy-aarch64.tar.xz +https://github.com/containerbase/git-prebuild/releases/latest/download/version +``` + +The download is always verified against the `.sha512` checksum, so a mirror needs to provide both files. +An exact `X.Y.Z` version from `2.48.0` on is downloaded as is, a release without a prebuild fails the download. +Any other version, like a partial `2.50`, a version before `2.48.0` or a vendor version like `2.43.0-1ubuntu1`, falls back to the newest prebuild with a warning. +The runtime libraries git needs are installed via `apt` package manager, see [custom base image](./custom-base-image.md#git) for the compatibility packages. +Images prepared by an older containerbase keep the git-core PPA apt source it added, they get the runtime libraries on the next `install-tool git`. ### `git-lfs` diff --git a/src/cli/install-tool/base-install.service.ts b/src/cli/install-tool/base-install.service.ts index def6f45b89..342b91c6f2 100644 --- a/src/cli/install-tool/base-install.service.ts +++ b/src/cli/install-tool/base-install.service.ts @@ -52,13 +52,12 @@ export abstract class BaseInstallService { /** * Some tools can only be installed as root, so they are only available at - * image build time. Eg. git is installed via apt. + * image build time. Eg. git, which writes the system wide `/etc/gitconfig`. */ 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. + * Some tools can't be uninstalled, eg. git, which other tools depend on. */ readonly canUninstall: boolean = true; diff --git a/src/cli/install-tool/version-resolvers.ts b/src/cli/install-tool/version-resolvers.ts index 591bb062b9..4e26d52f75 100644 --- a/src/cli/install-tool/version-resolvers.ts +++ b/src/cli/install-tool/version-resolvers.ts @@ -1,4 +1,5 @@ import { NugetVersionResolver } from '../tools/dotnet/nuget.ts'; +import { GitVersionResolver } from '../tools/git/index.ts'; import { ResolverMap } from '../tools/index.ts'; import { AndroidSdkCmdlineToolsVersionResolver } from '../tools/java/android.ts'; import { GradleVersionResolver } from '../tools/java/gradle.ts'; @@ -32,6 +33,7 @@ export const versionResolvers: readonly VersionResolverClass[] = [ CocoapodsVersionResolver, ConanVersionResolver, ComposerVersionResolver, + GitVersionResolver, GradleVersionResolver, JavaVersionResolver, JavaJreVersionResolver, diff --git a/src/cli/tools/git/index.spec.ts b/src/cli/tools/git/index.spec.ts index ec1659aff1..b46a48bc66 100644 --- a/src/cli/tools/git/index.spec.ts +++ b/src/cli/tools/git/index.spec.ts @@ -1,29 +1,47 @@ -import { readFile } from 'node:fs/promises'; +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 { fileContent, getDistro } from '../../utils/index.ts'; -import { GitInstallService, GitPrepareService } from './index.ts'; +import { resolveVersion } from '../../install-tool/index.ts'; +import { CompressionService, LinkToolService } from '../../services/index.ts'; +import { getDistro, logger } from '../../utils/index.ts'; +import { + GitInstallService, + GitPrepareService, + GitVersionResolver, +} from './index.ts'; import { scope } from '~test/http-mock.ts'; import { ensurePaths, rootPath } from '~test/path.ts'; -import { toolContext } from '~test/tool.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 = '/containerbase/git-prebuild/releases/download/2.56.0'; +const tarball = 'git archive'; + describe('cli/tools/git/index', () => { beforeAll(async () => { - // `etc/apt/sources.list.d` ships with the image + // `usr/bin` and `usr/local/bin` ship with the image await ensurePaths([ 'tmp', - 'etc/apt/sources.list.d', 'opt/containerbase/bin', + 'usr/bin', + 'usr/local/bin', ]); }); beforeEach(() => { + vi.mocked(arch).mockReturnValue('x64'); vi.mocked(getDistro).mockResolvedValue({ name: 'Ubuntu', versionCode: 'noble', @@ -31,103 +49,175 @@ describe('cli/tools/git/index', () => { }); // 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', - }); + execaMock.mockResolvedValue({ failed: false }); }); describe('GitPrepareService', () => { - test('adds the ppa', async () => { + test.each([ + { code: 'jammy', pkgs: ['libcurl4', 'libssl3', 'libcurl3-gnutls'] }, + { + code: 'noble', + pkgs: ['libcurl4t64', 'libssl3t64', 'libcurl3t64-gnutls'], + }, + { + code: 'resolute', + pkgs: ['libcurl4t64', 'libssl3t64', 'libcurl3t64-gnutls'], + }, + ])('installs the runtime packages on $code', async ({ code, pkgs }) => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: code, + versionId: '24.04', + }); 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(execaMock).toHaveBeenCalledWith( + 'apt-get', + [ + '-qq', + 'install', + '-y', + 'ca-certificates', + 'libexpat1', + 'libpcre2-8-0', + 'zlib1g', + 'git-man', + 'liberror-perl', + 'perl', + ...pkgs, + ], + { env: { DEBIAN_FRONTEND: 'noninteractive' } }, + ); + }); + + test('throws on an unsupported distro', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'focal', + versionId: '20.04', + }); + const { svc } = await toolContext(GitPrepareService); + + await expect(svc.prepare()).rejects.toThrow( + "Tool 'git' not supported on: focal!", ); - expect( - await readFile(rootPath('etc/apt/sources.list.d/git.sources'), 'utf8'), - ).toBe(fileContent` - 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); + test.each<{ cpu: NodeJS.Architecture; ghArch: string }>([ + { cpu: 'x64', ghArch: 'x86_64' }, + { cpu: 'arm64', ghArch: 'aarch64' }, + ])('installs the jammy prebuild for $ghArch', async ({ cpu, ghArch }) => { + vi.mocked(arch).mockReturnValue(cpu); + const filename = `git-2.56.0-jammy-${ghArch}.tar.xz`; + scope(baseUrl) + .get(`${releaseUrl}/${filename}.sha512`) + .reply(200, `${checksum(tarball, 'sha512')}\n`) + .get(`${releaseUrl}/${filename}`) + .reply(200, tarball); + const spy = vi.spyOn(CompressionService.prototype, 'extract'); + const { svc, pathSvc } = await toolContext(GitInstallService); - await expect(svc.install('2.55.0')).resolves.toBeUndefined(); + await expect(svc.install('2.56.0')).resolves.toBeUndefined(); + expect(spy).toHaveBeenCalledExactlyOnceWith({ + file: expect.stringContaining(filename), + cwd: pathSvc.toolPath('git'), + }); + // also for images prepared before git came from the prebuild expect(execaMock).toHaveBeenCalledWith( 'apt-get', - ['-qq', 'install', '-y', 'git'], + [ + '-qq', + 'install', + '-y', + 'ca-certificates', + 'libexpat1', + 'libpcre2-8-0', + 'zlib1g', + 'git-man', + 'liberror-perl', + 'perl', + 'libcurl4t64', + 'libssl3t64', + 'libcurl3t64-gnutls', + ], { env: { DEBIAN_FRONTEND: 'noninteractive' } }, ); }); - test('install: coerces a vendor version suffix', async () => { - const { svc } = await toolContext(GitInstallService); - execaMock.mockResolvedValue({ - failed: false, - stdout: 'git version 2.55.0-1ubuntu1', + test('install throws on an unsupported distro before downloading', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'focal', + versionId: '20.04', }); - - await expect(svc.install('2.55.0')).resolves.toBeUndefined(); - }); - - test('install: accepts ubuntu git below the published minimum', async () => { const { svc } = await toolContext(GitInstallService); - execaMock.mockResolvedValue({ - failed: false, - stdout: 'git version 2.34.1', - }); - await expect(svc.install('2.40.0')).resolves.toBeUndefined(); - expect(svc.minVersion).toBe('2.48.0'); + await expect(svc.install('2.56.0')).rejects.toThrow( + "Tool 'git' not supported on: focal!", + ); }); - test('install: throws on an unparsable version', async () => { + test.each([ + { version: '2.48.0', valid: true }, + { version: '2.56.0', valid: true }, + { version: '2.47.3', valid: false }, + { version: 'foo', valid: false }, + ])('validates $version: $valid', async ({ version, valid }) => { 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', - ); + expect(await svc.validate(version)).toBe(valid); }); - test('install: rejects a version below the minimum', async () => { - const { svc } = await toolContext(GitInstallService); - execaMock.mockResolvedValue({ - failed: false, - stdout: 'git version 2.32.0', - }); + test('symlinks the git binaries without a shell wrapper', async () => { + const wrapper = vi.spyOn(LinkToolService.prototype, 'shellwrapper'); + const { svc, pathSvc } = await toolContext(GitInstallService); + const binaries = [ + 'git', + 'git-receive-pack', + 'git-shell', + 'git-upload-archive', + 'git-upload-pack', + 'scalar', + ]; - await expect(svc.install('2.32.0')).rejects.toThrow( - 'Git version mismatch! Expected: 2.33.0, got: 2.32.0', - ); + await expect(svc.link('2.55.0')).resolves.toBeUndefined(); + // a version switch replaces the links + await expect(svc.link('2.56.0')).resolves.toBeUndefined(); + + const bin = join(pathSvc.versionedToolPath('git', '2.56.0'), 'bin'); + for (const name of binaries) { + expect(await fs.readlink(join(pathSvc.binDir, name))).toBe( + join(bin, name), + ); + // compatibility links follow the `/usr/local/bin` ones + expect(await fs.readlink(rootPath(`usr/bin/${name}`))).toBe( + rootPath(`usr/local/bin/${name}`), + ); + } + expect(wrapper).not.toHaveBeenCalled(); }); - test('link does nothing', async () => { + test('keeps an existing file in /usr/bin', async () => { const { svc } = await toolContext(GitInstallService); + const usrBinGit = rootPath('usr/bin/git'); + await fs.rm(usrBinGit, { force: true }); + await fs.writeFile(usrBinGit, 'ubuntu git'); - await expect(svc.link('2.55.0')).resolves.toBeUndefined(); + await expect(svc.link('2.56.0')).resolves.toBeUndefined(); + + expect(await fs.readFile(usrBinGit, 'utf8')).toBe('ubuntu git'); + await fs.rm(usrBinGit); }); test('allows all safe directories', async () => { const { svc } = await toolContext(GitInstallService); - await expect(svc.postInstall('2.55.0')).resolves.toBeUndefined(); + await expect(svc.postInstall('2.56.0')).resolves.toBeUndefined(); expect(execaMock).toHaveBeenCalledWith( 'git', @@ -139,7 +229,7 @@ describe('cli/tools/git/index', () => { test('prints the version', async () => { const { svc } = await toolContext(GitInstallService); - await expect(svc.test('2.55.0')).resolves.toBeUndefined(); + await expect(svc.test('2.56.0')).resolves.toBeUndefined(); expect(execaMock).toHaveBeenCalledWith( 'git', @@ -148,10 +238,82 @@ describe('cli/tools/git/index', () => { ); }); - test('cannot be uninstalled', async () => { + test('needs root and cannot be uninstalled', async () => { const { svc } = await toolContext(GitInstallService); + expect(svc.needsRoot).toBe(true); expect(svc.canUninstall).toBe(false); + expect(svc.minVersion).toBe('2.48.0'); + }); + }); + + describe('GitVersionResolver', () => { + test.each([{ version: undefined }, { version: 'latest' }])( + 'resolves $version to the latest prebuild', + async ({ version }) => { + scope(baseUrl) + .get('/containerbase/git-prebuild/releases/latest/download/version') + .reply(200, '2.56.0'); + const { svc } = await toolContext(GitVersionResolver); + + expect(await svc.resolve(version)).toBe('2.56.0'); + }, + ); + + test.each([ + { version: '2.48.0' }, + { version: '2.56.0' }, + // newer than the newest prebuild, fails the download + { version: '3.0.0' }, + ])('keeps $version without any request', async ({ version }) => { + const { svc } = await toolContext(GitVersionResolver); + + expect(await svc.resolve(version)).toBe(version); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test.each([ + { version: '2', reason: 'a partial major' }, + { version: '2.40', reason: 'a partial major.minor' }, + { version: '2.47.3', reason: 'before the first prebuild' }, + { version: '2.33.0', reason: 'before the first prebuild' }, + { version: '2.57.0-rc0', reason: 'a prerelease' }, + { version: '2.43.0-1ubuntu1', reason: 'a vendor version' }, + { version: 'foo', reason: 'no version' }, + ])( + 'falls back to the newest prebuild for $version, $reason', + async ({ version }) => { + scope(baseUrl) + .get('/containerbase/git-prebuild/releases/latest/download/version') + .reply(200, '2.56.0'); + const { svc } = await toolContext(GitVersionResolver); + + expect(await svc.resolve(version)).toBe('2.56.0'); + expect(logger.warn).toHaveBeenCalledExactlyOnceWith( + `No git ${version} prebuild, installing the newest git 2.56.0 instead`, + ); + }, + ); + + test('does not depend on the distro', async () => { + vi.mocked(getDistro).mockResolvedValue({ + name: 'Ubuntu', + versionCode: 'focal', + versionId: '20.04', + }); + const { svc } = await toolContext(GitVersionResolver); + + expect(await svc.resolve('2.56.0')).toBe('2.56.0'); + expect(getDistro).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + test('install-tool installs the version it resolved', async () => { + scope(baseUrl) + .get('/containerbase/git-prebuild/releases/latest/download/version') + .reply(200, '2.56.0'); + + expect(await resolveVersion('git', '2.40.0')).toBe('2.56.0'); }); }); }); diff --git a/src/cli/tools/git/index.ts b/src/cli/tools/git/index.ts index 7a27fd6bdf..018dda50cc 100644 --- a/src/cli/tools/git/index.ts +++ b/src/cli/tools/git/index.ts @@ -1,73 +1,81 @@ -import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { execa } from 'execa'; +import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; 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 { AptService } from '../../services/index.ts'; +import { logger, semverGte } from '../../utils/index.ts'; import { - fileContent, - getDistro, - semverCoerce, - semverGte, -} from '../../utils/index.ts'; + PrebuildInstallService, + PrebuildVersionResolver, +} from '../utils/prebuild.ts'; /** * The oldest git minor that still gets security fixes and the first version - * of the containerbase git prebuilds, published as the minimum version. + * of the containerbase git prebuilds, published as the minimum version. It is + * above the 2.33.0 renovate needs. + * https://github.com/renovatebot/renovate/blob/main/lib/util/git/index.ts#L180 */ const minVersion = '2.48.0'; /** - * The oldest git the installed version is checked against: the minimum git - * version renovate needs, which ubuntu's own git also meets when apt can't - * use the ppa. - * https://github.com/renovatebot/renovate/blob/main/lib/util/git/index.ts#L180 + * The libraries the git prebuild links against on every ubuntu release, and + * the packages ubuntu's git pulled in, kept for compatibility until the next + * major, see #7821. */ -const installedMinVersion = '2.33.0'; +const commonPackages = [ + 'ca-certificates', + 'libexpat1', + 'libpcre2-8-0', + 'zlib1g', + // compatibility, see #7821 + 'git-man', + 'liberror-perl', + 'perl', +]; -const keyUrl = - 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0xF911AB184317630C59970973E363C90F8F1B6217'; -const keyPath = 'etc/apt/keyrings/git.asc'; +/** + * The distro specific libraries the git prebuild links against, and the curl + * library ubuntu's git pulled in, kept for compatibility until the next + * major, see #7821. + */ +const distroPackages: Record = { + jammy: ['libcurl4', 'libssl3', 'libcurl3-gnutls'], + noble: ['libcurl4t64', 'libssl3t64', 'libcurl3t64-gnutls'], + resolute: ['libcurl4t64', 'libssl3t64', 'libcurl3t64-gnutls'], +}; + +/** + * The binaries ubuntu's git package puts on the path, without the + * `git-cvsserver` stub of the perl-less prebuild. + */ +const binaries = [ + 'git', + 'git-receive-pack', + 'git-shell', + 'git-upload-archive', + 'git-upload-pack', + 'scalar', +]; @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. + * Installs the apt packages the git prebuild needs on the current ubuntu + * release. + * + * @throws on an unsupported distro */ 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'), - fileContent` - Types: deb - URIs: https://ppa.launchpadcontent.net/git-core/ppa/ubuntu - Suites: ${distro.versionCode} - Components: main - Architectures: ${this.envSvc.arch} - Signed-By: /${keyPath} - `, - ); + await this.installDistroPackages(distroPackages, ...commonPackages); } } @injectable() @injectFromHierarchy() -export class GitInstallService extends BaseInstallService { +export class GitInstallService extends PrebuildInstallService { @inject(AptService) private readonly aptSvc!: AptService; @@ -77,60 +85,99 @@ export class GitInstallService extends BaseInstallService { override readonly minVersion = minVersion; - /** git is installed system wide by apt, other tools depend on it */ + /** Other tools depend on git, so it can't be uninstalled. */ override readonly canUninstall = false; /** - * Installs git from the ppa and verifies the version is new enough. + * Installs the apt packages the prebuild needs, also when the image was + * prepared before git came from the prebuild, then the prebuild itself. + * + * @throws on an unsupported distro */ - 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, installedMinVersion)) { - throw new Error( - `Git version mismatch! Expected: ${installedMinVersion}, got: ${version}`, - ); - } + override async install(version: string): Promise { + await this.aptSvc.installDistroPackages( + this.name, + distroPackages, + ...commonPackages, + ); + await super.install(version); } /** - * git is installed system wide by apt, so there is nothing to link. + * Symlinks the git binaries into the global bin folder. git needs no + * containerbase env and finds its own files through its real path, and a + * shell wrapper would neither forward signals nor keep the caller's env. + * + * Also links them into `/usr/bin`, where ubuntu's git put them, pointing to + * the `/usr/local/bin` links, so a version switch follows. Existing files, + * like an ubuntu git installed before, are kept. These links stay for + * compatibility until the next major, see #7822. */ - override link(_version: string): Promise { - return Promise.resolve(); + override async link(version: string): Promise { + const srcDir = join( + this.pathSvc.versionedToolPath(this.name, version), + 'bin', + ); + const usrBin = join(this.envSvc.rootDir, 'usr/bin'); + const usrLocalBin = join(this.envSvc.rootDir, 'usr/local/bin'); + for (const name of binaries) { + await this.symlink({ srcDir, name }); + await this.pathSvc.createSymlink( + join(usrLocalBin, name), + join(usrBin, name), + ); + } } /** - * Marks every repository as safe, whoever owns it. + * Marks every repository as safe, whoever owns it, in the system wide + * `/etc/gitconfig`. */ 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', '*']); } /** - * Prints the installed version, which also proves git runs. + * Only versions from the first prebuild on can be installed. The version + * resolver already replaces older ones with the newest prebuild. */ - override async test(_version: string): Promise { - await this._spawn(this.name, ['--version']); + override async validate(version: string): Promise { + return (await super.validate(version)) && semverGte(version, minVersion); } +} + +@injectable() +@injectFromHierarchy() +export class GitVersionResolver extends PrebuildVersionResolver { + readonly tool = 'git'; /** - * The version apt installed, which is not necessarily the requested one. + * Resolves a version: * - * @throws when the output holds no version at all + * - A missing version or `latest` resolves to the newest prebuild. + * - A full `X.Y.Z` version from the first prebuild on is kept without any + * request; a release without a prebuild fails the download. + * - Any other version, like a partial `2.50`, a version before the first + * prebuild or a vendor version like `2.43.0-1ubuntu1`, resolves to the + * newest prebuild, with a warning, so the version which is installed and + * recorded is the real one. */ - 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}`); + override async resolve( + version: string | undefined, + ): Promise { + if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { + return await super.resolve(version); + } + + if (/^\d+\.\d+\.\d+$/.test(version) && semverGte(version, minVersion)) { + return version; } - return coerced.version; + + const latest = await this.latestVersion(); + logger.warn( + `No git ${version} prebuild, installing the newest git ${latest} instead`, + ); + return latest; } } diff --git a/test/Dockerfile.distro b/test/Dockerfile.distro index a65a5f7324..6bb66bdf05 100644 --- a/test/Dockerfile.distro +++ b/test/Dockerfile.distro @@ -25,8 +25,65 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 + +RUN set -ex; \ + [ "$(command -v git)" = /usr/local/bin/git ]; \ + for bin in git git-receive-pack git-shell git-upload-archive git-upload-pack scalar; do \ + readlink -f "/usr/local/bin/${bin}" | grep '^/opt/containerbase/tools/git/2\.56\.0/bin/'; \ + [ -x "/usr/local/bin/${bin}" ]; \ + readlink -f "/usr/bin/${bin}" | grep '^/opt/containerbase/tools/git/2\.56\.0/bin/'; \ + done; \ + git --version | grep '^git version 2\.56\.0$'; \ + /usr/bin/git --version | grep '^git version 2\.56\.0$'; \ + perl -v; \ + git --exec-path | grep '^/opt/containerbase/tools/git/2\.56\.0/libexec/git-core$'; \ + su ubuntu -s /bin/sh -c 'env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp git --version' | grep '^git version 2\.56\.0$'; \ + git config --system --show-origin --get safe.directory | grep '^file:/etc/gitconfig[[:space:]]\*$'; \ + git clone --depth 1 https://github.com/containerbase/git-prebuild.git /tmp/git-prebuild; \ + rm -rf /tmp/git-prebuild; \ + true + +#-------------------------------------- +# Image: test-git-min +#-------------------------------------- +FROM base AS test-git-min + +RUN touch /.dummy + +# versions before the first prebuild fall back to the newest one +RUN set -ex; \ + install-tool git 2.40.0 > /git-fallback.log 2>&1 || { cat /git-fallback.log; exit 1; }; \ + cat /git-fallback.log; \ + grep -F 'No git 2.40.0 prebuild, installing the newest git ' /git-fallback.log; \ + newest="$(sed -n 's/.*No git 2\.40\.0 prebuild, installing the newest git \([0-9.]*\) instead.*/\1/p' /git-fallback.log)"; \ + [ -n "${newest}" ]; \ + [ ! -e /opt/containerbase/tools/git/2.40.0 ]; \ + git --version | grep -Fx "git version ${newest}"; \ + readlink -f /usr/local/bin/git | grep -Fx "/opt/containerbase/tools/git/${newest}/bin/git"; \ + rm /git-fallback.log; \ + true + +# the oldest supported git, keep it in sync with `minVersion` in +# `src/cli/tools/git/index.ts`, no renovate updates +RUN install-tool git 2.48.0 + +RUN set -ex; \ + [ "$(command -v git)" = /usr/local/bin/git ]; \ + for bin in git git-receive-pack git-shell git-upload-archive git-upload-pack scalar; do \ + readlink -f "/usr/local/bin/${bin}" | grep '^/opt/containerbase/tools/git/2\.48\.0/bin/'; \ + [ -x "/usr/local/bin/${bin}" ]; \ + done; \ + git --version | grep '^git version 2\.48\.0$'; \ + /usr/bin/git --version | grep '^git version 2\.48\.0$'; \ + git --exec-path | grep '^/opt/containerbase/tools/git/2\.48\.0/libexec/git-core$'; \ + git config --system --show-origin --get safe.directory | grep '^file:/etc/gitconfig[[:space:]]\*$'; \ + git init -q /tmp/git-min; \ + git -C /tmp/git-min status; \ + git ls-remote https://github.com/containerbase/git-prebuild.git HEAD; \ + rm -rf /tmp/git-min; \ + true #-------------------------------------- # Image: build @@ -74,6 +131,11 @@ RUN install-tool flux v2.9.6 # renovate: datasource=github-releases packageName=git-lfs/git-lfs RUN install-tool git-lfs v3.8.0 +RUN set -ex; \ + git lfs version | grep '^git-lfs/3\.8\.0 '; \ + git config --system --get filter.lfs.process | grep -F 'git-lfs filter-process'; \ + true + # renovate: datasource=github-releases packageName=gleam-lang/gleam RUN install-tool gleam 1.19.2 @@ -277,6 +339,7 @@ FROM base COPY --from=test /.dummy /.dummy COPY --from=test-docker /.dummy /.dummy COPY --from=test-erlang /.dummy /.dummy +COPY --from=test-git-min /.dummy /.dummy COPY --from=test-haskell /.dummy /.dummy COPY --from=test-java /.dummy /.dummy COPY --from=test-mono /.dummy /.dummy diff --git a/test/flutter/Dockerfile b/test/flutter/Dockerfile index c43dba3af6..b109af314f 100644 --- a/test/flutter/Dockerfile +++ b/test/flutter/Dockerfile @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # TODO: move to prepare -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/flutter/Dockerfile.arm64 b/test/flutter/Dockerfile.arm64 index d502c0dd96..0ac2f05134 100644 --- a/test/flutter/Dockerfile.arm64 +++ b/test/flutter/Dockerfile.arm64 @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # flutter currently needs git -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/golang/Dockerfile b/test/golang/Dockerfile index 5db9fe5baa..b3d665e977 100644 --- a/test/golang/Dockerfile +++ b/test/golang/Dockerfile @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # TODO: move to prepare -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/golang/Dockerfile.arm64 b/test/golang/Dockerfile.arm64 index cdf52a4c15..94c3449e85 100644 --- a/test/golang/Dockerfile.arm64 +++ b/test/golang/Dockerfile.arm64 @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # currently go doesn't install git -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/java/Dockerfile b/test/java/Dockerfile index 56f2b51f45..fabc7b4540 100644 --- a/test/java/Dockerfile +++ b/test/java/Dockerfile @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # Used for tests -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/latest/Dockerfile b/test/latest/Dockerfile index d05237aa04..1fffa0605b 100644 --- a/test/latest/Dockerfile +++ b/test/latest/Dockerfile @@ -175,8 +175,8 @@ FROM base AS testc RUN prepare-tool docker # TODO: move to prepare of git-lfs -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 USER 12021 @@ -396,6 +396,30 @@ RUN install-tool nuget 7.9.0 RUN nuget search nuget.commandline -Take 1 +#-------------------------------------- +# test: git and git-lfs +#-------------------------------------- +FROM base AS test-git + +# git comes with the base image +# renovate: datasource=github-releases packageName=git-lfs/git-lfs +RUN install-tool git-lfs v3.8.0 + +USER 12021 + +RUN set -ex; \ + [ "$(command -v git)" = /usr/local/bin/git ]; \ + readlink -f /usr/local/bin/git | grep '^/opt/containerbase/tools/git/[0-9.]*/bin/git$'; \ + git --exec-path | grep '^/opt/containerbase/tools/git/[0-9.]*/libexec/git-core$'; \ + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp git --version; \ + git config --system --show-origin --get safe.directory | grep '^file:/etc/gitconfig[[:space:]]\*$'; \ + git lfs version | grep '^git-lfs/3\.8\.0 '; \ + git config --system --get filter.lfs.process | grep -F 'git-lfs filter-process'; \ + git clone --depth 1 https://github.com/containerbase/git-prebuild.git /tmp/git-prebuild; \ + git -C /tmp/git-prebuild lfs ls-files; \ + rm -rf /tmp/git-prebuild; \ + true + #-------------------------------------- # test: v1 and v2 shell tools of custom images #-------------------------------------- @@ -441,4 +465,5 @@ 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-git /.dummy /.dummy COPY --link --from=test-legacy /.dummy /.dummy diff --git a/test/latest/Dockerfile.arm64 b/test/latest/Dockerfile.arm64 index 9aac3f67c7..12266d9e4f 100644 --- a/test/latest/Dockerfile.arm64 +++ b/test/latest/Dockerfile.arm64 @@ -128,17 +128,42 @@ RUN set -ex; \ #-------------------------------------- FROM base AS test-git -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 + +USER 12021 + +RUN set -ex; \ + [ "$(command -v git)" = /usr/local/bin/git ]; \ + readlink -f /usr/local/bin/git | grep '^/opt/containerbase/tools/git/2\.56\.0/bin/git$'; \ + git --exec-path | grep '^/opt/containerbase/tools/git/2\.56\.0/libexec/git-core$'; \ + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/tmp git --version | grep '^git version 2\.56\.0$'; \ + git config --system --show-origin --get safe.directory | grep '^file:/etc/gitconfig[[:space:]]\*$'; \ + git clone --depth 1 https://github.com/containerbase/git-prebuild.git /tmp/git-prebuild; \ + git -C /tmp/git-prebuild log -1 --oneline; \ + rm -rf /tmp/git-prebuild; \ + true #-------------------------------------- # Image: git-lfs #-------------------------------------- FROM test-git AS test-git-lfs +USER root + # renovate: datasource=github-releases packageName=git-lfs/git-lfs RUN install-tool git-lfs v3.8.0 +USER 12021 + +RUN set -ex; \ + git lfs version | grep '^git-lfs/3\.8\.0 '; \ + git config --system --get filter.lfs.process | grep -F 'git-lfs filter-process'; \ + git clone --depth 1 https://github.com/containerbase/git-prebuild.git /tmp/git-prebuild; \ + git -C /tmp/git-prebuild lfs ls-files; \ + rm -rf /tmp/git-prebuild; \ + true + #-------------------------------------- # Image: helmfile #-------------------------------------- diff --git a/test/ruby/Dockerfile b/test/ruby/Dockerfile index bd85f90c6b..ba66379d47 100644 --- a/test/ruby/Dockerfile +++ b/test/ruby/Dockerfile @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # TODO: move to prepare for bundler and cocoapods -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base diff --git a/test/ruby/Dockerfile.arm64 b/test/ruby/Dockerfile.arm64 index 6672f3fa35..75462ec6a9 100644 --- a/test/ruby/Dockerfile.arm64 +++ b/test/ruby/Dockerfile.arm64 @@ -54,8 +54,8 @@ RUN install-tool bundler 4.0.23 FROM test-ruby AS test-cocoapods # cocoapods need git, but don't install yet -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 # renovate: datasource=rubygems versioning=ruby RUN install-tool cocoapods 1.17.0 diff --git a/test/swift/Dockerfile b/test/swift/Dockerfile index 486b7d7ce6..bf205efce8 100644 --- a/test/swift/Dockerfile +++ b/test/swift/Dockerfile @@ -20,8 +20,8 @@ ARG CONTAINERBASE_LOG_LEVEL RUN install-containerbase # TODO: move to prepare -# renovate: datasource=github-tags packageName=git/git -RUN install-tool git v2.56.0 +# renovate: datasource=github-releases packageName=containerbase/git-prebuild +RUN install-tool git 2.56.0 #-------------------------------------- # Image: base