renovate/lib/config/presets/index.ts

452 lines
13 KiB
TypeScript

import is from '@sindresorhus/is';
import {
CONFIG_VALIDATION,
PLATFORM_RATE_LIMIT_EXCEEDED,
} from '../../constants/error-messages';
import { logger } from '../../logger';
import { ExternalHostError } from '../../types/errors/external-host-error';
import * as memCache from '../../util/cache/memory';
import * as packageCache from '../../util/cache/package';
import { getTtlOverride } from '../../util/cache/package/decorator';
import { clone } from '../../util/clone';
import { regEx } from '../../util/regex';
import * as template from '../../util/template';
import { GlobalConfig } from '../global';
import * as massage from '../massage';
import * as migration from '../migration';
import type { AllConfig, RenovateConfig } from '../types';
import { mergeChildConfig } from '../utils';
import { removedPresets } from './common';
import * as gitea from './gitea';
import * as github from './github';
import * as gitlab from './gitlab';
import * as http from './http';
import * as internal from './internal';
import * as local from './local';
import * as npm from './npm';
import type { ParsedPreset, Preset, PresetApi } from './types';
import {
PRESET_DEP_NOT_FOUND,
PRESET_INVALID,
PRESET_INVALID_JSON,
PRESET_NOT_FOUND,
PRESET_PROHIBITED_SUBPRESET,
PRESET_RENOVATE_CONFIG_NOT_FOUND,
} from './util';
const presetSources: Record<string, PresetApi> = {
github,
npm,
gitlab,
gitea,
local,
internal,
http,
};
const presetCacheNamespace = 'preset';
const nonScopedPresetWithSubdirRegex = regEx(
/^(?<repo>~?[\w\-. /]+?)\/\/(?:(?<presetPath>[\w\-./]+)\/)?(?<presetName>[\w\-.]+)(?:#(?<tag>[\w\-./]+?))?$/,
);
const gitPresetRegex = regEx(
/^(?<repo>~?[\w\-. /]+)(?::(?<presetName>[\w\-.+/]+))?(?:#(?<tag>[\w\-./]+?))?$/,
);
export function replaceArgs(
obj: string,
argMapping: Record<string, any>,
): string;
export function replaceArgs(
obj: string[],
argMapping: Record<string, any>,
): string[];
export function replaceArgs(
obj: Record<string, any>,
argMapping: Record<string, any>,
): Record<string, any>;
export function replaceArgs(
obj: Record<string, any>[],
argMapping: Record<string, any>,
): Record<string, any>[];
/**
* TODO: fix me #22198
* @param obj
* @param argMapping
*/
export function replaceArgs(obj: any, argMapping: Record<string, any>): any;
export function replaceArgs(
obj: string | string[] | Record<string, any> | Record<string, any>[],
argMapping: Record<string, any>,
): any {
if (is.string(obj)) {
let returnStr = obj;
for (const [arg, argVal] of Object.entries(argMapping)) {
const re = regEx(`{{${arg}}}`, 'g', false);
returnStr = returnStr.replace(re, argVal);
}
return returnStr;
}
if (is.array(obj)) {
const returnArray = [];
for (const item of obj) {
returnArray.push(replaceArgs(item, argMapping));
}
return returnArray;
}
if (is.object(obj)) {
const returnObj: Record<string, any> = {};
for (const [key, val] of Object.entries(obj)) {
returnObj[key] = replaceArgs(val, argMapping);
}
return returnObj;
}
return obj;
}
export function parsePreset(input: string): ParsedPreset {
let str = input;
let presetSource: string | undefined;
let presetPath: string | undefined;
let repo: string;
let presetName: string;
let tag: string | undefined;
let params: string[] | undefined;
if (str.startsWith('github>')) {
presetSource = 'github';
str = str.substring('github>'.length);
} else if (str.startsWith('gitlab>')) {
presetSource = 'gitlab';
str = str.substring('gitlab>'.length);
} else if (str.startsWith('gitea>')) {
presetSource = 'gitea';
str = str.substring('gitea>'.length);
} else if (str.startsWith('local>')) {
presetSource = 'local';
str = str.substring('local>'.length);
} else if (str.startsWith('http://') || str.startsWith('https://')) {
presetSource = 'http';
} else if (
!str.startsWith('@') &&
!str.startsWith(':') &&
str.includes('/')
) {
presetSource = 'local';
}
str = str.replace(regEx(/^npm>/), '');
presetSource = presetSource ?? 'npm';
if (str.includes('(')) {
params = str
.slice(str.indexOf('(') + 1, -1)
.split(',')
.map((elem) => elem.trim());
str = str.slice(0, str.indexOf('('));
}
if (presetSource === 'http') {
return { presetSource, repo: str, presetName: '', params };
}
const presetsPackages = [
'compatibility',
'config',
'customManagers',
'default',
'docker',
'group',
'helpers',
'mergeConfidence',
'monorepo',
'npm',
'packages',
'preview',
'replacements',
'schedule',
'security',
'workarounds',
];
if (
presetsPackages.some((presetPackage) => str.startsWith(`${presetPackage}:`))
) {
presetSource = 'internal';
[repo, presetName] = str.split(':');
} else if (str.startsWith(':')) {
// default namespace
presetSource = 'internal';
repo = 'default';
presetName = str.slice(1);
} else if (str.startsWith('@')) {
// scoped namespace
[, repo] = regEx(/(@.*?)(:|$)/).exec(str)!;
str = str.slice(repo.length);
if (!repo.includes('/')) {
repo += '/renovate-config';
}
if (str === '') {
presetName = 'default';
} else {
presetName = str.slice(1);
}
} else if (str.includes('//')) {
// non-scoped namespace with a subdirectory preset
// Validation
if (str.includes(':')) {
throw new Error(PRESET_PROHIBITED_SUBPRESET);
}
if (!nonScopedPresetWithSubdirRegex.test(str)) {
throw new Error(PRESET_INVALID);
}
({ repo, presetPath, presetName, tag } =
nonScopedPresetWithSubdirRegex.exec(str)!.groups!);
} else {
({ repo, presetName, tag } = gitPresetRegex.exec(str)!.groups!);
if (presetSource === 'npm' && !repo.startsWith('renovate-config-')) {
repo = `renovate-config-${repo}`;
}
if (!is.nonEmptyString(presetName)) {
presetName = 'default';
}
}
return {
presetSource,
presetPath,
repo,
presetName,
tag,
params,
};
}
export async function getPreset(
preset: string,
baseConfig?: RenovateConfig,
): Promise<RenovateConfig> {
logger.trace(`getPreset(${preset})`);
// Check if the preset has been removed or replaced
const newPreset = removedPresets[preset];
if (newPreset) {
return getPreset(newPreset, baseConfig);
}
if (newPreset === null) {
return {};
}
const { presetSource, repo, presetPath, presetName, tag, params } =
parsePreset(preset);
const cacheKey = `preset:${preset}`;
const presetCachePersistence = GlobalConfig.get(
'presetCachePersistence',
false,
);
let presetConfig: Preset | null | undefined;
if (presetCachePersistence) {
presetConfig = await packageCache.get(presetCacheNamespace, cacheKey);
} else {
presetConfig = memCache.get(cacheKey);
}
if (is.nullOrUndefined(presetConfig)) {
presetConfig = await presetSources[presetSource].getPreset({
repo,
presetPath,
presetName,
tag,
});
if (presetCachePersistence) {
await packageCache.set(
presetCacheNamespace,
cacheKey,
presetConfig,
getTtlOverride(presetCacheNamespace) ?? 15,
);
} else {
memCache.set(cacheKey, presetConfig);
}
}
if (!presetConfig) {
throw new Error(PRESET_DEP_NOT_FOUND);
}
logger.trace({ presetConfig }, `Found preset ${preset}`);
if (params) {
const argMapping: Record<string, string> = {};
for (const [index, value] of params.entries()) {
argMapping[`arg${index}`] = value;
}
presetConfig = replaceArgs(presetConfig, argMapping);
}
logger.trace({ presetConfig }, `Applied params to preset ${preset}`);
const presetKeys = Object.keys(presetConfig);
// istanbul ignore if
if (
presetKeys.length === 2 &&
presetKeys.includes('description') &&
presetKeys.includes('extends')
) {
// preset is just a collection of other presets
delete presetConfig.description;
}
const packageListKeys = ['description', 'matchPackageNames'];
if (presetKeys.every((key) => packageListKeys.includes(key))) {
delete presetConfig.description;
}
const { migratedConfig } = migration.migrateConfig(presetConfig);
return massage.massageConfig(migratedConfig);
}
export async function resolveConfigPresets(
inputConfig: AllConfig,
baseConfig?: RenovateConfig,
_ignorePresets?: string[],
existingPresets: string[] = [],
): Promise<AllConfig> {
let ignorePresets = clone(_ignorePresets);
if (!ignorePresets || ignorePresets.length === 0) {
ignorePresets = inputConfig.ignorePresets ?? [];
}
logger.trace(
{ config: inputConfig, existingPresets },
'resolveConfigPresets',
);
let config: AllConfig = {};
// First, merge all the preset configs from left to right
if (inputConfig.extends?.length) {
// Compile templates
inputConfig.extends = inputConfig.extends.map((tmpl) =>
template.compile(tmpl, {}),
);
for (const preset of inputConfig.extends) {
if (shouldResolvePreset(preset, existingPresets, ignorePresets)) {
logger.trace(`Resolving preset "${preset}"`);
const fetchedPreset = await fetchPreset(
preset,
baseConfig,
inputConfig,
existingPresets,
);
const presetConfig = await resolveConfigPresets(
fetchedPreset,
baseConfig ?? inputConfig,
ignorePresets,
existingPresets.concat([preset]),
);
// istanbul ignore if
if (inputConfig?.ignoreDeps?.length === 0) {
delete presetConfig.description;
}
config = mergeChildConfig(config, presetConfig);
}
}
}
logger.trace({ config }, `Post-preset resolve config`);
// Now assign "regular" config on top
config = mergeChildConfig(config, inputConfig);
delete config.extends;
delete config.ignorePresets;
logger.trace({ config }, `Post-merge resolve config`);
for (const [key, val] of Object.entries(config)) {
const ignoredKeys = ['content', 'onboardingConfig'];
if (is.array(val)) {
// Resolve nested objects inside arrays
config[key] = [];
for (const element of val) {
if (is.object(element)) {
(config[key] as RenovateConfig[]).push(
await resolveConfigPresets(
element as RenovateConfig,
baseConfig,
ignorePresets,
existingPresets,
),
);
} else {
(config[key] as unknown[]).push(element);
}
}
} else if (is.object(val) && !ignoredKeys.includes(key)) {
// Resolve nested objects
logger.trace(`Resolving object "${key}"`);
config[key] = await resolveConfigPresets(
val as RenovateConfig,
baseConfig,
ignorePresets,
existingPresets,
);
}
}
logger.trace({ config: inputConfig }, 'Input config');
logger.trace({ config }, 'Resolved config');
return config;
}
async function fetchPreset(
preset: string,
baseConfig: RenovateConfig | undefined,
inputConfig: AllConfig,
existingPresets: string[],
): Promise<AllConfig> {
try {
return await getPreset(preset, baseConfig ?? inputConfig);
} catch (err) {
logger.debug({ preset, err }, 'Preset fetch error');
// istanbul ignore if
if (err instanceof ExternalHostError) {
throw err;
}
// istanbul ignore if
if (err.message === PLATFORM_RATE_LIMIT_EXCEEDED) {
throw err;
}
const error = new Error(CONFIG_VALIDATION);
if (err.message === PRESET_DEP_NOT_FOUND) {
error.validationError = `Cannot find preset's package (${preset})`;
} else if (err.message === PRESET_RENOVATE_CONFIG_NOT_FOUND) {
error.validationError = `Preset package is missing a renovate-config entry (${preset})`;
} else if (err.message === PRESET_NOT_FOUND) {
error.validationError = `Preset name not found within published preset config (${preset})`;
} else if (err.message === PRESET_INVALID) {
error.validationError = `Preset is invalid (${preset})`;
} else if (err.message === PRESET_PROHIBITED_SUBPRESET) {
error.validationError = `Sub-presets cannot be combined with a custom path (${preset})`;
} else if (err.message === PRESET_INVALID_JSON) {
error.validationError = `Preset is invalid JSON (${preset})`;
} else {
error.validationError = `Preset caused unexpected error (${preset})`;
}
// istanbul ignore if
if (existingPresets.length) {
error.validationError +=
'. Note: this is a *nested* preset so please contact the preset author if you are unable to fix it yourself.';
}
logger.info(
{ validationError: error.validationError },
'Throwing preset error',
);
throw error;
}
}
function shouldResolvePreset(
preset: string,
existingPresets: string[],
ignorePresets: string[],
): boolean {
// istanbul ignore if
if (existingPresets.includes(preset)) {
logger.debug(
`Already seen preset ${preset} in [${existingPresets.join(', ')}]`,
);
return false;
}
if (ignorePresets.includes(preset)) {
// istanbul ignore next
logger.debug(
`Ignoring preset ${preset} in [${existingPresets.join(', ')}]`,
);
return false;
}
return true;
}