From b25022066e68dec810cc17d8368df8188b397c72 Mon Sep 17 00:00:00 2001 From: Adam Setch <adam.setch@outlook.com> Date: Tue, 21 Mar 2023 12:42:13 -0400 Subject: [PATCH] feat(replacements): support for replacement name templating (#20905) Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com> --- docs/usage/configuration-options.md | 42 ++++++- lib/config/options/index.ts | 10 ++ .../repository/process/lookup/index.spec.ts | 103 ++++++++++++++++++ .../repository/process/lookup/index.ts | 39 ++----- .../repository/process/lookup/types.ts | 1 + .../repository/process/lookup/utils.ts | 72 ++++++++++++ 6 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 lib/workers/repository/process/lookup/utils.ts diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 82305b51ac..f7fdb90932 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -2176,9 +2176,49 @@ Managers which do not support replacement: - `regex` Use the `replacementName` config option to set the name of a replacement package. -Must be used with `replacementVersion` (see example below). + +Can be used in combination with `replacementVersion`. + You can suggest a new community package rule by editing [the `replacements.ts` file on the Renovate repository](https://github.com/renovatebot/renovate/blob/main/lib/config/presets/internal/replacements.ts) and opening a pull request. +### replacementNameTemplate + +<!-- prettier-ignore --> +!!! note + `replacementName` will take precedence if used within the same package rule. + +Use the `replacementNameTemplate` config option to control the replacement name. + +Use the triple brace `{{{ }}}` notation to avoid Handlebars escaping any special characters. + +For example, the following package rule can be used to replace the registry for `docker` images: + +```json +{ + "packageRules": [ + { + "matchDatasources": ["docker"], + "matchPackagePrefix": ["^docker.io/.*)"], + "replacementNameTemplate": "{{{replace 'docker.io/' 'ghcr.io/' packageName}}}" + } + ] +} +``` + +Or, to add a registry prefix to any `docker` images that do not contain an explicit registry: + +```json +{ + "packageRules": [ + { + "matchDatasources": ["docker"], + "matchPackagePrefix": ["^([^.]+)(\\/\\:)?$"], + "replacementNameTemplate": "some.registry.org/{{{packageName}}}" + } + ] +} +``` + ### replacementVersion This config option only works with some managers. diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index ec44d89375..428b8dc177 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1214,6 +1214,16 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'replacementNameTemplate', + description: 'Controls what the replacement package name.', + type: 'string', + default: '{{{packageName}}}', + stage: 'package', + parent: 'packageRules', + cli: false, + env: false, + }, { name: 'replacementVersion', description: diff --git a/lib/workers/repository/process/lookup/index.spec.ts b/lib/workers/repository/process/lookup/index.spec.ts index 40f8e62f99..5cea45a80f 100644 --- a/lib/workers/repository/process/lookup/index.spec.ts +++ b/lib/workers/repository/process/lookup/index.spec.ts @@ -1941,6 +1941,14 @@ describe('workers/repository/process/lookup/index', () => { ]); }); + it('handles replacements - skips if package and replacement names match', async () => { + config.packageName = 'openjdk'; + config.currentValue = undefined; + config.datasource = DockerDatasource.id; + config.replacementName = 'openjdk'; + expect((await lookup.lookupUpdates(config)).updates).toMatchObject([]); + }); + it('handles replacements - name and version', async () => { config.currentValue = '1.4.1'; config.packageName = 'q'; @@ -1958,6 +1966,101 @@ describe('workers/repository/process/lookup/index', () => { ]); }); + it('handles replacements - can template replacement name without a replacement version', async () => { + config.packageName = 'mirror.some.org/library/openjdk'; + config.currentValue = '17.0.0'; + config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`; + config.datasource = DockerDatasource.id; + getDockerReleases.mockResolvedValueOnce({ + releases: [ + { + version: '17.0.0', + }, + { + version: '18.0.0', + }, + ], + }); + + expect((await lookup.lookupUpdates(config)).updates).toMatchObject([ + { + updateType: 'replacement', + newName: 'new.registry.io/library/openjdk', + newValue: '17.0.0', + }, + { + updateType: 'major', + newMajor: 18, + newValue: '18.0.0', + newVersion: '18.0.0', + }, + ]); + }); + + it('handles replacements - can template replacement name with a replacement version', async () => { + config.packageName = 'mirror.some.org/library/openjdk'; + config.currentValue = '17.0.0'; + config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`; + config.replacementVersion = '18.0.0'; + config.datasource = DockerDatasource.id; + getDockerReleases.mockResolvedValueOnce({ + releases: [ + { + version: '17.0.0', + }, + { + version: '18.0.0', + }, + ], + }); + + expect((await lookup.lookupUpdates(config)).updates).toMatchObject([ + { + updateType: 'replacement', + newName: 'new.registry.io/library/openjdk', + newValue: '18.0.0', + }, + { + updateType: 'major', + newMajor: 18, + newValue: '18.0.0', + newVersion: '18.0.0', + }, + ]); + }); + + it('handles replacements - replacementName takes precedence over replacementNameTemplate', async () => { + config.packageName = 'mirror.some.org/library/openjdk'; + config.currentValue = '17.0.0'; + config.replacementNameTemplate = `{{{replace 'mirror.some.org/' 'new.registry.io/' packageName}}}`; + config.replacementName = 'eclipse-temurin'; + config.datasource = DockerDatasource.id; + getDockerReleases.mockResolvedValueOnce({ + releases: [ + { + version: '17.0.0', + }, + { + version: '18.0.0', + }, + ], + }); + + expect((await lookup.lookupUpdates(config)).updates).toMatchObject([ + { + updateType: 'replacement', + newName: 'eclipse-temurin', + newValue: '17.0.0', + }, + { + updateType: 'major', + newMajor: 18, + newValue: '18.0.0', + newVersion: '18.0.0', + }, + ]); + }); + it('rollback for invalid version to last stable version', async () => { config.currentValue = '2.5.17'; config.packageName = 'vue'; diff --git a/lib/workers/repository/process/lookup/index.ts b/lib/workers/repository/process/lookup/index.ts index a262baf992..70c1e0595a 100644 --- a/lib/workers/repository/process/lookup/index.ts +++ b/lib/workers/repository/process/lookup/index.ts @@ -26,6 +26,11 @@ import { filterInternalChecks } from './filter-checks'; import { generateUpdate } from './generate'; import { getRollbackUpdate } from './rollback'; import type { LookupUpdateConfig, UpdateResult } from './types'; +import { + addReplacementUpdateIfValid, + isReplacementNameRulesConfigured, + isReplacementRulesConfigured, +} from './utils'; export async function lookupUpdates( inconfig: LookupUpdateConfig @@ -157,27 +162,10 @@ export async function lookupUpdates( } let rangeStrategy = getRangeStrategy(config); - if (config.replacementName && !config.replacementVersion) { - res.updates.push({ - updateType: 'replacement', - newName: config.replacementName, - newValue: currentValue!, - }); + if (isReplacementRulesConfigured(config)) { + addReplacementUpdateIfValid(res.updates, config); } - if (config.replacementName && config.replacementVersion) { - res.updates.push({ - updateType: 'replacement', - newName: config.replacementName, - newValue: versioning.getNewValue({ - // TODO #7154 - currentValue: currentValue!, - newVersion: config.replacementVersion, - rangeStrategy: rangeStrategy!, - isReplacement: true, - })!, - }); - } // istanbul ignore next if ( isVulnerabilityAlert && @@ -344,19 +332,12 @@ export async function lookupUpdates( } else { delete res.skipReason; } - } else if ( - !currentValue && - config.replacementName && - !config.replacementVersion - ) { + } else if (!currentValue && isReplacementNameRulesConfigured(config)) { logger.debug( `Handle name-only replacement for ${packageName} without current version` ); - res.updates.push({ - updateType: 'replacement', - newName: config.replacementName, - newValue: currentValue!, - }); + + addReplacementUpdateIfValid(res.updates, config); } else { res.skipReason = 'invalid-value'; } diff --git a/lib/workers/repository/process/lookup/types.ts b/lib/workers/repository/process/lookup/types.ts index 5a0e1a40bb..14f1187c62 100644 --- a/lib/workers/repository/process/lookup/types.ts +++ b/lib/workers/repository/process/lookup/types.ts @@ -46,6 +46,7 @@ export interface LookupUpdateConfig packageName: string; minimumConfidence?: MergeConfidence | undefined; replacementName?: string; + replacementNameTemplate?: string; replacementVersion?: string; } diff --git a/lib/workers/repository/process/lookup/utils.ts b/lib/workers/repository/process/lookup/utils.ts new file mode 100644 index 0000000000..4abf290368 --- /dev/null +++ b/lib/workers/repository/process/lookup/utils.ts @@ -0,0 +1,72 @@ +import is from '@sindresorhus/is'; + +import { getRangeStrategy } from '../../../../modules/manager'; +import type { LookupUpdate } from '../../../../modules/manager/types'; +import * as allVersioning from '../../../../modules/versioning'; +import * as template from '../../../../util/template'; +import type { LookupUpdateConfig } from './types'; + +export function addReplacementUpdateIfValid( + updates: LookupUpdate[], + config: LookupUpdateConfig +): void { + const replacementNewName = determineNewReplacementName(config); + const replacementNewValue = determineNewReplacementValue(config); + + if ( + config.packageName !== replacementNewName || + config.currentValue !== replacementNewValue + ) { + updates.push({ + updateType: 'replacement', + newName: replacementNewName, + newValue: replacementNewValue!, + }); + } +} + +export function isReplacementNameRulesConfigured( + config: LookupUpdateConfig +): boolean { + return ( + is.nonEmptyString(config.replacementName) || + is.nonEmptyString(config.replacementNameTemplate) + ); +} + +export function isReplacementRulesConfigured( + config: LookupUpdateConfig +): boolean { + return ( + isReplacementNameRulesConfigured(config) || + is.nonEmptyString(config.replacementVersion) + ); +} + +export function determineNewReplacementName( + config: LookupUpdateConfig +): string { + return ( + config.replacementName ?? + template.compile(config.replacementNameTemplate!, config, true) + ); +} + +export function determineNewReplacementValue( + config: LookupUpdateConfig +): string | undefined | null { + const versioning = allVersioning.get(config.versioning); + const rangeStrategy = getRangeStrategy(config); + + if (!is.nullOrUndefined(config.replacementVersion)) { + return versioning.getNewValue({ + // TODO #7154 + currentValue: config.currentValue!, + newVersion: config.replacementVersion, + rangeStrategy: rangeStrategy!, + isReplacement: true, + }); + } + + return config.currentValue; +} -- GitLab