mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
55a7341be2
* test(manager): close azure-pipelines, bundler and cocoapods gaps Adds a pipeline whose step task is not versioned, so nothing is extracted, and a gem lock line carrying no version, which is skipped. Marks four guards that cannot be false: `extractContainer()` always returns a dep, `getLockFilePath()` always returns a path, and the cocoapods dep name, spec name and git account all derive from values their enclosing check already proved present. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(manager): close cargo, composer and cpanfile gaps Adds a composer repository map that disables something other than packagist, which is dropped rather than recorded. Marks five guards: the cargo warning only fires for a crate dep and its retry only runs when a dep was already current, a gitlab rule without a token never passes the auth check, and both cpanfile handlers run only once their match produced a value. Two implicit elses stay counted, in the cargo version resolver and the composer repository mapper, since a coverage-ignore hint does not attach to an `else if`. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(manager): close deps-edn, docker-compose and dockerfile gaps Adds three extractions: a deps.edn alias whose value is not a map, a compose extension carrying no image, and a dockerfile escape directive naming a character other than a backtick, which leaves the default in place. Marks the deps-edn repository name check, since object keys are always strings, and notes the repository spec chain, where a coverage-ignore hint does not attach to an `else if`. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(manager/flux): close the flux coverage gaps Adds two GitRepository resources whose url is ssh rather than http, one pinned to a tag and one to a commit. Neither yields a source url, since only http urls can be turned into one. Marks the Kustomization image guard: the schema rejects an image without a name, so `extractImage()` cannot return null there. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(manager): mark github-actions, gitlabci and glasskube guards None of these six needs a test. A parsed action ref is either a docker or a repository ref, `getDep()` always returns a dep for a job container and a service, and the remaining three need a listed file that cannot be read, a listed file that yields no deps, or a manifest whose repositories match no name. The glasskube resource kind chain stays counted, since a coverage-ignore hint does not attach to an `else if`. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(manager): mark gomod and gradle-wrapper guards Four guards that need setups no test provides: a removed dep that is not added back, a go.mod the replace massaging leaves unchanged, and a daemon-jvm file carrying no toolchain version. A parsed go directive always yields a version. The additional-dependency count chain stays counted, since a coverage-ignore hint does not attach to an `else if`. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> --------- Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
580 lines
17 KiB
TypeScript
580 lines
17 KiB
TypeScript
import { isString } from '@sindresorhus/is';
|
|
import {
|
|
type Document,
|
|
type Scalar,
|
|
type YAMLMap,
|
|
isMap,
|
|
isPair,
|
|
isScalar,
|
|
parseAllDocuments,
|
|
} from 'yaml';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { coerceArray } from '../../../util/array.ts';
|
|
import { readLocalFile } from '../../../util/fs/index.ts';
|
|
import { regEx } from '../../../util/regex.ts';
|
|
import { isHttpUrl } from '../../../util/url.ts';
|
|
import { parseYaml } from '../../../util/yaml.ts';
|
|
import { BitbucketTagsDatasource } from '../../datasource/bitbucket-tags/index.ts';
|
|
import { GitRefsDatasource } from '../../datasource/git-refs/index.ts';
|
|
import { GitTagsDatasource } from '../../datasource/git-tags/index.ts';
|
|
import { GithubReleasesDatasource } from '../../datasource/github-releases/index.ts';
|
|
import { GithubTagsDatasource } from '../../datasource/github-tags/index.ts';
|
|
import { GitlabTagsDatasource } from '../../datasource/gitlab-tags/index.ts';
|
|
import { HelmDatasource } from '../../datasource/helm/index.ts';
|
|
import { getDep } from '../dockerfile/extract.ts';
|
|
import { findDependencies } from '../helm-values/extract.ts';
|
|
import {
|
|
getOciChartDep,
|
|
isOCIRegistry,
|
|
removeOCIPrefix,
|
|
} from '../helmv3/oci.ts';
|
|
import { extractImage } from '../kustomize/extract.ts';
|
|
import type {
|
|
ExtractConfig,
|
|
PackageDependency,
|
|
PackageFile,
|
|
PackageFileContent,
|
|
} from '../types.ts';
|
|
import {
|
|
collectHelmRepos,
|
|
isSystemManifest,
|
|
systemManifestHeaderRegex,
|
|
} from './common.ts';
|
|
import { FluxResource, type HelmRepository } from './schema.ts';
|
|
import type {
|
|
FluxManagerData,
|
|
FluxManifest,
|
|
ResourceFluxManifest,
|
|
SystemFluxManifest,
|
|
} from './types.ts';
|
|
|
|
function readManifest(
|
|
content: string,
|
|
packageFile: string,
|
|
): FluxManifest | null {
|
|
if (isSystemManifest(packageFile)) {
|
|
const versionMatch = regEx(systemManifestHeaderRegex).exec(content);
|
|
if (!versionMatch) {
|
|
return null;
|
|
}
|
|
return {
|
|
kind: 'system',
|
|
file: packageFile,
|
|
content,
|
|
version: versionMatch[1],
|
|
components: versionMatch[2],
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: 'resource',
|
|
file: packageFile,
|
|
content,
|
|
resources: parseYaml(content, {
|
|
customSchema: FluxResource,
|
|
failureBehaviour: 'filter',
|
|
removeTemplates: true,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const githubUrlRegex = regEx(
|
|
/^(?:https:\/\/|git@)github\.com[/:](?<packageName>[^/]+\/[^/]+?)(?:\.git)?$/,
|
|
);
|
|
const gitlabUrlRegex = regEx(
|
|
/^(?:https:\/\/|git@)gitlab\.com[/:](?<packageName>[^/]+\/[^/]+?)(?:\.git)?$/,
|
|
);
|
|
const bitbucketUrlRegex = regEx(
|
|
/^(?:https:\/\/|git@)bitbucket\.org[/:](?<packageName>[^/]+\/[^/]+?)(?:\.git)?$/,
|
|
);
|
|
|
|
function resolveGitRepositoryPerSourceTag(
|
|
dep: PackageDependency,
|
|
gitUrl: string,
|
|
): void {
|
|
const githubMatchGroups = githubUrlRegex.exec(gitUrl)?.groups;
|
|
if (githubMatchGroups) {
|
|
dep.datasource = GithubTagsDatasource.id;
|
|
dep.packageName = githubMatchGroups.packageName;
|
|
dep.sourceUrl = `https://github.com/${dep.packageName}`;
|
|
return;
|
|
}
|
|
|
|
const gitlabMatchGroups = gitlabUrlRegex.exec(gitUrl)?.groups;
|
|
if (gitlabMatchGroups) {
|
|
dep.datasource = GitlabTagsDatasource.id;
|
|
dep.packageName = gitlabMatchGroups.packageName;
|
|
dep.sourceUrl = `https://gitlab.com/${dep.packageName}`;
|
|
return;
|
|
}
|
|
|
|
const bitbucketMatchGroups = bitbucketUrlRegex.exec(gitUrl)?.groups;
|
|
if (bitbucketMatchGroups) {
|
|
dep.datasource = BitbucketTagsDatasource.id;
|
|
dep.packageName = bitbucketMatchGroups.packageName;
|
|
dep.sourceUrl = `https://bitbucket.org/${dep.packageName}`;
|
|
return;
|
|
}
|
|
|
|
dep.datasource = GitTagsDatasource.id;
|
|
dep.packageName = gitUrl;
|
|
if (isHttpUrl(gitUrl)) {
|
|
dep.sourceUrl = gitUrl.replace(regEx(/\.git$/), '');
|
|
}
|
|
}
|
|
|
|
function resolveHelmRepository(
|
|
dep: PackageDependency,
|
|
matchingRepositories: HelmRepository[],
|
|
registryAliases: Record<string, string> | undefined,
|
|
sourceRefName?: string,
|
|
): void {
|
|
if (matchingRepositories.length) {
|
|
dep.registryUrls = matchingRepositories
|
|
.map((repo) => {
|
|
if (repo.spec.type === 'oci' || isOCIRegistry(repo.spec.url)) {
|
|
Object.assign(
|
|
dep,
|
|
getOciChartDep(repo.spec.url, dep.depName, registryAliases),
|
|
);
|
|
return null;
|
|
}
|
|
return repo.spec.url;
|
|
})
|
|
.filter(isString);
|
|
|
|
// if registryUrls is empty, delete it from dep
|
|
if (!dep.registryUrls?.length) {
|
|
delete dep.registryUrls;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (sourceRefName && registryAliases) {
|
|
const aliasUrl = registryAliases[sourceRefName];
|
|
if (aliasUrl) {
|
|
if (isOCIRegistry(aliasUrl)) {
|
|
// Treat alias value as an OCI registry URL
|
|
Object.assign(
|
|
dep,
|
|
getOciChartDep(aliasUrl, dep.depName, registryAliases),
|
|
);
|
|
} else {
|
|
dep.registryUrls = [aliasUrl];
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
dep.skipReason = 'unknown-registry';
|
|
}
|
|
|
|
function resolveSystemManifest(
|
|
manifest: SystemFluxManifest,
|
|
): PackageDependency<FluxManagerData>[] {
|
|
return [
|
|
{
|
|
depName: 'fluxcd/flux2',
|
|
datasource: GithubReleasesDatasource.id,
|
|
currentValue: manifest.version,
|
|
managerData: {
|
|
components: manifest.components,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Returns all `spec.ref` map nodes for OCIRepository resources matching `resourceName`.
|
|
*/
|
|
function findOCIRefNodes(
|
|
docs: Document.Parsed[],
|
|
resourceName: string,
|
|
): YAMLMap[] {
|
|
const refNodes: YAMLMap[] = [];
|
|
for (const doc of docs) {
|
|
const docContents = doc.contents;
|
|
if (!isMap(docContents)) {
|
|
continue;
|
|
}
|
|
const kindNode = docContents.get('kind', true);
|
|
if (
|
|
!isScalar(kindNode) ||
|
|
(kindNode.value as unknown) !== 'OCIRepository'
|
|
) {
|
|
continue;
|
|
}
|
|
const nameNode = docContents.getIn(['metadata', 'name'], true);
|
|
if (!isScalar(nameNode) || nameNode.value !== resourceName) {
|
|
continue;
|
|
}
|
|
const specNode = docContents.get('spec');
|
|
if (!isMap(specNode)) {
|
|
continue;
|
|
}
|
|
const refNode = specNode.get('ref');
|
|
if (isMap(refNode)) {
|
|
refNodes.push(refNode);
|
|
}
|
|
}
|
|
|
|
return refNodes;
|
|
}
|
|
|
|
function extractOCIRefTagAndDigestRange(
|
|
docs: Document.Parsed[],
|
|
content: string,
|
|
resourceName: string,
|
|
): { replaceString: string; tagFirst: boolean } | null {
|
|
for (const refNode of findOCIRefNodes(docs, resourceName)) {
|
|
let tagKey: Scalar | undefined;
|
|
let tagValue: Scalar | undefined;
|
|
let digestKey: Scalar | undefined;
|
|
let digestValue: Scalar | undefined;
|
|
|
|
for (const item of refNode.items) {
|
|
if (!isPair(item) || !isScalar(item.key)) {
|
|
continue;
|
|
}
|
|
if (item.key.value === 'tag' && isScalar(item.value)) {
|
|
tagKey = item.key;
|
|
tagValue = item.value;
|
|
} else if (item.key.value === 'digest' && isScalar(item.value)) {
|
|
digestKey = item.key;
|
|
digestValue = item.value;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!tagKey?.range ||
|
|
!tagValue?.range ||
|
|
!digestKey?.range ||
|
|
!digestValue?.range
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const tagFirst = tagKey.range[0] < digestKey.range[0];
|
|
const start = tagFirst ? tagKey.range[0] : digestKey.range[0];
|
|
const end = tagFirst ? digestValue.range[1] : tagValue.range[1];
|
|
|
|
return { replaceString: content.slice(start, end), tagFirst };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractOCIRefTagRange(
|
|
docs: Document.Parsed[],
|
|
content: string,
|
|
resourceName: string,
|
|
): { replaceString: string; indentation: string } | null {
|
|
for (const refNode of findOCIRefNodes(docs, resourceName)) {
|
|
for (const item of refNode.items) {
|
|
if (
|
|
isPair(item) &&
|
|
isScalar(item.key) &&
|
|
item.key.value === 'tag' &&
|
|
isScalar(item.value) &&
|
|
item.key.range &&
|
|
item.value.range
|
|
) {
|
|
const keyStart = item.key.range[0];
|
|
const valueEnd = item.value.range[1];
|
|
const lineStart = content.lastIndexOf('\n', keyStart - 1) + 1;
|
|
return {
|
|
replaceString: content.slice(keyStart, valueEnd),
|
|
indentation: content.slice(lineStart, keyStart),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveResourceManifest(
|
|
manifest: ResourceFluxManifest,
|
|
helmRepositories: HelmRepository[],
|
|
registryAliases: Record<string, string> | undefined,
|
|
content: string,
|
|
): PackageDependency[] {
|
|
let docs: Document.Parsed[] | undefined;
|
|
const deps: PackageDependency[] = [];
|
|
for (const resource of manifest.resources) {
|
|
switch (resource.kind) {
|
|
case 'HelmRelease': {
|
|
if (resource.spec.chartRef) {
|
|
logger.trace(
|
|
'HelmRelease using chartRef was found, skipping as version will be handled via referenced resource directly',
|
|
);
|
|
} else if (resource.spec.chart) {
|
|
const chartSpec = resource.spec.chart.spec;
|
|
const depName = chartSpec.chart;
|
|
const dep: PackageDependency = {
|
|
depName,
|
|
currentValue: resource.spec.chart.spec.version,
|
|
datasource: HelmDatasource.id,
|
|
};
|
|
|
|
if (depName.startsWith('./')) {
|
|
dep.skipReason = 'local-chart';
|
|
delete dep.datasource;
|
|
} else {
|
|
const sourceRef = chartSpec.sourceRef;
|
|
const matchingRepositories = helmRepositories.filter(
|
|
(rep) =>
|
|
rep.kind === sourceRef?.kind &&
|
|
rep.metadata.name === sourceRef.name &&
|
|
rep.metadata.namespace ===
|
|
(sourceRef?.namespace ?? resource.metadata?.namespace),
|
|
);
|
|
resolveHelmRepository(
|
|
dep,
|
|
matchingRepositories,
|
|
registryAliases,
|
|
sourceRef?.name,
|
|
);
|
|
}
|
|
deps.push(dep);
|
|
} else {
|
|
logger.debug(
|
|
`invalid or incomplete ${resource.metadata.name} HelmRelease spec, skipping`,
|
|
);
|
|
}
|
|
|
|
if (resource.spec.values) {
|
|
logger.trace('detecting dependencies in HelmRelease values');
|
|
deps.push(...findDependencies(resource.spec.values, registryAliases));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'HelmChart': {
|
|
if (resource.spec.sourceRef.kind === 'GitRepository') {
|
|
logger.trace(
|
|
'HelmChart using GitRepository was found, skipping as version will be handled via referenced resource directly',
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const dep: PackageDependency = {
|
|
depName: resource.spec.chart,
|
|
};
|
|
|
|
if (resource.spec.sourceRef.kind === 'HelmRepository') {
|
|
dep.currentValue = resource.spec.version;
|
|
dep.datasource = HelmDatasource.id;
|
|
|
|
const sourceRef = resource.spec.sourceRef;
|
|
const matchingRepositories = helmRepositories.filter(
|
|
(rep) =>
|
|
rep.kind === sourceRef?.kind &&
|
|
rep.metadata.name === sourceRef.name &&
|
|
rep.metadata.namespace === resource.metadata?.namespace,
|
|
);
|
|
resolveHelmRepository(
|
|
dep,
|
|
matchingRepositories,
|
|
registryAliases,
|
|
sourceRef?.name,
|
|
);
|
|
} else {
|
|
dep.skipReason = 'unsupported-datasource';
|
|
}
|
|
deps.push(dep);
|
|
break;
|
|
}
|
|
|
|
case 'GitRepository': {
|
|
const dep: PackageDependency = {
|
|
depName: resource.metadata.name,
|
|
};
|
|
|
|
if (resource.spec.ref?.commit) {
|
|
const gitUrl = resource.spec.url;
|
|
dep.currentDigest = resource.spec.ref.commit;
|
|
dep.datasource = GitRefsDatasource.id;
|
|
dep.packageName = gitUrl;
|
|
dep.replaceString = resource.spec.ref.commit;
|
|
if (isHttpUrl(gitUrl)) {
|
|
dep.sourceUrl = gitUrl.replace(regEx(/\.git$/), '');
|
|
}
|
|
if (resource.spec.ref?.branch) {
|
|
dep.currentValue = resource.spec.ref.branch;
|
|
}
|
|
} else if (resource.spec.ref?.tag) {
|
|
dep.currentValue = resource.spec.ref.tag;
|
|
resolveGitRepositoryPerSourceTag(dep, resource.spec.url);
|
|
} else {
|
|
dep.skipReason = 'unversioned-reference';
|
|
}
|
|
deps.push(dep);
|
|
break;
|
|
}
|
|
case 'OCIRepository': {
|
|
const container = removeOCIPrefix(resource.spec.url);
|
|
if (resource.spec.ref?.digest && resource.spec.ref?.tag) {
|
|
const combinedDep = getDep(
|
|
`${container}@${resource.spec.ref.digest}`,
|
|
false,
|
|
registryAliases,
|
|
);
|
|
// Set currentValue to the tag so the docker datasource can look up the image's new digest
|
|
combinedDep.currentValue = resource.spec.ref.tag;
|
|
|
|
const refRange = extractOCIRefTagAndDigestRange(
|
|
(docs ??= parseAllDocuments(content, { strict: false })),
|
|
content,
|
|
resource.metadata.name,
|
|
);
|
|
if (refRange) {
|
|
combinedDep.replaceString = refRange.replaceString;
|
|
if (refRange.tagFirst) {
|
|
combinedDep.autoReplaceStringTemplate = refRange.replaceString
|
|
.replace(resource.spec.ref.tag, '{{newValue}}')
|
|
.replace(resource.spec.ref.digest, '{{newDigest}}');
|
|
} else {
|
|
combinedDep.autoReplaceStringTemplate = refRange.replaceString
|
|
.replace(resource.spec.ref.digest, '{{newDigest}}')
|
|
.replace(resource.spec.ref.tag, '{{newValue}}');
|
|
}
|
|
} else {
|
|
logger.debug(
|
|
{ file: manifest.file, name: resource.metadata.name },
|
|
'Could not find tag/digest nodes in content, skipping replacement',
|
|
);
|
|
combinedDep.skipReason = 'invalid-value';
|
|
}
|
|
|
|
deps.push(combinedDep);
|
|
} else if (resource.spec.ref?.digest) {
|
|
const dep = getDep(
|
|
`${container}@${resource.spec.ref.digest}`,
|
|
false,
|
|
registryAliases,
|
|
);
|
|
deps.push(dep);
|
|
} else if (resource.spec.ref?.tag) {
|
|
const dep = getDep(
|
|
`${container}:${resource.spec.ref.tag}`,
|
|
false,
|
|
registryAliases,
|
|
);
|
|
const refTagRange = extractOCIRefTagRange(
|
|
(docs ??= parseAllDocuments(content, { strict: false })),
|
|
content,
|
|
resource.metadata.name,
|
|
);
|
|
if (refTagRange) {
|
|
dep.replaceString = refTagRange.replaceString;
|
|
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
|
dep.autoReplaceStringTemplate = `${refTagRange.replaceString.replace(
|
|
resource.spec.ref.tag,
|
|
'{{newValue}}',
|
|
)}{{#if newDigest}}${newline}${refTagRange.indentation}digest: {{newDigest}}{{/if}}`;
|
|
} else {
|
|
logger.debug(
|
|
{ file: manifest.file, name: resource.metadata.name },
|
|
'Unable to locate tag node for replacement (may be YAML alias or alias reference), digest pinning will not be possible',
|
|
);
|
|
dep.replaceString = resource.spec.ref.tag;
|
|
dep.autoReplaceStringTemplate = '{{newValue}}';
|
|
}
|
|
deps.push(dep);
|
|
} else {
|
|
const dep = getDep(container, false, registryAliases);
|
|
dep.skipReason = 'unversioned-reference';
|
|
deps.push(dep);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'Kustomization': {
|
|
for (const image of coerceArray(resource.spec.images)) {
|
|
const dep = extractImage(image, registryAliases);
|
|
// v8 ignore else -- the schema rejects an image without a name
|
|
if (dep) {
|
|
deps.push(dep);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return deps;
|
|
}
|
|
|
|
export function extractPackageFile(
|
|
content: string,
|
|
packageFile: string,
|
|
config?: ExtractConfig,
|
|
): PackageFileContent<FluxManagerData> | null {
|
|
const manifest = readManifest(content, packageFile);
|
|
if (!manifest) {
|
|
return null;
|
|
}
|
|
const helmRepositories = collectHelmRepos([manifest]);
|
|
let deps: PackageDependency[] | null = null;
|
|
switch (manifest.kind) {
|
|
case 'system':
|
|
deps = resolveSystemManifest(manifest);
|
|
break;
|
|
case 'resource': {
|
|
deps = resolveResourceManifest(
|
|
manifest,
|
|
helmRepositories,
|
|
config?.registryAliases,
|
|
content,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
return deps?.length ? { deps } : null;
|
|
}
|
|
|
|
export async function extractAllPackageFiles(
|
|
config: ExtractConfig,
|
|
packageFiles: string[],
|
|
): Promise<PackageFile<FluxManagerData>[] | null> {
|
|
const manifests: FluxManifest[] = [];
|
|
const results: PackageFile<FluxManagerData>[] = [];
|
|
|
|
for (const file of packageFiles) {
|
|
const content = await readLocalFile(file, 'utf8');
|
|
if (content) {
|
|
const manifest = readManifest(content, file);
|
|
if (manifest) {
|
|
manifests.push(manifest);
|
|
}
|
|
}
|
|
}
|
|
|
|
const helmRepositories = collectHelmRepos(manifests);
|
|
|
|
for (const manifest of manifests) {
|
|
let deps: PackageDependency[] | null = null;
|
|
switch (manifest.kind) {
|
|
case 'system':
|
|
deps = resolveSystemManifest(manifest);
|
|
break;
|
|
case 'resource': {
|
|
deps = resolveResourceManifest(
|
|
manifest,
|
|
helmRepositories,
|
|
config.registryAliases,
|
|
manifest.content,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
if (deps?.length) {
|
|
results.push({
|
|
packageFile: manifest.file,
|
|
deps,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results.length ? results : null;
|
|
}
|