mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-11 22:46:27 +00:00
feat: Log warnings when unknown configuration options or option types found (#554)
This PR adds detection and log warnings for the following config validation errors: - Unknown config option (e.g misspelling of a valid config option) - Config option is a wrong type (e.g. string instead of boolean) It does *not* propagate this warning to the user (i.e. in onboarding or PRs) yet due to the high chance that we'll find a false negative. I will watch logs for a week or so and then once happy with results will activate user-visible warnings via #556. Closes #548, Closes #555
This commit is contained in:
parent
5c712116a1
commit
7d493a14bf
11 changed files with 230 additions and 13 deletions
84
lib/config/validation.js
Normal file
84
lib/config/validation.js
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
const options = require('./definitions').getOptions();
|
||||||
|
|
||||||
|
const optionTypes = {};
|
||||||
|
options.forEach(option => {
|
||||||
|
optionTypes[option.name] = [option.type];
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
validateConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateConfig(config) {
|
||||||
|
let errors = [];
|
||||||
|
|
||||||
|
function isIgnored(key) {
|
||||||
|
const ignoredNodes = ['api', 'depType'];
|
||||||
|
return ignoredNodes.indexOf(key) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAFunction(value) {
|
||||||
|
const getType = {};
|
||||||
|
return value && getType.toString.call(value) === '[object Function]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(obj) {
|
||||||
|
return Object.prototype.toString.call(obj) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(config)) {
|
||||||
|
const val = config[key];
|
||||||
|
if (
|
||||||
|
!isIgnored(key) && // We need to ignore some reserved keys
|
||||||
|
!isAFunction(val) // Ignore all functions
|
||||||
|
) {
|
||||||
|
if (!optionTypes[key]) {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Invalid configuration option: \`${key}\``,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const type = optionTypes[key].toString();
|
||||||
|
if (type === 'boolean') {
|
||||||
|
if (val !== true && val !== false) {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Configuration option \`${key}\` should be boolean`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === 'list') {
|
||||||
|
if (!Array.isArray(val)) {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Configuration option \`${key}\` should be a list (Array)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === 'string') {
|
||||||
|
if (!(typeof val === 'string' || val instanceof String)) {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Configuration option \`${key}\` should be a string`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === 'integer') {
|
||||||
|
if (val !== parseInt(val, 10)) {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Configuration option \`${key}\` should be an integer`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (type === 'json') {
|
||||||
|
if (isObject(val)) {
|
||||||
|
errors = errors.concat(module.exports.validateConfig(val));
|
||||||
|
} else {
|
||||||
|
errors.push({
|
||||||
|
depName: 'Configuration Error',
|
||||||
|
message: `Configuration option \`${key}\` should be a json object`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ const logger = require('../../logger');
|
||||||
const configParser = require('../../config');
|
const configParser = require('../../config');
|
||||||
const repositoryWorker = require('../repository');
|
const repositoryWorker = require('../repository');
|
||||||
const versions = require('./versions');
|
const versions = require('./versions');
|
||||||
|
const configValidation = require('../../config/validation');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
start,
|
start,
|
||||||
|
@ -11,6 +12,10 @@ module.exports = {
|
||||||
async function start() {
|
async function start() {
|
||||||
try {
|
try {
|
||||||
const config = await configParser.parseConfigs(process.env, process.argv);
|
const config = await configParser.parseConfigs(process.env, process.argv);
|
||||||
|
const configErrors = configValidation.validateConfig(config);
|
||||||
|
if (configErrors.length) {
|
||||||
|
logger.error({ configErrors }, 'Found config errors');
|
||||||
|
}
|
||||||
config.logger = logger;
|
config.logger = logger;
|
||||||
config.versions = versions.detectVersions(config);
|
config.versions = versions.detectVersions(config);
|
||||||
// Iterate through repositories sequentially
|
// Iterate through repositories sequentially
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const configParser = require('../../config');
|
const configParser = require('../../config');
|
||||||
const depTypeWorker = require('../dep-type');
|
const depTypeWorker = require('../dep-type');
|
||||||
|
const configValidation = require('../../config/validation');
|
||||||
|
|
||||||
let logger = require('../../logger');
|
let logger = require('../../logger');
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ module.exports = {
|
||||||
|
|
||||||
async function renovatePackageFile(packageFileConfig) {
|
async function renovatePackageFile(packageFileConfig) {
|
||||||
let config = Object.assign({}, packageFileConfig);
|
let config = Object.assign({}, packageFileConfig);
|
||||||
|
let upgrades = [];
|
||||||
logger = config.logger;
|
logger = config.logger;
|
||||||
logger.info(`Processing package file`);
|
logger.info(`Processing package file`);
|
||||||
// If onboarding, use the package.json in onboarding branch unless a custom base branch was defined
|
// If onboarding, use the package.json in onboarding branch unless a custom base branch was defined
|
||||||
|
@ -33,6 +35,23 @@ async function renovatePackageFile(packageFileConfig) {
|
||||||
{ config: packageContent.renovate },
|
{ config: packageContent.renovate },
|
||||||
'package.json>renovate config'
|
'package.json>renovate config'
|
||||||
);
|
);
|
||||||
|
const errors = configValidation.validateConfig(packageContent.renovate);
|
||||||
|
if (errors.length) {
|
||||||
|
logger.warn(
|
||||||
|
{ errors },
|
||||||
|
'Found package.json>renovate configuration errors'
|
||||||
|
);
|
||||||
|
/* TODO #556
|
||||||
|
errors.forEach(error => {
|
||||||
|
upgrades.push(
|
||||||
|
Object.assign({}, error, {
|
||||||
|
depName: `${config.packageFile}(renovate)`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
}
|
||||||
// package.json>renovate config takes precedence over existing config
|
// package.json>renovate config takes precedence over existing config
|
||||||
config = configParser.mergeChildConfig(config, packageContent.renovate);
|
config = configParser.mergeChildConfig(config, packageContent.renovate);
|
||||||
} else {
|
} else {
|
||||||
|
@ -41,14 +60,13 @@ async function renovatePackageFile(packageFileConfig) {
|
||||||
// Now check if config is disabled
|
// Now check if config is disabled
|
||||||
if (config.enabled === false) {
|
if (config.enabled === false) {
|
||||||
logger.info('packageFile is disabled');
|
logger.info('packageFile is disabled');
|
||||||
return [];
|
return upgrades;
|
||||||
}
|
}
|
||||||
|
|
||||||
const depTypeConfigs = config.depTypes.map(depType =>
|
const depTypeConfigs = config.depTypes.map(depType =>
|
||||||
module.exports.getDepTypeConfig(config, depType)
|
module.exports.getDepTypeConfig(config, depType)
|
||||||
);
|
);
|
||||||
logger.trace({ config: depTypeConfigs }, `depTypeConfigs`);
|
logger.trace({ config: depTypeConfigs }, `depTypeConfigs`);
|
||||||
let upgrades = [];
|
|
||||||
for (const depTypeConfig of depTypeConfigs) {
|
for (const depTypeConfig of depTypeConfigs) {
|
||||||
upgrades = upgrades.concat(
|
upgrades = upgrades.concat(
|
||||||
await depTypeWorker.renovateDepType(packageContent, depTypeConfig)
|
await depTypeWorker.renovateDepType(packageContent, depTypeConfig)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
const ini = require('ini');
|
const ini = require('ini');
|
||||||
const jsonValidator = require('json-dup-key-validator');
|
const jsonValidator = require('json-dup-key-validator');
|
||||||
const configParser = require('../../config');
|
const configParser = require('../../config');
|
||||||
|
const configValidation = require('../../config/validation');
|
||||||
// API
|
// API
|
||||||
const githubApi = require('../../api/github');
|
const githubApi = require('../../api/github');
|
||||||
const gitlabApi = require('../../api/gitlab');
|
const gitlabApi = require('../../api/gitlab');
|
||||||
|
@ -119,6 +120,16 @@ async function mergeRenovateJson(config, branchName) {
|
||||||
}
|
}
|
||||||
renovateJson = JSON.parse(renovateJsonContent);
|
renovateJson = JSON.parse(renovateJsonContent);
|
||||||
config.logger.debug({ config: renovateJson }, 'renovate.json config');
|
config.logger.debug({ config: renovateJson }, 'renovate.json config');
|
||||||
|
const renovateJsonErrors = configValidation.validateConfig(renovateJson);
|
||||||
|
if (renovateJsonErrors.length) {
|
||||||
|
config.logger.warn({ renovateJsonErrors }, 'Found renovate.json errors');
|
||||||
|
/* TODO #556
|
||||||
|
renovateJsonErrors.forEach(error => {
|
||||||
|
config.errors.push(
|
||||||
|
Object.assign({}, error, { depName: 'renovate.json' })
|
||||||
|
);
|
||||||
|
}); */
|
||||||
|
}
|
||||||
returnConfig = configParser.mergeChildConfig(returnConfig, renovateJson);
|
returnConfig = configParser.mergeChildConfig(returnConfig, renovateJson);
|
||||||
returnConfig.renovateJsonPresent = true;
|
returnConfig.renovateJsonPresent = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
39
test/config/__snapshots__/validation.spec.js.snap
Normal file
39
test/config/__snapshots__/validation.spec.js.snap
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`config/validation validateConfig(config) errors for all types 1`] = `
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Configuration option \`enabled\` should be boolean",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Configuration option \`schedule\` should be a list (Array)",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Configuration option \`semanticPrefix\` should be a string",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Configuration option \`githubAppId\` should be an integer",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Configuration option \`lockFileMaintenance\` should be a json object",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`config/validation validateConfig(config) returns nested errors 1`] = `
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Invalid configuration option: \`foo\`",
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
"depName": "Configuration Error",
|
||||||
|
"message": "Invalid configuration option: \`bar\`",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`;
|
30
test/config/validation.spec.js
Normal file
30
test/config/validation.spec.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
const configValidation = require('../../lib/config/validation.js');
|
||||||
|
|
||||||
|
describe('config/validation', () => {
|
||||||
|
describe('validateConfig(config)', () => {
|
||||||
|
it('returns nested errors', () => {
|
||||||
|
const config = {
|
||||||
|
foo: 1,
|
||||||
|
prBody: 'some-body',
|
||||||
|
lockFileMaintenance: {
|
||||||
|
bar: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const errors = configValidation.validateConfig(config);
|
||||||
|
expect(errors).toHaveLength(2);
|
||||||
|
expect(errors).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
it('errors for all types', () => {
|
||||||
|
const config = {
|
||||||
|
enabled: 1,
|
||||||
|
schedule: 'after 5pm',
|
||||||
|
semanticPrefix: 7,
|
||||||
|
githubAppId: 'none',
|
||||||
|
lockFileMaintenance: false,
|
||||||
|
};
|
||||||
|
const errors = configValidation.validateConfig(config);
|
||||||
|
expect(errors).toHaveLength(5);
|
||||||
|
expect(errors).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -11,6 +11,13 @@ describe('lib/workers/global', () => {
|
||||||
configParser.getRepositoryConfig = jest.fn();
|
configParser.getRepositoryConfig = jest.fn();
|
||||||
repositoryWorker.renovateRepository = jest.fn();
|
repositoryWorker.renovateRepository = jest.fn();
|
||||||
});
|
});
|
||||||
|
it('handles config errors', async () => {
|
||||||
|
configParser.parseConfigs.mockReturnValueOnce({
|
||||||
|
repositories: [],
|
||||||
|
foo: 1,
|
||||||
|
});
|
||||||
|
await globalWorker.start();
|
||||||
|
});
|
||||||
it('handles zero repos', async () => {
|
it('handles zero repos', async () => {
|
||||||
configParser.parseConfigs.mockReturnValueOnce({
|
configParser.parseConfigs.mockReturnValueOnce({
|
||||||
repositories: [],
|
repositories: [],
|
||||||
|
@ -19,7 +26,7 @@ describe('lib/workers/global', () => {
|
||||||
});
|
});
|
||||||
it('processes repositories', async () => {
|
it('processes repositories', async () => {
|
||||||
configParser.parseConfigs.mockReturnValueOnce({
|
configParser.parseConfigs.mockReturnValueOnce({
|
||||||
foo: 1,
|
enabled: true,
|
||||||
repositories: ['a', 'b'],
|
repositories: ['a', 'b'],
|
||||||
});
|
});
|
||||||
await globalWorker.start();
|
await globalWorker.start();
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`packageFileWorker renovatePackageFile(config) handles renovate config errors 1`] = `Array []`;
|
|
@ -22,6 +22,12 @@ describe('packageFileWorker', () => {
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
it('handles renovate config errors', async () => {
|
||||||
|
config.enabled = false;
|
||||||
|
config.api.getFileJson.mockReturnValueOnce({ renovate: { foo: 1 } });
|
||||||
|
const res = await packageFileWorker.renovatePackageFile(config);
|
||||||
|
expect(res).toMatchSnapshot();
|
||||||
|
});
|
||||||
it('handles null', async () => {
|
it('handles null', async () => {
|
||||||
const allUpgrades = await packageFileWorker.renovatePackageFile(config);
|
const allUpgrades = await packageFileWorker.renovatePackageFile(config);
|
||||||
expect(allUpgrades).toHaveLength(1);
|
expect(allUpgrades).toHaveLength(1);
|
||||||
|
|
|
@ -52,7 +52,7 @@ exports[`workers/repository/apis mergeRenovateJson(config) returns error in conf
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"depName": "renovate.json",
|
"depName": "renovate.json",
|
||||||
"message": "Syntax error: expecting String near { foo: 1 }",
|
"message": "Syntax error: expecting String near { enabled:",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
@ -61,7 +61,9 @@ exports[`workers/repository/apis mergeRenovateJson(config) returns error plus ex
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
"depName": "renovate.json",
|
"depName": "renovate.json",
|
||||||
"message": "Syntax error: duplicated keys \\"foo\\" near \\"foo\\": 2 }",
|
"message": "Syntax error: duplicated keys \\"enabled\\" near \\": false }",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`workers/repository/apis mergeRenovateJson(config) returns error plus extended config if unknown keys 1`] = `Array []`;
|
||||||
|
|
|
@ -129,29 +129,41 @@ describe('workers/repository/apis', () => {
|
||||||
expect(await apis.mergeRenovateJson(config)).toEqual(config);
|
expect(await apis.mergeRenovateJson(config)).toEqual(config);
|
||||||
});
|
});
|
||||||
it('returns extended config if renovate.json found', async () => {
|
it('returns extended config if renovate.json found', async () => {
|
||||||
config.api.getFileContent.mockReturnValueOnce('{ "foo": 1 }');
|
config.api.getFileContent.mockReturnValueOnce('{ "enabled": true }');
|
||||||
const returnConfig = await apis.mergeRenovateJson(config);
|
const returnConfig = await apis.mergeRenovateJson(config);
|
||||||
expect(returnConfig.foo).toBe(1);
|
expect(returnConfig.enabled).toBe(true);
|
||||||
expect(returnConfig.renovateJsonPresent).toBe(true);
|
expect(returnConfig.renovateJsonPresent).toBe(true);
|
||||||
expect(returnConfig.errors).toHaveLength(0);
|
expect(returnConfig.errors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
it('returns error plus extended config if duplicate keys', async () => {
|
it('returns error plus extended config if unknown keys', async () => {
|
||||||
config.api.getFileContent.mockReturnValueOnce('{ "foo": 1, "foo": 2 }');
|
config.api.getFileContent.mockReturnValueOnce(
|
||||||
|
'{ "enabled": true, "foo": false }'
|
||||||
|
);
|
||||||
const returnConfig = await apis.mergeRenovateJson(config);
|
const returnConfig = await apis.mergeRenovateJson(config);
|
||||||
expect(returnConfig.foo).toBe(2);
|
expect(returnConfig.enabled).toBe(true);
|
||||||
|
expect(returnConfig.renovateJsonPresent).toBe(true);
|
||||||
|
expect(returnConfig.errors).toHaveLength(0); // TODO: Update to 1 later
|
||||||
|
expect(returnConfig.errors).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
it('returns error plus extended config if duplicate keys', async () => {
|
||||||
|
config.api.getFileContent.mockReturnValueOnce(
|
||||||
|
'{ "enabled": true, "enabled": false }'
|
||||||
|
);
|
||||||
|
const returnConfig = await apis.mergeRenovateJson(config);
|
||||||
|
expect(returnConfig.enabled).toBe(false);
|
||||||
expect(returnConfig.renovateJsonPresent).toBe(true);
|
expect(returnConfig.renovateJsonPresent).toBe(true);
|
||||||
expect(returnConfig.errors).toHaveLength(1);
|
expect(returnConfig.errors).toHaveLength(1);
|
||||||
expect(returnConfig.errors).toMatchSnapshot();
|
expect(returnConfig.errors).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
it('returns error in config if renovate.json cannot be parsed', async () => {
|
it('returns error in config if renovate.json cannot be parsed', async () => {
|
||||||
config.api.getFileContent.mockReturnValueOnce('{ foo: 1 }');
|
config.api.getFileContent.mockReturnValueOnce('{ enabled: true }');
|
||||||
const returnConfig = await apis.mergeRenovateJson(config);
|
const returnConfig = await apis.mergeRenovateJson(config);
|
||||||
expect(returnConfig.foo).toBeUndefined();
|
expect(returnConfig.enabled).toBeUndefined();
|
||||||
expect(returnConfig.renovateJsonPresent).toBeUndefined();
|
expect(returnConfig.renovateJsonPresent).toBeUndefined();
|
||||||
expect(returnConfig.errors).toMatchSnapshot();
|
expect(returnConfig.errors).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
it('returns error in JSON.parse', async () => {
|
it('returns error in JSON.parse', async () => {
|
||||||
config.api.getFileContent.mockReturnValueOnce('{ foo: 1 }');
|
config.api.getFileContent.mockReturnValueOnce('{ enabled: true }');
|
||||||
jsonValidator.validate = jest.fn();
|
jsonValidator.validate = jest.fn();
|
||||||
jsonValidator.validate.mockReturnValueOnce(false);
|
jsonValidator.validate.mockReturnValueOnce(false);
|
||||||
jsonValidator.validate.mockReturnValueOnce(false);
|
jsonValidator.validate.mockReturnValueOnce(false);
|
||||||
|
|
Loading…
Reference in a new issue