Skip to content

Commit

Permalink
feat(config)!: parse JSON5/YAML self-hosted admin config (#12644)
Browse files Browse the repository at this point in the history
Adds support for alternative admin config file formats.

BREAKING CHANGE: Renovate will now fail if RENOVATE_CONFIG_FILE is specified without a file extension
  • Loading branch information
nejch authored Dec 9, 2021
1 parent 7c4a71b commit 9aa97af
Show file tree
Hide file tree
Showing 6 changed files with 92 additions and 31 deletions.
4 changes: 4 additions & 0 deletions docs/development/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ Options which have `"globalOnly": true` are reserved only for bot global configu
You can override default configuration using a configuration file, with default name `config.js` in the working directory.
If you need an alternate location or name, set it in the environment variable `RENOVATE_CONFIG_FILE`.

**Note:** `RENOVATE_CONFIG_FILE` must be provided with an explicit file extension.
For example `RENOVATE_CONFIG_FILE=myconfig.js` or `RENOVATE_CONFIG_FILE=myconfig.json` and not `RENOVATE_CONFIG_FILE=myconfig`.
If none is provided, or the file type is invalid, Renovate will fail.

Using a configuration file gives you very granular configuration options.
For instance, you can override most settings at the global (file), repository, or package level.
e.g. apply one set of labels for `backend/package.json` and a different set of labels for `frontend/package.json` in the same repository.
Expand Down
19 changes: 8 additions & 11 deletions lib/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
// istanbul ignore file
import { dequal } from 'dequal';
import { readFile } from 'fs-extra';
import JSON5 from 'json5';
import { configFileNames } from './config/app-strings';
import { massageConfig } from './config/massage';
import { migrateConfig } from './config/migration';
import type { RenovateConfig } from './config/types';
import { validateConfig } from './config/validation';
import { logger } from './logger';
import { getConfig as getFileConfig } from './workers/global/config/parse/file';
import {
getConfig as getFileConfig,
getParsedContent,
} from './workers/global/config/parse/file';

let returnVal = 0;

Expand Down Expand Up @@ -52,22 +54,17 @@ type PackageJson = {
(name) => name !== 'package.json'
)) {
try {
const rawContent = await readFile(file, 'utf8');
logger.info(`Validating ${file}`);
const parsedContent = await getParsedContent(file);
try {
let jsonContent: RenovateConfig;
if (file.endsWith('.json5')) {
jsonContent = JSON5.parse(rawContent);
} else {
jsonContent = JSON.parse(rawContent);
}
await validate(file, jsonContent);
logger.info(`Validating ${file}`);
await validate(file, parsedContent);
} catch (err) {
logger.info({ err }, `${file} is not valid Renovate config`);
returnVal = 1;
}
} catch (err) {
// file does not exist
continue;
}
}
try {
Expand Down
4 changes: 4 additions & 0 deletions lib/workers/global/config/parse/__fixtures__/config.json5
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
// comment
"token": "abcdefg",
}
3 changes: 3 additions & 0 deletions lib/workers/global/config/parse/__fixtures__/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
# comment
token: abcdefg
59 changes: 44 additions & 15 deletions lib/workers/global/config/parse/file.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fs from 'fs';
import { DirectoryResult, dir } from 'tmp-promise';
import upath from 'upath';
import { logger } from '../../../../logger';
import customConfig from './__fixtures__/file';
import * as file from './file';

Expand All @@ -16,12 +17,17 @@ describe('workers/global/config/parse/file', () => {
});

describe('.getConfig()', () => {
it('parses custom config file', async () => {
const configFile = upath.resolve(__dirname, './__fixtures__/file.js');
it.each([
['custom config file with extension', 'file.js'],
['JSON5 config file', 'config.json5'],
['YAML config file', 'config.yaml'],
])('parses %s', async (fileType, filePath) => {
const configFile = upath.resolve(__dirname, './__fixtures__/', filePath);
expect(
await file.getConfig({ RENOVATE_CONFIG_FILE: configFile })
).toEqual(customConfig);
});

it('migrates', async () => {
const configFile = upath.resolve(__dirname, './__fixtures__/file2.js');
const res = await file.getConfig({ RENOVATE_CONFIG_FILE: configFile });
Expand All @@ -33,12 +39,10 @@ describe('workers/global/config/parse/file', () => {
expect(await file.getConfig({})).toBeDefined();
});

it('fatal error and exit if error in parsing config.js', async () => {
const mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
const configFile = upath.resolve(tmp.path, './file3.js');
const fileContent = `module.exports = {
it.each([
[
'config.js',
`module.exports = {
"platform": "github",
"token":"abcdef",
"logFileLevel": "warn",
Expand All @@ -48,13 +52,23 @@ describe('workers/global/config/parse/file', () => {
"extends": ["config:base"],
},
"repositories": [ "test/test" ],
};`;
fs.writeFileSync(configFile, fileContent, { encoding: 'utf8' });
await file.getConfig({ RENOVATE_CONFIG_FILE: configFile });
expect(mockProcessExit).toHaveBeenCalledWith(1);

fs.unlinkSync(configFile);
});
};`,
],
['config.json5', `"invalid":`],
['config.yaml', `invalid: -`],
])(
'fatal error and exit if error in parsing %s',
async (fileName, fileContent) => {
const mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementationOnce(() => undefined as never);
const configFile = upath.resolve(tmp.path, fileName);
fs.writeFileSync(configFile, fileContent, { encoding: 'utf8' });
await file.getConfig({ RENOVATE_CONFIG_FILE: configFile });
expect(mockProcessExit).toHaveBeenCalledWith(1);
fs.unlinkSync(configFile);
}
);

it('fatal error and exit if custom config file does not exist', async () => {
const mockProcessExit = jest
Expand All @@ -66,5 +80,20 @@ describe('workers/global/config/parse/file', () => {

expect(mockProcessExit).toHaveBeenCalledWith(1);
});

it.each([
['invalid config file type', './file.txt'],
['missing config file type', './file'],
])('fatal error and exit if %s', async (fileType, filePath) => {
const mockProcessExit = jest
.spyOn(process, 'exit')
.mockImplementationOnce(() => undefined as never);
const configFile = upath.resolve(tmp.path, filePath);
fs.writeFileSync(configFile, `{"token": "abc"}`, { encoding: 'utf8' });
await file.getConfig({ RENOVATE_CONFIG_FILE: configFile });
expect(mockProcessExit).toHaveBeenCalledWith(1);
expect(logger.fatal).toHaveBeenCalledWith('Unsupported file type');
fs.unlinkSync(configFile);
});
});
});
34 changes: 29 additions & 5 deletions lib/workers/global/config/parse/file.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
import { load } from 'js-yaml';
import JSON5 from 'json5';
import upath from 'upath';
import { migrateConfig } from '../../../../config/migration';
import type { AllConfig } from '../../../../config/types';
import type { AllConfig, RenovateConfig } from '../../../../config/types';
import { logger } from '../../../../logger';
import { readFile } from '../../../../util/fs';

export async function getParsedContent(file: string): Promise<RenovateConfig> {
switch (upath.extname(file)) {
case '.yaml':
case '.yml':
return load(await readFile(file, 'utf8'), {
json: true,
}) as RenovateConfig;
case '.json5':
case '.json':
return JSON5.parse(await readFile(file, 'utf8'));
case '.js': {
const tmpConfig = await import(file);
return tmpConfig.default ? tmpConfig.default : tmpConfig;
}
default:
throw new Error('Unsupported file type');
}
}

export async function getConfig(env: NodeJS.ProcessEnv): Promise<AllConfig> {
let configFile = env.RENOVATE_CONFIG_FILE || 'config';
let configFile = env.RENOVATE_CONFIG_FILE || 'config.js';
if (!upath.isAbsolute(configFile)) {
configFile = `${process.cwd()}/${configFile}`;
logger.debug('Checking for config file in ' + configFile);
}
logger.debug('Checking for config file in ' + configFile);
let config: AllConfig = {};
try {
const tmpConfig = await import(configFile);
config = tmpConfig.default ? tmpConfig.default : tmpConfig;
config = await getParsedContent(configFile);
} catch (err) {
// istanbul ignore if
if (err instanceof SyntaxError || err instanceof TypeError) {
logger.fatal(`Could not parse config file \n ${err.stack}`);
process.exit(1);
} else if (err.message === 'Unsupported file type') {
logger.fatal(err.message);
process.exit(1);
} else if (env.RENOVATE_CONFIG_FILE) {
logger.fatal('No custom config file found on disk');
process.exit(1);
Expand Down

0 comments on commit 9aa97af

Please sign in to comment.