diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 4401d88a52..5449af59ea 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -934,6 +934,12 @@ Example: } ``` +### matchHost + +This can be a base URL (e.g. `https://api.github.com`) or a hostname like `github.com` or `api.github.com`. +If the value starts with `http(s)` then it will only match against URLs which start with the full base URL. +Otherwise, it will be matched by checking if the URL's hostname matches the `matchHost` directly or ends with it. + ### timeout Use this figure to adjust the timeout for queries. diff --git a/lib/config/definitions.ts b/lib/config/definitions.ts index 1b2fc1a4e5..b34b6a9921 100644 --- a/lib/config/definitions.ts +++ b/lib/config/definitions.ts @@ -1728,6 +1728,15 @@ const options: RenovateOptions[] = [ cli: false, env: false, }, + { + name: 'matchHost', + description: 'A host name or base URL to match against', + type: 'string', + stage: 'repository', + parent: 'hostRules', + cli: false, + env: false, + }, { name: 'timeout', description: 'Timeout (in milliseconds) for queries to external endpoints.', diff --git a/lib/manager/npm/post-update/index.ts b/lib/manager/npm/post-update/index.ts index 8bbb33eca6..a9e9f70b36 100644 --- a/lib/manager/npm/post-update/index.ts +++ b/lib/manager/npm/post-update/index.ts @@ -18,6 +18,7 @@ import { } from '../../../util/fs'; import { branchExists, getFile, getRepoStatus } from '../../../util/git'; import * as hostRules from '../../../util/host-rules'; +import { validateUrl } from '../../../util/url'; import type { PackageFile, PostUpdateConfig, Upgrade } from '../../types'; import * as lerna from './lerna'; import * as npm from './npm'; @@ -444,9 +445,8 @@ export async function getAdditionalFiles( }); for (const hostRule of npmHostRules) { if (hostRule.resolvedHost) { - const uri = hostRule.baseUrl - ? hostRule.baseUrl.replace(/^https?:/, '') - : `//${hostRule.resolvedHost}/`; + let uri = hostRule.baseUrl || hostRule.matchHost || hostRule.resolvedHost; + uri = validateUrl(uri) ? uri.replace(/^https?:/, '') : `//${uri}/`; if (hostRule.token) { const key = hostRule.authType === 'Basic' ? '_auth' : '_authToken'; additionalNpmrcContent.push(`${uri}:${key}=${hostRule.token}`); diff --git a/lib/types/host-rules.ts b/lib/types/host-rules.ts index ca71a4c98f..39ad48b56f 100644 --- a/lib/types/host-rules.ts +++ b/lib/types/host-rules.ts @@ -4,6 +4,7 @@ export interface HostRule { domainName?: string; hostName?: string; baseUrl?: string; + matchHost?: string; token?: string; username?: string; password?: string; diff --git a/lib/util/__snapshots__/host-rules.spec.ts.snap b/lib/util/__snapshots__/host-rules.spec.ts.snap index 2eb17cea81..8bf8dfa34b 100644 --- a/lib/util/__snapshots__/host-rules.spec.ts.snap +++ b/lib/util/__snapshots__/host-rules.spec.ts.snap @@ -31,5 +31,7 @@ exports[`util/host-rules find() returns hosts 1`] = ` Array [ "nuget.local", "my.local.registry", + "another.local.registry", + "yet.another.local.registry", ] `; diff --git a/lib/util/host-rules.spec.ts b/lib/util/host-rules.spec.ts index fbd70ce22c..84b5ef8fd1 100644 --- a/lib/util/host-rules.spec.ts +++ b/lib/util/host-rules.spec.ts @@ -106,6 +106,29 @@ describe(getName(), () => { find({ hostType: datasourceNuget.id, url: 'https://nuget.local/api' }) ).toMatchSnapshot(); }); + it('matches on matchHost with protocol', () => { + add({ + matchHost: 'https://domain.com', + token: 'def', + }); + expect(find({ url: 'https://api.domain.com' }).token).toBeUndefined(); + expect(find({ url: 'https://domain.com' }).token).toEqual('def'); + expect( + find({ + hostType: datasourceNuget.id, + url: 'https://domain.com/renovatebot', + }).token + ).toEqual('def'); + }); + it('matches on matchHost without protocol', () => { + add({ + matchHost: 'domain.com', + token: 'def', + }); + expect(find({ url: 'https://api.domain.com' }).token).toEqual('def'); + expect(find({ url: 'https://domain.com' }).token).toEqual('def'); + expect(find({ url: 'httpsdomain.com' }).token).toBeUndefined(); + }); it('matches on hostType and endpoint', () => { add({ hostType: datasourceNuget.id, @@ -145,11 +168,21 @@ describe(getName(), () => { hostName: 'my.local.registry', token: 'def', }); + add({ + hostType: datasourceNuget.id, + matchHost: 'another.local.registry', + token: 'xyz', + }); + add({ + hostType: datasourceNuget.id, + matchHost: 'https://yet.another.local.registry', + token: '123', + }); const res = hosts({ hostType: datasourceNuget.id, }); expect(res).toMatchSnapshot(); - expect(res).toHaveLength(2); + expect(res).toHaveLength(4); }); }); describe('findAll()', () => { diff --git a/lib/util/host-rules.ts b/lib/util/host-rules.ts index a0df94b902..51ef0fb44e 100644 --- a/lib/util/host-rules.ts +++ b/lib/util/host-rules.ts @@ -4,10 +4,11 @@ import { logger } from '../logger'; import { HostRule } from '../types'; import { clone } from './clone'; import * as sanitize from './sanitize'; +import { parseUrl, validateUrl } from './url'; let hostRules: HostRule[] = []; -const matchFields = ['hostName', 'domainName', 'baseUrl']; +const matchFields = ['matchHost', 'hostName', 'domainName', 'baseUrl']; export function add(params: HostRule): void { const matchedFields = matchFields.filter((field) => params[field]); @@ -19,7 +20,8 @@ export function add(params: HostRule): void { ); } const confidentialFields = ['password', 'token']; - let resolvedHost = params.baseUrl || params.hostName || params.domainName; + let resolvedHost = + params.baseUrl || params.hostName || params.domainName || params.matchHost; if (resolvedHost) { resolvedHost = URL.parse(resolvedHost).hostname || resolvedHost; confidentialFields.forEach((field) => { @@ -74,6 +76,10 @@ function isBaseUrlRule(rule: HostRule): boolean { return !rule.hostType && !!rule.baseUrl; } +function isHostOnlyRule(rule: HostRule): boolean { + return !rule.hostType && !!rule.matchHost; +} + function isMultiRule(rule: HostRule): boolean { return rule.hostType && !!rule.resolvedHost; } @@ -104,6 +110,21 @@ function matchesBaseUrl(rule: HostRule, search: HostRuleSearch): boolean { return search.url && rule.baseUrl && search.url.startsWith(rule.baseUrl); } +function matchesHost(rule: HostRule, search: HostRuleSearch): boolean { + if (!rule.matchHost) { + return false; + } + if (validateUrl(rule.matchHost)) { + return search.url.startsWith(rule.matchHost); + } + const parsedUrl = parseUrl(search.url); + if (!parsedUrl?.hostname) { + return false; + } + const { hostname } = parsedUrl; + return hostname === rule.matchHost || hostname.endsWith(`.${rule.matchHost}`); +} + export function find(search: HostRuleSearch): HostRule { if (!(search.hostType || search.url)) { logger.warn({ search }, 'Invalid hostRules search'); @@ -140,6 +161,11 @@ export function find(search: HostRuleSearch): HostRule { .forEach((rule) => { res = merge(res, rule); }); + hostRules + .filter((rule) => isHostOnlyRule(rule) && matchesHost(rule, search)) + .forEach((rule) => { + res = merge(res, rule); + }); // Finally, find combination matches hostRules .filter( @@ -147,6 +173,7 @@ export function find(search: HostRuleSearch): HostRule { isMultiRule(rule) && matchesHostType(rule, search) && (matchesDomainName(rule, search) || + matchesHost(rule, search) || matchesHostName(rule, search) || matchesBaseUrl(rule, search)) ) @@ -158,6 +185,7 @@ export function find(search: HostRuleSearch): HostRule { delete res.hostName; delete res.baseUrl; delete res.resolvedHost; + delete res.matchHost; return res; }