diff --git a/docs/usage/self-hosted-experimental.md b/docs/usage/self-hosted-experimental.md index 62b5e65a6cb6cc847a80ccf287ff53b499952ba9..563757351086faf38622cb7cca3d532ad40fe720 100644 --- a/docs/usage/self-hosted-experimental.md +++ b/docs/usage/self-hosted-experimental.md @@ -76,6 +76,11 @@ When using `matchPackageNames` and `matchPackagePatterns` matchers: 1. Renovate first tries to match against `depName` 2. If `depName` doesn't match then Renovate tries to match against `packageName` +## `RENOVATE_X_MERGE_CONFIDENCE_API_BASE_URL` + +If set, Renovate will query this API for Merge Confidence data. +This feature is in private beta. + ## `RENOVATE_X_AUTODISCOVER_REPO_SORT` <!-- prettier-ignore --> diff --git a/lib/util/merge-confidence/index.spec.ts b/lib/util/merge-confidence/index.spec.ts index dc46fd6c2df911cfd0841b24635d604bfaa1455d..b64be0b932373b596f2ec8df55faa88c009d528b 100644 --- a/lib/util/merge-confidence/index.spec.ts +++ b/lib/util/merge-confidence/index.spec.ts @@ -1,14 +1,22 @@ import * as httpMock from '../../../test/http-mock'; +import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages'; +import { logger } from '../../logger'; +import type { HostRule } from '../../types'; import * as memCache from '../cache/memory'; import * as hostRules from '../host-rules'; -import type { MergeConfidence } from './types'; import { getMergeConfidenceLevel, + initConfig, + initMergeConfidence, isActiveConfidenceLevel, + resetConfig, satisfiesConfidenceLevel, } from '.'; describe('util/merge-confidence/index', () => { + const apiBaseUrl = 'https://www.baseurl.com/'; + const defaultApiBaseUrl = 'https://badges.renovateapi.com/'; + describe('isActiveConfidenceLevel()', () => { it('returns false if null', () => { expect(isActiveConfidenceLevel(null as never)).toBeFalse(); @@ -19,9 +27,7 @@ describe('util/merge-confidence/index', () => { }); it('returns false if nonsense', () => { - expect( - isActiveConfidenceLevel('nonsense' as MergeConfidence) - ).toBeFalse(); + expect(isActiveConfidenceLevel('nonsense')).toBeFalse(); }); it('returns true if valid value (high)', () => { @@ -43,157 +49,323 @@ describe('util/merge-confidence/index', () => { }); }); - describe('getMergeConfidenceLevel()', () => { + describe('API calling functions', () => { + const hostRule: HostRule = { + hostType: 'merge-confidence', + token: 'some-token', + }; + beforeEach(() => { - hostRules.clear(); + jest.resetAllMocks(); + process.env.RENOVATE_X_MERGE_CONFIDENCE_API_BASE_URL = apiBaseUrl; + hostRules.add(hostRule); + initConfig(); memCache.reset(); }); - it('returns neutral if undefined updateType', async () => { - expect( - await getMergeConfidenceLevel( - 'npm', - 'renovate', - '25.0.0', - '25.0.0', - undefined as never - ) - ).toBe('neutral'); + afterEach(() => { + hostRules.clear(); + resetConfig(); }); - it('returns neutral if irrelevant updateType', async () => { - expect( - await getMergeConfidenceLevel( - 'npm', - 'renovate', - '24.1.0', - '25.0.0', - 'bump' - ) - ).toBe('neutral'); - }); + describe('getMergeConfidenceLevel()', () => { + it('returns neutral if undefined updateType', async () => { + expect( + await getMergeConfidenceLevel( + 'npm', + 'renovate', + '25.0.0', + '25.0.0', + undefined as never + ) + ).toBe('neutral'); + }); - it('returns high if pinning', async () => { - expect( - await getMergeConfidenceLevel( - 'npm', - 'renovate', - '25.0.1', - '25.0.1', - 'pin' - ) - ).toBe('high'); - }); + it('returns neutral if irrelevant updateType', async () => { + expect( + await getMergeConfidenceLevel( + 'npm', + 'renovate', + '24.1.0', + '25.0.0', + 'bump' + ) + ).toBe('neutral'); + }); - it('returns undefined if no token', async () => { - expect( - await getMergeConfidenceLevel( - 'npm', - 'renovate', - '24.2.0', - '25.0.0', - 'major' - ) - ).toBeUndefined(); - }); + it('returns high if pinning', async () => { + expect( + await getMergeConfidenceLevel( + 'npm', + 'renovate', + '25.0.1', + '25.0.1', + 'pin' + ) + ).toBe('high'); + }); - it('returns valid confidence level', async () => { - hostRules.add({ hostType: 'merge-confidence', token: '123test' }); - const datasource = 'npm'; - const depName = 'renovate'; - const currentVersion = '24.3.0'; - const newVersion = '25.0.0'; - httpMock - .scope('https://badges.renovateapi.com') - .get( - `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}` - ) - .reply(200, { confidence: 'high' }); - expect( - await getMergeConfidenceLevel( - datasource, - depName, - currentVersion, - newVersion, - 'major' - ) - ).toBe('high'); - }); + it('returns undefined if no token', async () => { + resetConfig(); + hostRules.clear(); - it('returns undefined if invalid confidence level', async () => { - hostRules.add({ hostType: 'merge-confidence', token: '123test' }); - const datasource = 'npm'; - const depName = 'renovate'; - const currentVersion = '25.0.0'; - const newVersion = '25.1.0'; - httpMock - .scope('https://badges.renovateapi.com') - .get( - `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}` - ) - .reply(200, { nope: 'nope' }); - expect( - await getMergeConfidenceLevel( - datasource, - depName, - currentVersion, - newVersion, - 'minor' - ) - ).toBeUndefined(); - }); + expect( + await getMergeConfidenceLevel( + 'npm', + 'renovate', + '24.2.0', + '25.0.0', + 'major' + ) + ).toBeUndefined(); + }); + + it('returns undefined if datasource is unsupported', async () => { + expect( + await getMergeConfidenceLevel( + 'not-npm', + 'renovate', + '24.2.0', + '25.0.0', + 'major' + ) + ).toBeUndefined(); + }); + + it('returns valid confidence level', async () => { + const datasource = 'npm'; + const depName = 'renovate'; + const currentVersion = '24.3.0'; + const newVersion = '25.0.0'; + httpMock + .scope(apiBaseUrl) + .get( + `/api/mc/json/${datasource}/${depName}/${currentVersion}/${newVersion}` + ) + .reply(200, { confidence: 'high' }); + + expect( + await getMergeConfidenceLevel( + datasource, + depName, + currentVersion, + newVersion, + 'major' + ) + ).toBe('high'); + }); + + it('returns neutral on invalid merge confidence response from api', async () => { + const datasource = 'npm'; + const depName = 'renovate'; + const currentVersion = '25.0.0'; + const newVersion = '25.1.0'; + httpMock + .scope(apiBaseUrl) + .get( + `/api/mc/json/${datasource}/${depName}/${currentVersion}/${newVersion}` + ) + .reply(200, { invalid: 'invalid' }); + + expect( + await getMergeConfidenceLevel( + datasource, + depName, + currentVersion, + newVersion, + 'minor' + ) + ).toBe('neutral'); + }); + + it('returns neutral on non 403/5xx error from API', async () => { + const datasource = 'npm'; + const depName = 'renovate'; + const currentVersion = '25.0.0'; + const newVersion = '25.4.0'; + httpMock + .scope(apiBaseUrl) + .get( + `/api/mc/json/${datasource}/${depName}/${currentVersion}/${newVersion}` + ) + .reply(400); + + expect( + await getMergeConfidenceLevel( + datasource, + depName, + currentVersion, + newVersion, + 'minor' + ) + ).toBe('neutral'); + expect(logger.warn).toHaveBeenCalledWith( + expect.anything(), + 'error fetching merge confidence data' + ); + }); + + it('throws on 403-Forbidden response from API', async () => { + const datasource = 'npm'; + const packageName = 'renovate'; + const currentVersion = '25.0.0'; + const newVersion = '25.4.0'; + httpMock + .scope(apiBaseUrl) + .get( + `/api/mc/json/${datasource}/${packageName}/${currentVersion}/${newVersion}` + ) + .reply(403); - it('returns undefined if exception from API', async () => { - hostRules.add({ hostType: 'merge-confidence', token: '123test' }); - const datasource = 'npm'; - const depName = 'renovate'; - const currentVersion = '25.0.0'; - const newVersion = '25.4.0'; - httpMock - .scope('https://badges.renovateapi.com') - .get( - `/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}` - ) - .reply(403); - expect( - await getMergeConfidenceLevel( - datasource, - depName, - currentVersion, - newVersion, - 'minor' - ) - ).toBeUndefined(); - - // FIXME: no cache hit - httpMock - .scope('https://badges.renovateapi.com') - .get( - `/packages/${datasource}/${depName}-new/${newVersion}/confidence.api/${currentVersion}` - ) - .reply(403); - // memory cache - expect( - await getMergeConfidenceLevel( - datasource, - depName + '-new', - currentVersion, - newVersion, - 'minor' - ) - ).toBeUndefined(); + await expect( + getMergeConfidenceLevel( + datasource, + packageName, + currentVersion, + newVersion, + 'minor' + ) + ).rejects.toThrow(EXTERNAL_HOST_ERROR); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + 'merge confidence API token rejected - aborting run' + ); + }); + + it('throws on server error responses', async () => { + const datasource = 'npm'; + const packageName = 'renovate'; + const currentVersion = '25.0.0'; + const newVersion = '25.4.0'; + httpMock + .scope(apiBaseUrl) + .get( + `/api/mc/json/${datasource}/${packageName}/${currentVersion}/${newVersion}` + ) + .reply(503); + + await expect( + getMergeConfidenceLevel( + datasource, + packageName, + currentVersion, + newVersion, + 'minor' + ) + ).rejects.toThrow(EXTERNAL_HOST_ERROR); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + 'merge confidence API failure: 5xx - aborting run' + ); + }); + + it('returns high if pinning digest', async () => { + expect( + await getMergeConfidenceLevel( + 'npm', + 'renovate', + '25.0.1', + '25.0.1', + 'pinDigest' + ) + ).toBe('high'); + }); }); - it('returns high if pinning digest', async () => { - expect( - await getMergeConfidenceLevel( - 'npm', - 'renovate', - '25.0.1', - '25.0.1', - 'pinDigest' - ) - ).toBe('high'); + describe('initMergeConfidence()', () => { + it('using default base url if none is set', async () => { + resetConfig(); + delete process.env.RENOVATE_X_MERGE_CONFIDENCE_API_BASE_URL; + httpMock + .scope(defaultApiBaseUrl) + .get(`/api/mc/availability`) + .reply(200); + + await expect(initMergeConfidence()).toResolve(); + expect(logger.trace).toHaveBeenCalledWith( + 'using default merge confidence API base URL' + ); + expect(logger.debug).toHaveBeenCalledWith( + 'merge confidence API - successfully authenticated' + ); + }); + + it('warns and then resolves if base url is invalid', async () => { + resetConfig(); + process.env.RENOVATE_X_MERGE_CONFIDENCE_API_BASE_URL = + 'invalid-url.com'; + httpMock + .scope(defaultApiBaseUrl) + .get(`/api/mc/availability`) + .reply(200); + + await expect(initMergeConfidence()).toResolve(); + expect(logger.warn).toHaveBeenCalledWith( + expect.anything(), + 'invalid merge confidence API base URL found in environment variables - using default value instead' + ); + expect(logger.debug).toHaveBeenCalledWith( + 'merge confidence API - successfully authenticated' + ); + }); + + it('resolves if no token', async () => { + resetConfig(); + hostRules.clear(); + + await expect(initMergeConfidence()).toResolve(); + expect(logger.trace).toHaveBeenCalledWith( + 'merge confidence API usage is disabled' + ); + }); + + it('resolves when token is valid', async () => { + httpMock.scope(apiBaseUrl).get(`/api/mc/availability`).reply(200); + + await expect(initMergeConfidence()).toResolve(); + expect(logger.debug).toHaveBeenCalledWith( + 'merge confidence API - successfully authenticated' + ); + }); + + it('throws on 403-Forbidden from mc API', async () => { + httpMock.scope(apiBaseUrl).get(`/api/mc/availability`).reply(403); + + await expect(initMergeConfidence()).rejects.toThrow( + EXTERNAL_HOST_ERROR + ); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + 'merge confidence API token rejected - aborting run' + ); + }); + + it('throws on 5xx host errors from mc API', async () => { + httpMock.scope(apiBaseUrl).get(`/api/mc/availability`).reply(503); + + await expect(initMergeConfidence()).rejects.toThrow( + EXTERNAL_HOST_ERROR + ); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + 'merge confidence API failure: 5xx - aborting run' + ); + }); + + it('throws on ECONNRESET', async () => { + httpMock + .scope(apiBaseUrl) + .get(`/api/mc/availability`) + .replyWithError({ code: 'ECONNRESET' }); + + await expect(initMergeConfidence()).rejects.toThrow( + EXTERNAL_HOST_ERROR + ); + expect(logger.error).toHaveBeenCalledWith( + expect.anything(), + 'merge confidence API request failed - aborting run' + ); + }); }); }); }); diff --git a/lib/util/merge-confidence/index.ts b/lib/util/merge-confidence/index.ts index 7d19a84bd7c37ad2bc90819022bfedb94908a6b7..a63d389c5312f842ece8474e3d3e6079739a203d 100644 --- a/lib/util/merge-confidence/index.ts +++ b/lib/util/merge-confidence/index.ts @@ -1,13 +1,19 @@ +import is from '@sindresorhus/is'; import type { UpdateType } from '../../config/types'; import { logger } from '../../logger'; -import * as memCache from '../cache/memory'; +import { ExternalHostError } from '../../types/errors/external-host-error'; import * as packageCache from '../cache/package'; import * as hostRules from '../host-rules'; import { Http } from '../http'; import { MERGE_CONFIDENCE } from './common'; import type { MergeConfidence } from './types'; -const http = new Http('merge-confidence'); +const hostType = 'merge-confidence'; +const http = new Http(hostType); +let token: string | undefined; +let apiBaseUrl: string | undefined; + +const supportedDatasources = ['npm', 'maven', 'pypi']; export const confidenceLevels: Record<MergeConfidence, number> = { low: -1, @@ -16,8 +22,22 @@ export const confidenceLevels: Record<MergeConfidence, number> = { 'very high': 2, }; -export function isActiveConfidenceLevel(confidence: MergeConfidence): boolean { - return confidence !== 'low' && MERGE_CONFIDENCE.includes(confidence); +export function initConfig(): void { + apiBaseUrl = getApiBaseUrl(); + token = getApiToken(); +} + +export function resetConfig(): void { + token = undefined; + apiBaseUrl = undefined; +} + +export function isMergeConfidence(value: string): value is MergeConfidence { + return MERGE_CONFIDENCE.includes(value as MergeConfidence); +} + +export function isActiveConfidenceLevel(confidence: string): boolean { + return isMergeConfidence(confidence) && confidence !== 'low'; } export function satisfiesConfidenceLevel( @@ -42,6 +62,18 @@ const updateTypeConfidenceMapping: Record<UpdateType, MergeConfidence | null> = patch: null, }; +/** + * Retrieves the merge confidence of a package update if the merge confidence API is enabled. Otherwise, undefined is returned. + * + * @param datasource + * @param depName + * @param currentVersion + * @param newVersion + * @param updateType + * + * @returns The merge confidence level for the given package release. + * @throws {ExternalHostError} If a request has been made and an error occurs during the request, such as a timeout, connection reset, authentication failure, or internal server error. + */ export async function getMergeConfidenceLevel( datasource: string, depName: string, @@ -49,44 +81,161 @@ export async function getMergeConfidenceLevel( newVersion: string, updateType: UpdateType ): Promise<MergeConfidence | undefined> { + if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) { + return undefined; + } + + if (!supportedDatasources.includes(datasource)) { + return undefined; + } + if (!(currentVersion && newVersion && updateType)) { return 'neutral'; } + const mappedConfidence = updateTypeConfidenceMapping[updateType]; if (mappedConfidence) { return mappedConfidence; } - const { token } = hostRules.find({ - hostType: 'merge-confidence', - url: 'https://badges.renovateapi.com', - }); - if (!token) { - logger.warn('No Merge Confidence API token found'); - return undefined; - } - // istanbul ignore if - if (memCache.get('merge-confidence-invalid-token')) { - return undefined; + + return await queryApi(datasource, depName, currentVersion, newVersion); +} + +/** + * Queries the Merge Confidence API with the given package release information. + * + * @param datasource + * @param depName + * @param currentVersion + * @param newVersion + * + * @returns The merge confidence level for the given package release. + * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request. + * + * @remarks + * Results are cached for 60 minutes to reduce the number of API calls. + */ +async function queryApi( + datasource: string, + depName: string, + currentVersion: string, + newVersion: string +): Promise<MergeConfidence> { + // istanbul ignore if: defensive, already been validated before calling this function + if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) { + return 'neutral'; } - const url = `https://badges.renovateapi.com/packages/${datasource}/${depName}/${newVersion}/confidence.api/${currentVersion}`; - const cachedResult = await packageCache.get('merge-confidence', token + url); + + const url = `${apiBaseUrl}api/mc/json/${datasource}/${depName}/${currentVersion}/${newVersion}`; + const cacheKey = `${token}:${url}`; + const cachedResult = await packageCache.get(hostType, cacheKey); + // istanbul ignore if if (cachedResult) { + logger.debug( + { datasource, depName, currentVersion, newVersion, cachedResult }, + 'using merge confidence cached result' + ); return cachedResult; } - let confidence: MergeConfidence | undefined; + + let confidence: MergeConfidence = 'neutral'; try { const res = (await http.getJson<{ confidence: MergeConfidence }>(url)).body; - if (MERGE_CONFIDENCE.includes(res.confidence)) { + if (isMergeConfidence(res.confidence)) { confidence = res.confidence; } } catch (err) { - logger.debug({ err }, 'Error fetching merge confidence'); - if (err.statusCode === 403) { - memCache.set('merge-confidence-invalid-token', true); - logger.warn('Merge Confidence API token rejected'); - } + apiErrorHandler(err); } - await packageCache.set('merge-confidence', token + url, confidence, 60); + + await packageCache.set(hostType, cacheKey, confidence, 60); return confidence; } + +/** + * Checks the health of the Merge Confidence API by attempting to authenticate with it. + * + * @returns Resolves when the API health check is completed successfully. + * + * @throws {ExternalHostError} if a timeout, connection reset error, authentication failure, or internal server error occurs during the request. + * + * @remarks + * This function first checks that the API base URL and an authentication bearer token are defined before attempting to + * authenticate with the API. If either the base URL or token is not defined, it will immediately return + * without making a request. + */ +export async function initMergeConfidence(): Promise<void> { + initConfig(); + + if (is.nullOrUndefined(apiBaseUrl) || is.nullOrUndefined(token)) { + logger.trace('merge confidence API usage is disabled'); + return; + } + + const url = `${apiBaseUrl}api/mc/availability`; + try { + await http.get(url); + } catch (err) { + apiErrorHandler(err); + } + + logger.debug('merge confidence API - successfully authenticated'); + return; +} + +function getApiBaseUrl(): string { + const defaultBaseUrl = 'https://badges.renovateapi.com/'; + const baseFromEnv = process.env.RENOVATE_X_MERGE_CONFIDENCE_API_BASE_URL; + + if (is.nullOrUndefined(baseFromEnv)) { + logger.trace('using default merge confidence API base URL'); + return defaultBaseUrl; + } + + try { + const parsedBaseUrl = new URL(baseFromEnv).toString(); + logger.trace( + { baseUrl: parsedBaseUrl }, + 'using merge confidence API base found in environment variables' + ); + return parsedBaseUrl; + } catch (err) { + logger.warn( + { err, baseFromEnv }, + 'invalid merge confidence API base URL found in environment variables - using default value instead' + ); + return defaultBaseUrl; + } +} + +function getApiToken(): string | undefined { + return hostRules.find({ + hostType, + })?.token; +} + +/** + * Handles errors returned by the Merge Confidence API. + * + * @param err - The error object returned by the API. + * @throws {ExternalHostError} if a timeout or connection reset error, authentication failure, or internal server error occurs during the request. + */ +function apiErrorHandler(err: any): void { + if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET') { + logger.error({ err }, 'merge confidence API request failed - aborting run'); + throw new ExternalHostError(err, hostType); + } + + if (err.statusCode === 403) { + logger.error({ err }, 'merge confidence API token rejected - aborting run'); + throw new ExternalHostError(err, hostType); + } + + if (err.statusCode >= 500 && err.statusCode < 600) { + logger.error({ err }, 'merge confidence API failure: 5xx - aborting run'); + throw new ExternalHostError(err, hostType); + } + + logger.warn({ err }, 'error fetching merge confidence data'); +} diff --git a/lib/workers/global/initialize.ts b/lib/workers/global/initialize.ts index 73a2d5eefb7229b64d5adc21715b0bafdff88311..3ec5c90ab6fdee5ea957d10efb5cff0feb6c538a 100644 --- a/lib/workers/global/initialize.ts +++ b/lib/workers/global/initialize.ts @@ -9,6 +9,7 @@ import * as packageCache from '../../util/cache/package'; import { setEmojiConfig } from '../../util/emoji'; import { validateGitVersion } from '../../util/git'; import * as hostRules from '../../util/host-rules'; +import { initMergeConfidence } from '../../util/merge-confidence'; import { setMaxLimit } from './limits'; async function setDirectories(input: AllConfig): Promise<AllConfig> { @@ -74,6 +75,7 @@ export async function globalInitialize( limitCommitsPerRun(config); setEmojiConfig(config); setGlobalHostRules(config); + await initMergeConfidence(); return config; }