diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index 52fbdd607b6c92118c3a15dc3c29cbfa2520e8f8..af593128fbcc0d756660b9c0d5c86166bcc414e0 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -672,6 +672,58 @@ By default, Renovate does not autodiscover repositories that are mirrors. Change this setting to `true` to include repositories that are mirrors as Renovate targets. +## inheritConfig + +When you enable this option, Renovate will look for the `inheritConfigFileName` file in the `inheritConfigRepoName` repository before processing a repository, and read this in as config. + +If the repository is in a nested organization or group on a supported platform such as GitLab, such as `topGroup/nestedGroup/projectName` then Renovate will look in `topGroup/nestedGroup/renovate-config`. + +If `inheritConfig` is `true` but the inherited config file does _not_ exist then Renovate will proceed without warning. +If the file exists but cannot be parsed, then Renovate will raise a config warning issue and abort the job. + +The inherited config may include all valid repository config and these config options: + +- `bbUseDevelopmentBranch` +- `onboarding` +- `onboardingBranch` +- `onboardingCommitMessage` +- `onboardingConfig` +- `onboardingConfigFileName` +- `onboardingNoDeps` +- `onboardingPrTitle` +- `onboardingRebaseCheckbox` +- `requireConfig` + +<!-- prettier-ignore --> +!!! note + The above list is prepared manually and may become out of date. + Consult the self-hosted configuration docs and look for `inheritConfigSupport` values there for the definitive list. + +This way organizations can change/control the default behavior, like whether configs are required and how repositories are onboarded. + +We disabled `inheritConfig` in the Mend Renovate App to avoid wasting millions of API calls per week. +This is because each `404` response from the GitHub API due to a missing org inherited config counts as a used API call. +We will add a smart/dynamic approach in future, so that we can selectively enable `inheritConfig` per organization. + +## inheritConfigFileName + +Change this setting if you want Renovate to look for a different file name within the `inheritConfigRepoName` repository. +You may use nested files, for example: `"some-dir/config.json"`. + +## inheritConfigRepoName + +Change this setting if you want Renovate to look in an alternative repository for the inherited config. +The repository must be on the same platform and endpoint, and Renovate's token must have `read` permissions to the repository. + +## inheritConfigStrict + +By default Renovate will silently (debug log message only) ignore cases where `inheritConfig=true` but no inherited config is found. +When you set `inheritConfigStrict=true` then Renovate will abort the run and raise a config error if Renovate can't find the inherited config. + +<!-- prettier-ignore --> +!!! warning + Only set this config option to `true` if _every_ organization has an inherited config file _and_ you want to make sure Renovate _always_ uses that inherited config. + ## logContext `logContext` is included with each log entry only if `logFormat="json"` - it is not included in the pretty log output. diff --git a/lib/config/index.spec.ts b/lib/config/index.spec.ts index 9a3e6a40c61fe9ebc7b531aab99bd3cdcb161bee..d8d81d4e2622131ffad4bd9f505af9a871651539 100644 --- a/lib/config/index.spec.ts +++ b/lib/config/index.spec.ts @@ -1,5 +1,10 @@ import { getConfig } from './defaults'; -import { filterConfig, getManagerConfig, mergeChildConfig } from './index'; +import { + filterConfig, + getManagerConfig, + mergeChildConfig, + removeGlobalConfig, +} from './index'; jest.mock('../modules/datasource/npm'); jest.mock('../../config.js', () => ({}), { virtual: true }); @@ -131,4 +136,20 @@ describe('config/index', () => { expect(config.vulnerabilitySeverity).toBe('CRITICAL'); }); }); + + describe('removeGlobalConfig()', () => { + it('removes all global config', () => { + const filteredConfig = removeGlobalConfig(defaultConfig, false); + expect(filteredConfig).not.toHaveProperty('onboarding'); + expect(filteredConfig).not.toHaveProperty('binarySource'); + expect(filteredConfig.prHourlyLimit).toBe(2); + }); + + it('retains inherited config', () => { + const filteredConfig = removeGlobalConfig(defaultConfig, true); + expect(filteredConfig).toHaveProperty('onboarding'); + expect(filteredConfig).not.toHaveProperty('binarySource'); + expect(filteredConfig.prHourlyLimit).toBe(2); + }); + }); }); diff --git a/lib/config/index.ts b/lib/config/index.ts index 869a8250e5a9da907701d5e6496253b4f75e747e..0494348f4849a177ddaf119092f4c915c00981e6 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -31,6 +31,22 @@ export function getManagerConfig( return managerConfig; } +export function removeGlobalConfig( + config: RenovateConfig, + keepInherited: boolean, +): RenovateConfig { + const outputConfig: RenovateConfig = { ...config }; + for (const option of options.getOptions()) { + if (keepInherited && option.inheritConfigSupport) { + continue; + } + if (option.globalOnly) { + delete outputConfig[option.name]; + } + } + return outputConfig; +} + export function filterConfig( inputConfig: AllConfig, targetStage: RenovateConfigStage, @@ -39,6 +55,7 @@ export function filterConfig( const outputConfig: RenovateConfig = { ...inputConfig }; const stages: (string | undefined)[] = [ 'global', + 'inherit', 'repository', 'package', 'branch', diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index fdaa18906471abe4be6a3f62362b0846952e6008..c8e743b6c16ec890b51a7741b9e55caf62f0b02f 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -122,6 +122,7 @@ const options: RenovateOptions[] = [ type: 'string', default: 'renovate/configure', globalOnly: true, + inheritConfigSupport: true, cli: false, }, { @@ -131,6 +132,7 @@ const options: RenovateOptions[] = [ type: 'string', default: null, globalOnly: true, + inheritConfigSupport: true, cli: false, }, { @@ -140,6 +142,7 @@ const options: RenovateOptions[] = [ type: 'string', default: 'renovate.json', globalOnly: true, + inheritConfigSupport: true, cli: false, }, { @@ -148,6 +151,7 @@ const options: RenovateOptions[] = [ type: 'boolean', default: false, globalOnly: true, + inheritConfigSupport: true, }, { name: 'onboardingPrTitle', @@ -156,6 +160,7 @@ const options: RenovateOptions[] = [ type: 'string', default: 'Configure Renovate', globalOnly: true, + inheritConfigSupport: true, cli: false, }, { @@ -507,6 +512,7 @@ const options: RenovateOptions[] = [ stage: 'repository', type: 'boolean', globalOnly: true, + inheritConfigSupport: true, }, { name: 'onboardingConfig', @@ -515,6 +521,7 @@ const options: RenovateOptions[] = [ type: 'object', default: { $schema: 'https://docs.renovatebot.com/renovate-schema.json' }, globalOnly: true, + inheritConfigSupport: true, mergeable: true, }, { @@ -583,6 +590,38 @@ const options: RenovateOptions[] = [ default: true, globalOnly: true, }, + { + name: 'inheritConfig', + description: + 'If `true`, Renovate will inherit configuration from the `inheritConfigFileName` file in `inheritConfigRepoName', + type: 'boolean', + default: false, + globalOnly: true, + }, + { + name: 'inheritConfigRepoName', + description: + 'Renovate will look in this repo for the `inheritConfigFileName`.', + type: 'string', + default: '{{parentOrg}}/renovate-config', + globalOnly: true, + }, + { + name: 'inheritConfigFileName', + description: + 'Renovate will look for this config file name in the `inheritConfigRepoName`.', + type: 'string', + default: 'org-inherited-config.json', + globalOnly: true, + }, + { + name: 'inheritConfigStrict', + description: + 'If `true`, any `inheritedConfig` fetch errror will result in an aborted run.', + type: 'boolean', + default: false, + globalOnly: true, + }, { name: 'requireConfig', description: @@ -592,6 +631,7 @@ const options: RenovateOptions[] = [ default: 'required', allowedValues: ['required', 'optional', 'ignored'], globalOnly: true, + inheritConfigSupport: true, }, { name: 'optimizeForDisabled', @@ -1921,6 +1961,7 @@ const options: RenovateOptions[] = [ default: false, supportedPlatforms: ['bitbucket'], globalOnly: true, + inheritConfigSupport: true, }, // Automatic merging { diff --git a/lib/config/types.ts b/lib/config/types.ts index 06ea517e8410e1361b57678c8a2d7ff9df1f9d8b..6200580499410068625bb0f0ab6652e3b20dc2fc 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -8,6 +8,7 @@ import type { MergeConfidence } from '../util/merge-confidence/types'; export type RenovateConfigStage = | 'global' + | 'inherit' | 'repository' | 'package' | 'branch' @@ -232,6 +233,11 @@ export interface RenovateConfig hostRules?: HostRule[]; + inheritConfig?: boolean; + inheritConfigFileName?: string; + inheritConfigRepoName?: string; + inheritConfigStrict?: boolean; + ignorePresets?: string[]; forkProcessing?: 'auto' | 'enabled' | 'disabled'; isFork?: boolean; @@ -394,6 +400,8 @@ export interface RenovateOptionBase { */ globalOnly?: boolean; + inheritConfigSupport?: boolean; + allowedValues?: string[]; allowString?: boolean; diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index 9d675b5b7c578e854ef7810e2ba45828d55a5f42..1f0fc7290a7a868aa38540ec054fec8e3e4b8893 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -45,10 +45,30 @@ describe('config/validation', () => { expect(warnings).toHaveLength(2); expect(warnings).toMatchObject([ { - message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within repository config file.`, + message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, }, { - message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within repository config file.`, + message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, + }, + ]); + }); + + it('catches global options in inherit config', async () => { + const config = { + binarySource: 'something', + username: 'user', + }; + const { warnings } = await configValidation.validateConfig( + 'inherit', + config, + ); + expect(warnings).toHaveLength(2); + expect(warnings).toMatchObject([ + { + message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, + }, + { + message: `The "username" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, }, ]); }); @@ -70,6 +90,17 @@ describe('config/validation', () => { expect(warnings).toHaveLength(0); }); + it('does not warn for valid inheritConfig', async () => { + const config = { + onboarding: false, + }; + const { warnings } = await configValidation.validateConfig( + 'inherit', + config, + ); + expect(warnings).toHaveLength(0); + }); + it('catches invalid templates', async () => { const config = { commitMessage: '{{{something}}', @@ -1020,7 +1051,7 @@ describe('config/validation', () => { expect(warnings).toMatchObject([ { topic: 'Configuration Error', - message: `The "customEnvVariables" option is a global option reserved only for Renovate's global configuration and cannot be configured within repository config file.`, + message: `The "customEnvVariables" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, }, ]); }); @@ -1426,7 +1457,7 @@ describe('config/validation', () => { }, { topic: 'Configuration Error', - message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within repository config file.`, + message: `The "binarySource" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, }, ]); }); diff --git a/lib/config/validation.ts b/lib/config/validation.ts index 17c67b56928aedfbc489e3dedf810508e397be15..976a91b028644b2967523f7731c3cadcd4c3f7bc 100644 --- a/lib/config/validation.ts +++ b/lib/config/validation.ts @@ -40,6 +40,7 @@ const options = getOptions(); let optionTypes: Record<string, RenovateOptions['type']>; let optionParents: Record<string, AllowedParents[]>; let optionGlobals: Set<string>; +let optionInherits: Set<string>; const managerList = getManagerList(); @@ -98,6 +99,18 @@ function getDeprecationMessage(option: string): string | undefined { return deprecatedOptions[option]; } +function isInhertConfigOption(key: string): boolean { + if (!optionInherits) { + optionInherits = new Set(); + for (const option of options) { + if (option.inheritConfigSupport) { + optionInherits.add(option.name); + } + } + } + return optionInherits.has(key); +} + function isGlobalOption(key: string): boolean { if (!optionGlobals) { optionGlobals = new Set(); @@ -121,7 +134,7 @@ export function getParentName(parentPath: string | undefined): string { } export async function validateConfig( - configType: 'global' | 'repo', + configType: 'global' | 'inherit' | 'repo', config: RenovateConfig, isPreset?: boolean, parentPath?: string, @@ -164,22 +177,25 @@ export async function validateConfig( }); } - if (configType === 'global' && isGlobalOption(key)) { - await validateGlobalConfig( - key, - val, - optionTypes[key], - warnings, - errors, - currentPath, - config, - ); - continue; - } else { - if (isGlobalOption(key) && !isFalseGlobal(key, parentPath)) { + if (isGlobalOption(key)) { + if (configType === 'global') { + await validateGlobalConfig( + key, + val, + optionTypes[key], + warnings, + errors, + currentPath, + config, + ); + continue; + } else if ( + !isFalseGlobal(key, parentPath) && + !(configType === 'inherit' && isInhertConfigOption(key)) + ) { warnings.push({ topic: 'Configuration Error', - message: `The "${key}" option is a global option reserved only for Renovate's global configuration and cannot be configured within repository config file.`, + message: `The "${key}" option is a global option reserved only for Renovate's global configuration and cannot be configured within a repository's config file.`, }); continue; } diff --git a/lib/constants/error-messages.ts b/lib/constants/error-messages.ts index aa7da4b79e500e6c5e83752496f31d3b0e103cc7..434c39926f9d10ab85d2562b2975ba0a49badb22 100644 --- a/lib/constants/error-messages.ts +++ b/lib/constants/error-messages.ts @@ -17,6 +17,8 @@ export const CONFIG_PRESETS_INVALID = 'config-presets-invalid'; export const CONFIG_SECRETS_EXPOSED = 'config-secrets-exposed'; export const CONFIG_SECRETS_INVALID = 'config-secrets-invalid'; export const CONFIG_GIT_URL_UNAVAILABLE = 'config-git-url-unavailable'; +export const CONFIG_INHERIT_NOT_FOUND = 'config-inherit-not-found'; +export const CONFIG_INHERIT_PARSE_ERROR = 'config-inherit-parse-error'; // Repository Errors - causes repo to be considered as disabled export const REPOSITORY_ACCESS_FORBIDDEN = 'forbidden'; diff --git a/lib/workers/repository/init/config.ts b/lib/workers/repository/init/config.ts index 96f64a7ba73913a486306bae18ab9492b3e82f57..a8f9a14c6dfeceb8b5b3293cdccd72dce10ab32b 100644 --- a/lib/workers/repository/init/config.ts +++ b/lib/workers/repository/init/config.ts @@ -1,5 +1,6 @@ import type { RenovateConfig } from '../../../config/types'; import { checkOnboardingBranch } from '../onboarding/branch'; +import { mergeInheritedConfig } from './inherited'; import { mergeRenovateConfig } from './merge'; // istanbul ignore next @@ -8,6 +9,7 @@ export async function getRepoConfig( ): Promise<RenovateConfig> { let config = { ...config_ }; config.baseBranch = config.defaultBranch; + config = await mergeInheritedConfig(config); config = await checkOnboardingBranch(config); config = await mergeRenovateConfig(config); return config; diff --git a/lib/workers/repository/init/inherited.spec.ts b/lib/workers/repository/init/inherited.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f89c18201649c8397af7995baba786f1e6dc8ff8 --- /dev/null +++ b/lib/workers/repository/init/inherited.spec.ts @@ -0,0 +1,87 @@ +import { platform } from '../../../../test/util'; +import type { RenovateConfig } from '../../../config/types'; +import { + CONFIG_INHERIT_NOT_FOUND, + CONFIG_INHERIT_PARSE_ERROR, + CONFIG_VALIDATION, +} from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { mergeInheritedConfig } from './inherited'; + +describe('workers/repository/init/inherited', () => { + let config: RenovateConfig; + + beforeEach(() => { + config = { + repository: 'test/repo', + inheritConfig: true, + inheritConfigRepoName: 'inherit/repo', + inheritConfigFileName: 'config.json', + inheritConfigStrict: false, + }; + }); + + it('should return the same config if repository or inheritConfig is not defined', async () => { + config.repository = undefined; + const result = await mergeInheritedConfig(config); + expect(result).toEqual(config); + }); + + it('should return the same config if inheritConfigRepoName or inheritConfigFileName is not a string', async () => { + config.inheritConfigRepoName = undefined; + const result = await mergeInheritedConfig(config); + expect(result).toEqual(config); + }); + + it('should throw an error if getting the raw file fails and inheritConfigStrict is true', async () => { + config.inheritConfigStrict = true; + platform.getRawFile.mockRejectedValue(new Error('File not found')); + await expect(mergeInheritedConfig(config)).rejects.toThrow( + CONFIG_INHERIT_NOT_FOUND, + ); + }); + + it('should return the same config if getting the raw file fails and inheritConfigStrict is false', async () => { + platform.getRawFile.mockRejectedValue(new Error('File not found')); + const result = await mergeInheritedConfig(config); + expect(result).toEqual(config); + }); + + it('should throw an error if parsing the inherited config fails', async () => { + platform.getRawFile.mockResolvedValue('invalid json'); + await expect(mergeInheritedConfig(config)).rejects.toThrow( + CONFIG_INHERIT_PARSE_ERROR, + ); + }); + + it('should throw an error if config includes an invalid option', async () => { + platform.getRawFile.mockResolvedValue('{"something": "invalid"}'); + await expect(mergeInheritedConfig(config)).rejects.toThrow( + CONFIG_VALIDATION, + ); + }); + + it('should throw an error if config includes an invalid value', async () => { + platform.getRawFile.mockResolvedValue('{"onboarding": "invalid"}'); + await expect(mergeInheritedConfig(config)).rejects.toThrow( + CONFIG_VALIDATION, + ); + }); + + it('should warn if validateConfig returns warnings', async () => { + platform.getRawFile.mockResolvedValue('{"binarySource": "docker"}'); + const res = await mergeInheritedConfig(config); + expect(res.binarySource).toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should merge inherited config', async () => { + platform.getRawFile.mockResolvedValue( + '{"onboarding":false,"labels":["test"]}', + ); + const res = await mergeInheritedConfig(config); + expect(res.labels).toEqual(['test']); + expect(res.onboarding).toBeFalse(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/workers/repository/init/inherited.ts b/lib/workers/repository/init/inherited.ts new file mode 100644 index 0000000000000000000000000000000000000000..f25be4f3f3169e861084cb76d5faefab19cfcd9d --- /dev/null +++ b/lib/workers/repository/init/inherited.ts @@ -0,0 +1,103 @@ +import is from '@sindresorhus/is'; +import { dequal } from 'dequal'; +import { mergeChildConfig, removeGlobalConfig } from '../../../config'; +import { parseFileConfig } from '../../../config/parse'; +import type { RenovateConfig } from '../../../config/types'; +import { validateConfig } from '../../../config/validation'; +import { + CONFIG_INHERIT_NOT_FOUND, + CONFIG_INHERIT_PARSE_ERROR, + CONFIG_VALIDATION, +} from '../../../constants/error-messages'; +import { logger } from '../../../logger'; +import { platform } from '../../../modules/platform'; +import * as template from '../../../util/template'; + +export async function mergeInheritedConfig( + config: RenovateConfig, +): Promise<RenovateConfig> { + // typescript doesn't know that repo is defined + if (!config.repository || !config.inheritConfig) { + return config; + } + if ( + !is.string(config.inheritConfigRepoName) || + !is.string(config.inheritConfigFileName) + ) { + // Config validation should prevent this error + logger.error( + { + inheritConfigRepoName: config.inheritConfigRepoName, + inheritConfigFileName: config.inheritConfigFileName, + }, + 'Invalid inherited config.', + ); + return config; + } + const templateConfig = { + topLevelOrg: config.topLevelOrg, + parentOrg: config.parentOrg, + repository: config.repository, + }; + const inheritConfigRepoName = template.compile( + config.inheritConfigRepoName, + templateConfig, + false, + ); + logger.trace( + { templateConfig, inheritConfigRepoName }, + 'Compiled inheritConfigRepoName result.', + ); + logger.debug( + `Checking for inherited config file ${config.inheritConfigFileName} in repo ${inheritConfigRepoName}.`, + ); + let configFileRaw: string | null = null; + try { + configFileRaw = await platform.getRawFile( + config.inheritConfigFileName, + inheritConfigRepoName, + ); + } catch (err) { + if (config.inheritConfigStrict) { + logger.debug({ err }, 'Error getting inherited config.'); + throw new Error(CONFIG_INHERIT_NOT_FOUND); + } + logger.trace({ err }, `Error getting inherited config.`); + } + if (!configFileRaw) { + logger.debug(`No inherited config found in ${inheritConfigRepoName}.`); + return config; + } + const parseResult = parseFileConfig( + config.inheritConfigFileName, + configFileRaw, + ); + if (!parseResult.success) { + logger.debug({ parseResult }, 'Error parsing inherited config.'); + throw new Error(CONFIG_INHERIT_PARSE_ERROR); + } + const inheritedConfig = parseResult.parsedContents as RenovateConfig; + logger.debug({ config: inheritedConfig }, `Inherited config`); + const res = await validateConfig('inherit', inheritedConfig); + if (res.errors.length) { + logger.warn( + { errors: res.errors }, + 'Found errors in inherited configuration.', + ); + throw new Error(CONFIG_VALIDATION); + } + if (res.warnings.length) { + logger.warn( + { warnings: res.warnings }, + 'Found warnings in inherited configuration.', + ); + } + const filteredConfig = removeGlobalConfig(inheritedConfig, true); + if (!dequal(inheritedConfig, filteredConfig)) { + logger.debug( + { inheritedConfig, filteredConfig }, + 'Removed global config from inherited config.', + ); + } + return mergeChildConfig(config, filteredConfig); +}