mirror of
https://github.com/renovatebot/renovate.git
synced 2025-01-12 06:56:24 +00:00
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import is from '@sindresorhus/is';
|
|
import { dequal } from 'dequal';
|
|
import { HOST_DISABLED } 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 { clone } from '../util/clone';
|
|
import { regEx } from '../util/regex';
|
|
import { trimTrailingSlash } from '../util/url';
|
|
import * as allVersioning from '../versioning';
|
|
import datasources from './api';
|
|
import { addMetaData } from './metadata';
|
|
import type {
|
|
DatasourceApi,
|
|
DigestConfig,
|
|
GetPkgReleasesConfig,
|
|
GetReleasesConfig,
|
|
ReleaseResult,
|
|
} from './types';
|
|
|
|
export * from './types';
|
|
export { isGetPkgReleasesConfig } from './common';
|
|
|
|
export const getDatasources = (): Map<string, DatasourceApi> => datasources;
|
|
export const getDatasourceList = (): string[] => Array.from(datasources.keys());
|
|
|
|
const cacheNamespace = 'datasource-releases';
|
|
|
|
function getDatasourceFor(datasource: string): DatasourceApi {
|
|
return datasources.get(datasource);
|
|
}
|
|
|
|
type GetReleasesInternalConfig = GetReleasesConfig & GetPkgReleasesConfig;
|
|
|
|
// TODO: fix error Type
|
|
function logError(datasource: string, lookupName: string, err: any): void {
|
|
const { statusCode, code: errCode, url } = err;
|
|
if (statusCode === 404) {
|
|
logger.debug({ datasource, lookupName, url }, 'Datasource 404');
|
|
} else if (statusCode === 401 || statusCode === 403) {
|
|
logger.debug({ datasource, lookupName, url }, 'Datasource unauthorized');
|
|
} else if (errCode) {
|
|
logger.debug(
|
|
{ datasource, lookupName, url, errCode },
|
|
'Datasource connection error'
|
|
);
|
|
} else {
|
|
logger.debug({ datasource, lookupName, err }, 'Datasource unknown error');
|
|
}
|
|
}
|
|
|
|
async function getRegistryReleases(
|
|
datasource: DatasourceApi,
|
|
config: GetReleasesConfig,
|
|
registryUrl: string
|
|
): Promise<ReleaseResult> {
|
|
const cacheKey = `${datasource.id} ${registryUrl} ${config.lookupName}`;
|
|
if (datasource.caching) {
|
|
const cachedResult = await packageCache.get<ReleaseResult>(
|
|
cacheNamespace,
|
|
cacheKey
|
|
);
|
|
// istanbul ignore if
|
|
if (cachedResult) {
|
|
logger.trace({ cacheKey }, 'Returning cached datasource response');
|
|
return cachedResult;
|
|
}
|
|
}
|
|
const res = await datasource.getReleases({ ...config, registryUrl });
|
|
if (res?.releases.length) {
|
|
res.registryUrl ??= registryUrl;
|
|
}
|
|
// cache non-null responses unless marked as private
|
|
if (datasource.caching && res && !res.isPrivate) {
|
|
logger.trace({ cacheKey }, 'Caching datasource response');
|
|
const cacheMinutes = 15;
|
|
await packageCache.set(cacheNamespace, cacheKey, res, cacheMinutes);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
function firstRegistry(
|
|
config: GetReleasesInternalConfig,
|
|
datasource: DatasourceApi,
|
|
registryUrls: string[]
|
|
): Promise<ReleaseResult> {
|
|
if (registryUrls.length > 1) {
|
|
logger.warn(
|
|
{ datasource: datasource.id, depName: config.depName, registryUrls },
|
|
'Excess registryUrls found for datasource lookup - using first configured only'
|
|
);
|
|
}
|
|
const registryUrl = registryUrls[0];
|
|
return getRegistryReleases(datasource, config, registryUrl);
|
|
}
|
|
|
|
async function huntRegistries(
|
|
config: GetReleasesInternalConfig,
|
|
datasource: DatasourceApi,
|
|
registryUrls: string[]
|
|
): Promise<ReleaseResult> {
|
|
let res: ReleaseResult;
|
|
let caughtError: Error;
|
|
for (const registryUrl of registryUrls) {
|
|
try {
|
|
res = await getRegistryReleases(datasource, config, registryUrl);
|
|
if (res) {
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ExternalHostError) {
|
|
throw err;
|
|
}
|
|
// We'll always save the last-thrown error
|
|
caughtError = err;
|
|
logger.trace({ err }, 'datasource hunt failure');
|
|
}
|
|
}
|
|
if (res) {
|
|
return res;
|
|
}
|
|
if (caughtError) {
|
|
throw caughtError;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function mergeRegistries(
|
|
config: GetReleasesInternalConfig,
|
|
datasource: DatasourceApi,
|
|
registryUrls: string[]
|
|
): Promise<ReleaseResult> {
|
|
let combinedRes: ReleaseResult;
|
|
let caughtError: Error;
|
|
for (const registryUrl of registryUrls) {
|
|
try {
|
|
const res = await getRegistryReleases(datasource, config, registryUrl);
|
|
if (res) {
|
|
if (combinedRes) {
|
|
for (const existingRelease of combinedRes.releases || []) {
|
|
existingRelease.registryUrl = combinedRes.registryUrl;
|
|
}
|
|
for (const additionalRelease of res.releases || []) {
|
|
additionalRelease.registryUrl = res.registryUrl;
|
|
}
|
|
combinedRes = { ...res, ...combinedRes };
|
|
delete combinedRes.registryUrl;
|
|
combinedRes.releases = [...combinedRes.releases, ...res.releases];
|
|
} else {
|
|
combinedRes = res;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ExternalHostError) {
|
|
throw err;
|
|
}
|
|
// We'll always save the last-thrown error
|
|
caughtError = err;
|
|
logger.trace({ err }, 'datasource merge failure');
|
|
}
|
|
}
|
|
// De-duplicate releases
|
|
if (combinedRes?.releases?.length) {
|
|
const seenVersions = new Set<string>();
|
|
combinedRes.releases = combinedRes.releases.filter((release) => {
|
|
if (seenVersions.has(release.version)) {
|
|
return false;
|
|
}
|
|
seenVersions.add(release.version);
|
|
return true;
|
|
});
|
|
}
|
|
if (combinedRes) {
|
|
return combinedRes;
|
|
}
|
|
if (caughtError) {
|
|
throw caughtError;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveRegistryUrls(
|
|
datasource: DatasourceApi,
|
|
extractedUrls: string[]
|
|
): string[] {
|
|
const { defaultRegistryUrls = [] } = datasource;
|
|
if (!datasource.customRegistrySupport) {
|
|
if (is.nonEmptyArray(extractedUrls)) {
|
|
logger.warn(
|
|
{ datasource: datasource.id, registryUrls: extractedUrls },
|
|
'Custom registries are not allowed for this datasource and will be ignored'
|
|
);
|
|
}
|
|
return defaultRegistryUrls;
|
|
}
|
|
const customUrls = extractedUrls?.filter(Boolean);
|
|
let registryUrls: string[];
|
|
if (is.nonEmptyArray(customUrls)) {
|
|
registryUrls = [...customUrls];
|
|
} else {
|
|
registryUrls = [...defaultRegistryUrls];
|
|
}
|
|
return registryUrls.filter(Boolean).map(trimTrailingSlash);
|
|
}
|
|
|
|
export function getDefaultVersioning(datasourceName: string): string {
|
|
const datasource = getDatasourceFor(datasourceName);
|
|
// istanbul ignore if: wrong regex manager config?
|
|
if (!datasource) {
|
|
logger.warn({ datasourceName }, 'Missing datasource!');
|
|
}
|
|
return datasource?.defaultVersioning || 'semver';
|
|
}
|
|
|
|
function applyReplacements(
|
|
config: GetReleasesInternalConfig
|
|
): Pick<ReleaseResult, 'replacementName' | 'replacementVersion'> | undefined {
|
|
if (config.replacementName && config.replacementVersion) {
|
|
return {
|
|
replacementName: config.replacementName,
|
|
replacementVersion: config.replacementVersion,
|
|
};
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function fetchReleases(
|
|
config: GetReleasesInternalConfig
|
|
): Promise<ReleaseResult | null> {
|
|
const { datasource: datasourceName } = config;
|
|
if (!datasourceName || getDatasourceFor(datasourceName) === undefined) {
|
|
logger.warn('Unknown datasource: ' + datasourceName);
|
|
return null;
|
|
}
|
|
const datasource = getDatasourceFor(datasourceName);
|
|
const registryUrls = resolveRegistryUrls(datasource, config.registryUrls);
|
|
let dep: ReleaseResult = null;
|
|
const registryStrategy = datasource.registryStrategy || 'hunt';
|
|
try {
|
|
if (is.nonEmptyArray(registryUrls)) {
|
|
if (registryStrategy === 'first') {
|
|
dep = await firstRegistry(config, datasource, registryUrls);
|
|
} else if (registryStrategy === 'hunt') {
|
|
dep = await huntRegistries(config, datasource, registryUrls);
|
|
} else if (registryStrategy === 'merge') {
|
|
dep = await mergeRegistries(config, datasource, registryUrls);
|
|
}
|
|
} else {
|
|
dep = await datasource.getReleases(config);
|
|
}
|
|
} catch (err) {
|
|
if (err.message === HOST_DISABLED || err.err?.message === HOST_DISABLED) {
|
|
return null;
|
|
}
|
|
if (err instanceof ExternalHostError) {
|
|
throw err;
|
|
}
|
|
logError(datasource.id, config.lookupName, err);
|
|
}
|
|
if (!dep || dequal(dep, { releases: [] })) {
|
|
return null;
|
|
}
|
|
addMetaData(dep, datasourceName, config.lookupName);
|
|
dep = { ...dep, ...applyReplacements(config) };
|
|
return dep;
|
|
}
|
|
|
|
function getRawReleases(
|
|
config: GetReleasesInternalConfig
|
|
): Promise<ReleaseResult | null> {
|
|
const { datasource, lookupName, registryUrls } = config;
|
|
const cacheKey = `${cacheNamespace}${datasource}${lookupName}${String(
|
|
registryUrls
|
|
)}`;
|
|
// By returning a Promise and reusing it, we should only fetch each package at most once
|
|
const cachedResult = memCache.get<Promise<ReleaseResult | null>>(cacheKey);
|
|
// istanbul ignore if
|
|
if (cachedResult !== undefined) {
|
|
return cachedResult;
|
|
}
|
|
const promisedRes = fetchReleases(config);
|
|
memCache.set(cacheKey, promisedRes);
|
|
return promisedRes;
|
|
}
|
|
|
|
export async function getPkgReleases(
|
|
config: GetPkgReleasesConfig
|
|
): Promise<ReleaseResult | null> {
|
|
if (!config.datasource) {
|
|
logger.warn('No datasource found');
|
|
return null;
|
|
}
|
|
const lookupName = config.lookupName || config.depName;
|
|
if (!lookupName) {
|
|
logger.error({ config }, 'Datasource getReleases without lookupName');
|
|
return null;
|
|
}
|
|
let res: ReleaseResult;
|
|
try {
|
|
res = clone(
|
|
await getRawReleases({
|
|
...config,
|
|
lookupName,
|
|
})
|
|
);
|
|
} catch (e) /* istanbul ignore next */ {
|
|
if (e instanceof ExternalHostError) {
|
|
e.hostType = config.datasource;
|
|
e.lookupName = lookupName;
|
|
}
|
|
throw e;
|
|
}
|
|
if (!res) {
|
|
return res;
|
|
}
|
|
if (config.extractVersion) {
|
|
const extractVersionRegEx = regEx(config.extractVersion);
|
|
res.releases = res.releases
|
|
.map((release) => {
|
|
const version = extractVersionRegEx.exec(release.version)?.groups
|
|
?.version;
|
|
if (version) {
|
|
return { ...release, version }; // overwrite version
|
|
}
|
|
return null; // filter out any we can't extract
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
// Use the datasource's default versioning if none is configured
|
|
const versioning =
|
|
config.versioning || getDefaultVersioning(config.datasource);
|
|
const version = allVersioning.get(versioning);
|
|
|
|
// Filter and sort valid versions
|
|
res.releases = res.releases
|
|
.filter((release) => version.isVersion(release.version))
|
|
.sort((a, b) => version.sortVersions(a.version, b.version));
|
|
|
|
// Filter versions for uniqueness
|
|
res.releases = res.releases.filter(
|
|
(filterRelease, filterIndex) =>
|
|
res.releases.findIndex(
|
|
(findRelease) => findRelease.version === filterRelease.version
|
|
) === filterIndex
|
|
);
|
|
// Filter releases for compatibility
|
|
for (const [constraintName, constraintValue] of Object.entries(
|
|
config.constraints || {}
|
|
)) {
|
|
// Currently we only support if the constraint is a plain version
|
|
// TODO: Support range/range compatibility filtering #8476
|
|
if (version.isVersion(constraintValue)) {
|
|
res.releases = res.releases.filter((release) => {
|
|
if (!is.nonEmptyArray(release.constraints?.[constraintName])) {
|
|
// A release with no constraints is OK
|
|
return true;
|
|
}
|
|
return release.constraints[constraintName].some(
|
|
// If any of the release's constraints match, then it's OK
|
|
(releaseConstraint) =>
|
|
!releaseConstraint ||
|
|
version.matches(constraintValue, releaseConstraint)
|
|
);
|
|
});
|
|
}
|
|
}
|
|
// Strip constraints from releases result
|
|
res.releases.forEach((release) => {
|
|
delete release.constraints;
|
|
});
|
|
return res;
|
|
}
|
|
|
|
export function supportsDigests(config: DigestConfig): boolean {
|
|
return 'getDigest' in getDatasourceFor(config.datasource);
|
|
}
|
|
|
|
export function getDigest(
|
|
config: DigestConfig,
|
|
value?: string
|
|
): Promise<string | null> {
|
|
const datasource = getDatasourceFor(config.datasource);
|
|
const lookupName = config.lookupName || config.depName;
|
|
const registryUrls = resolveRegistryUrls(datasource, config.registryUrls);
|
|
const digestConfig: DigestConfig = {
|
|
registryUrl: registryUrls[0],
|
|
currentValue: config.currentValue,
|
|
currentDigest: config.currentDigest,
|
|
lookupName,
|
|
};
|
|
return datasource.getDigest(digestConfig, value);
|
|
}
|
|
|
|
export function getDefaultConfig(
|
|
datasource: string
|
|
): Promise<Record<string, unknown>> {
|
|
const loadedDatasource = getDatasourceFor(datasource);
|
|
return Promise.resolve<Record<string, unknown>>(
|
|
loadedDatasource?.defaultConfig || Object.create({})
|
|
);
|
|
}
|