Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add hermit binary source #16259

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/usage/self-hosted-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ By default, Renovate uses a child process to run such tools, so they must be:

- installed before running Renovate
- available in the path
- managed by Hermit

But you can tell Renovate to use "sidecar" containers for third-party tools by setting `binarySource=docker`.
For this to work, `docker` needs to be installed and the Docker socket available to Renovate.
Expand All @@ -146,6 +147,8 @@ Supported tools for dynamic install are:
- `jb`
- `npm`

If all projects are managed by Hermit, you can tell Renovate to use the tooling versions specified in each project via Hermit by setting `binarySource=hermit`.

Tools not on this list fall back to `binarySource=global`.

## cacheDir
Expand Down
2 changes: 1 addition & 1 deletion lib/config/options/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ const options: RenovateOptions[] = [
'Controls how third-party tools like npm or Gradle are called: directly, via Docker sidecar containers, or via dynamic install.',
globalOnly: true,
type: 'string',
allowedValues: ['global', 'docker', 'install'],
allowedValues: ['global', 'docker', 'install', 'hermit'],
default: 'global',
},
{
Expand Down
2 changes: 1 addition & 1 deletion lib/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export interface RepoGlobalConfig {
allowPostUpgradeCommandTemplating?: boolean;
allowScripts?: boolean;
allowedPostUpgradeCommands?: string[];
binarySource?: 'docker' | 'global' | 'install';
binarySource?: 'docker' | 'global' | 'install' | 'hermit';
customEnvVariables?: Record<string, string>;
dockerChildPrefix?: string;
dockerImagePrefix?: string;
Expand Down
87 changes: 87 additions & 0 deletions lib/util/exec/hermit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
jest.mock('../../config/global');
jest.mock('fs-extra');
jest.mock('./common');

import fs from 'fs-extra';
import { mocked, mockedFunction } from '../../../test/util';
import { GlobalConfig } from '../../config/global';
import { rawExec } from './common';
import { findHermitCwd, getHermitEnvs, isHermit } from './hermit';
import type { ExecResult, RawExecOptions } from './types';

const globalConfigMock = mocked(GlobalConfig);
const fsMock = mocked(fs);
const rawExecMock = mockedFunction(rawExec);

describe('util/exec/hermit', () => {
describe('isHermit', () => {
it('should return true when binarySource is hermit', () => {
globalConfigMock.get.mockReturnValue({ binarySource: 'docker' });
expect(isHermit()).toBeFalsy();
globalConfigMock.get.mockReturnValue({ binarySource: 'hermit' });
expect(isHermit()).toBeTruthy();
});
});

describe('findHermitCwd', () => {
it('should find the closest hermit cwd to the given path', () => {
const root = '/usr/src/app/repository-a';
globalConfigMock.get.mockReturnValue(root);
const nestedCwd = 'nested/other/directory';
fsMock.statSync.mockImplementation((p) => {
if (p === `${root}/bin/hermit` || p === `${root}/nested/bin/hermit`) {
return {} as fs.Stats;
}

throw new Error('not exists');
});

expect(findHermitCwd(nestedCwd)).toBe(`${root}/nested/bin`);
expect(findHermitCwd('other/directory')).toBe(`${root}/bin`);
});

it('should throw error when hermit cwd is not found', () => {
const root = '/usr/src/app/repository-a';
const err = new Error('hermit not found for other/directory');
globalConfigMock.get.mockReturnValue(root);
fsMock.statSync.mockImplementation(() => {
throw new Error('not exists');
});

let e: Error = undefined;

try {
findHermitCwd('other/directory');
} catch (err) {
e = err;
}

expect(e).toStrictEqual(err);
});
});

describe('getHermitEnvs', () => {
it('should return hermit environment variables when hermit env returns successfully', async () => {
const root = '/usr/src/app/repository-a';
globalConfigMock.get.mockReturnValue(root);
fsMock.statSync.mockImplementation((p) => {
if (p === `${root}/bin/hermit`) {
return {} as fs.Stats;
}

throw new Error('not exists');
});
rawExecMock.mockResolvedValue({
stdout: `GOBIN=/usr/src/app/repository-a/.hermit/go/bin
PATH=/usr/src/app/repository-a/bin
`,
} as ExecResult);
const resp = await getHermitEnvs({} as RawExecOptions);

expect(resp).toStrictEqual({
GOBIN: '/usr/src/app/repository-a/.hermit/go/bin',
PATH: '/usr/src/app/repository-a/bin',
});
});
});
});
87 changes: 87 additions & 0 deletions lib/util/exec/hermit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import os from 'os';
import fs from 'fs-extra';
import upath from 'upath';
import { GlobalConfig } from '../../config/global';
import { logger } from '../../logger';
import { rawExec } from './common';
import type { RawExecOptions } from './types';

export function isHermit(): boolean {
const { binarySource } = GlobalConfig.get();
return binarySource === 'hermit';
}

function statFileSync(f: string): fs.Stats | undefined {
let exists: fs.Stats | undefined = undefined;
try {
exists = fs.statSync(f);
} catch (e) {
// not doing anything when file not exists for errors
}

return exists;
}

function statHermit(defaultCwd: string, parts: string[]): fs.Stats | undefined {
const hermitForCwd = upath.join(...[defaultCwd, ...parts, 'bin', 'hermit']);
logger.trace({ hermitForCwd }, 'looking up hermit');
return statFileSync(hermitForCwd);
}

export function findHermitCwd(cwd: string): string {
const defaultCwd = GlobalConfig.get('localDir') ?? '';
const parts = cwd.replace(defaultCwd, '').split(upath.sep);
let exists: fs.Stats | undefined = undefined;

// search the current relative path until reach the defaultCwd
while (parts.length > 0) {
exists = statHermit(defaultCwd, parts);
// on file found. break out of the loop
if (exists !== undefined) {
break;
}
// otherwise, continue searching in parent directory
parts.pop();
}

// search in defaultCwd
if (exists === undefined) {
exists = statHermit(defaultCwd, parts);
}

if (exists === undefined) {
throw new Error(`hermit not found for ${cwd}`);
}

// once the path is found, return ${[path}/bin
// so that hermit runs with ./hermit
return upath.join(...[defaultCwd, ...parts, 'bin']);
}

export async function getHermitEnvs(
rawOptions: RawExecOptions
): Promise<Record<string, string>> {
const cwd = (rawOptions.cwd ?? '').toString();
const hermitCwd = findHermitCwd(cwd);
logger.debug({ cwd, hermitCwd }, 'fetching hermit environment variables');
// with -r will output the raw unquoted environment variables to consume
const hermitEnvResp = await rawExec('./hermit env -r', {
...rawOptions,
cwd: hermitCwd,
});
const hermitEnvVars = hermitEnvResp.stdout
.split(os.EOL)
.reduce((acc: Record<string, string>, line): Record<string, string> => {
const trimmedLine = line.trim();
if (trimmedLine === '') {
return acc;
}
const equalIndex = trimmedLine.indexOf('=');
const name = trimmedLine.substring(0, equalIndex);
const value = trimmedLine.substring(equalIndex + 1);
acc[name] = value;
return acc;
}, {});

return hermitEnvVars;
}
53 changes: 53 additions & 0 deletions lib/util/exec/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import {
exec as _cpExec,
} from 'child_process';
import { envMock } from '../../../test/exec-util';
import { mockedFunction } from '../../../test/util';
import { GlobalConfig } from '../../config/global';
import type { RepoGlobalConfig } from '../../config/types';
import { TEMPORARY_ERROR } from '../../constants/error-messages';
import * as dockerModule from './docker';
import { getHermitEnvs } from './hermit';
import type { ExecOptions, RawExecOptions, VolumeOption } from './types';
import { exec } from '.';

const cpExec: jest.Mock<typeof _cpExec> = _cpExec as any;
const getHermitEnvsMock = mockedFunction(getHermitEnvs);

jest.mock('child_process');
jest.mock('./hermit', () => ({
...jest.requireActual('./hermit'),
getHermitEnvs: jest.fn() as jest.MockedFunction<typeof getHermitEnvs>,
}));
jest.mock('../../modules/datasource');

interface TestInput {
Expand All @@ -22,6 +29,7 @@ interface TestInput {
outCmd: string[];
outOpts: RawExecOptions[];
adminConfig?: Partial<RepoGlobalConfig>;
hermitEnvs?: Record<string, string>;
}

describe('util/exec/index', () => {
Expand Down Expand Up @@ -693,6 +701,46 @@ describe('util/exec/index', () => {
},
},
],

[
'Hermit',
{
processEnv: {
...envMock.basic,
CUSTOM_KEY: 'CUSTOM_VALUE',
PATH: '/home/user-a/bin;/usr/local/bin',
},
inCmd,
inOpts: {
cwd,
},
outCmd: [inCmd],
outOpts: [
{
cwd,
encoding,
env: {
...envMock.basic,
CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE',
GOBIN: '/usr/src/app/repository-a/.hermit/go/bin',
PATH: '/usr/src/app/repository-a/bin/;/home/user-a/bin;/usr/local/bin;',
},
timeout: 900000,
maxBuffer: 10485760,
},
],
hermitEnvs: {
GOBIN: '/usr/src/app/repository-a/.hermit/go/bin',
PATH: '/usr/src/app/repository-a/bin/;/home/user-a/bin;/usr/local/bin;',
},
adminConfig: {
customEnvVariables: {
CUSTOM_KEY: 'CUSTOM_OVERRIDEN_VALUE',
},
binarySource: 'hermit',
},
},
],
];

test.each(testInputs)('%s', async (_msg, testOpts) => {
Expand All @@ -703,6 +751,7 @@ describe('util/exec/index', () => {
outCmd: outCommand,
outOpts,
adminConfig = {} as any,
hermitEnvs,
} = testOpts;

process.env = procEnv;
Expand All @@ -716,6 +765,10 @@ describe('util/exec/index', () => {
return undefined as never;
});
GlobalConfig.set({ cacheDir, localDir: cwd, ...adminConfig });
if (hermitEnvs !== undefined) {
getHermitEnvsMock.mockResolvedValue(hermitEnvs);
}

await exec(cmd as string, inOpts);

expect(actualCmd).toEqual(outCommand);
Expand Down
11 changes: 11 additions & 0 deletions lib/util/exec/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateInstallCommands, isDynamicInstall } from './buildpack';
import { rawExec } from './common';
import { generateDockerCommand, removeDockerContainer } from './docker';
import { getChildProcessEnv } from './env';
import { getHermitEnvs, isHermit } from './hermit';
import type {
DockerOptions,
ExecOptions,
Expand Down Expand Up @@ -129,6 +130,16 @@ async function prepareRawExec(
...(await generateInstallCommands(opts.toolConstraints)),
...rawCommands,
];
} else if (isHermit()) {
const hermitEnvVars = await getHermitEnvs(rawOptions);
logger.debug(
{ hermitEnvVars },
'merging hermit environment variables into the execution options'
);
rawOptions.env = {
...rawOptions.env,
...hermitEnvVars,
};
}

return { rawCommands, rawOptions };
Expand Down