Files
renovate/lib/modules/datasource/pod/index.ts
Gregor Zeitlinger 0d9888a4f2 feat(datasource): add GraalVM version datasource (#40497)
* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* add coverage

* fix

* fix

* fix

* code cov

* code cov

* code cov

* refactor(datasource): integrate GraalVM into java-version datasource

Merges graalvm-version datasource into java-version as a unified multi-vendor
Java distribution datasource. Implements vendor-based modular architecture with
routing layer for Adoptium (Eclipse Temurin) and Oracle GraalVM.

Architecture:
- Vendor-specific handlers (adoptium.ts, graalvm.ts) with routing in index.ts
- Vendor-aware system detection with different arch/os mappings per vendor
- Custom registry support for GraalVM's mise-java API
- Adoptium remains the default registry for backwards compatibility

Package names:
- New explicit: adoptium-jdk, adoptium-jre, oracle-graalvm-jdk, oracle-graalvm-jre
- Legacy (map to Adoptium): java, java-jdk, java-jre

Changes:
- Created adoptium.ts and graalvm.ts vendor handlers
- Enhanced common.ts with vendor detection and vendor-specific system mappings
- Updated index.ts with routing layer and custom registry support
- Merged test suites with comprehensive coverage for both vendors
- Updated mise manager integration to use JavaVersionDatasource
- Removed graalvm-version datasource directory

All 65 datasource tests and 93 mise manager tests passing.

* fix

* test(datasource): add coverage for non-HttpError paths in java-version

* fix(datasource): remove dead code and add coverage for java-version

Remove unreachable HttpError handling from _getReleases catch block
since both getAdoptiumReleases and getGraalvmReleases handle HttpError
internally. Add test for the graalvmRegistryUrl fallback when
registryUrl is undefined.

* fix(datasource): address review comments for GraalVM datasource

- Remove unused 'datasource-graalvm-version' cache namespace
- Convert MiseJavaRelease interface to Zod schema and derive type from it
- Use http.getJson() with the Zod schema in getGraalvmReleases instead
  of getJsonUnchecked, letting the schema handle invalid/empty responses
- Update getGraalvmReleases test mock to use getJson

* docs: remove unused datasource-graalvm-version from cacheTtlOverride docs

* fix(datasource): preserve Adoptium config typing

* fix(datasource): harden GraalVM release parsing

* fix(datasource): address GraalVM review feedback

* test: cover GraalVM mise package mapping

* test(datasource): cover default GraalVM registry fallback

* test(datasource): cover legacy registry resolution

* test(datasource): type legacy datasource mocks

* fix(datasource): harden GraalVM defaults and responses

* refactor(datasource): use function-only registry accessors

* test(datasource): cover registry accessor functions

* test: restore coverage exclusions

* chore: retrigger CI checks
2026-09-23 08:40:24 +00:00

256 lines
7.5 KiB
TypeScript

import crypto from 'node:crypto';
import {
HOST_BLOCKED,
HOST_DISABLED,
} from '../../../constants/error-messages.ts';
import { logger } from '../../../logger/index.ts';
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
import { withCache } from '../../../util/cache/package/with-cache.ts';
import { GithubHttp } from '../../../util/http/github.ts';
import type { HttpError } from '../../../util/http/index.ts';
import { refusedHostMessage } from '../../../util/http/util.ts';
import { newlineRegex, regEx } from '../../../util/regex.ts';
import { Datasource } from '../datasource.ts';
import { massageGithubUrl } from '../metadata.ts';
import type { GetReleasesConfig, ReleaseResult } from '../types.ts';
type URLFormatOptions =
| 'withShardWithSpec'
| 'withShardWithoutSpec'
| 'withSpecsWithoutShard'
| 'withoutSpecsWithoutShard';
function shardParts(packageName: string): string[] {
return crypto
.createHash('md5')
.update(packageName)
.digest('hex')
.slice(0, 3)
.split('');
}
const githubRegex = regEx(
/(?<hostURL>^https:\/\/[a-zA-Z0-9-.]+)\/(?<account>[^/]+)\/(?<repo>[^/]+?)(?:\.git|\/.*)?$/,
);
function releasesGithubUrl(
packageName: string,
opts: {
hostURL: string;
account: string;
repo: string;
useShard: boolean;
useSpecs: boolean;
},
): string {
const { hostURL, account, repo, useShard, useSpecs } = opts;
const prefix =
hostURL && hostURL !== 'https://github.com'
? `${hostURL}/api/v3/repos`
: 'https://api.github.com/repos';
const shard = shardParts(packageName).join('/');
// `Specs` in the pods repo URL is a new requirement for legacy support also allow pod repo URL without `Specs`
const packageNamePath = useSpecs ? `Specs/${packageName}` : packageName;
const shardPath = useSpecs
? `Specs/${shard}/${packageName}`
: `${shard}/${packageName}`;
const suffix = useShard ? shardPath : packageNamePath;
return `${prefix}/${account}/${repo}/contents/${suffix}`;
}
function handleError(packageName: string, err: HttpError): void {
const errorData = { packageName, err };
const statusCode = err.response?.statusCode ?? 0;
if (statusCode === 429 || (statusCode >= 500 && statusCode < 600)) {
logger.warn({ packageName, err }, `CocoaPods registry failure`);
throw new ExternalHostError(err);
}
if (statusCode === 401) {
logger.debug(errorData, 'Authorization error');
} else if (statusCode === 404) {
logger.debug(errorData, 'Package lookup error');
} else if ([HOST_BLOCKED, HOST_DISABLED].includes(err.message)) {
logger.trace(errorData, refusedHostMessage(err));
} else {
logger.warn(errorData, 'CocoaPods lookup failure: Unknown error');
}
}
function isDefaultRepo(url: string): boolean {
const match = githubRegex.exec(url);
if (match?.groups) {
const { account, repo } = match.groups;
return (
account.toLowerCase() === 'cocoapods' && repo.toLowerCase() === 'specs'
); // https://github.com/CocoaPods/Specs.git
}
return false;
}
function releasesCDNUrl(packageName: string, registryUrl: string): string {
const shard = shardParts(packageName).join('_');
return `${registryUrl}/all_pods_versions_${shard}.txt`;
}
export class PodDatasource extends Datasource {
static readonly id = 'pod';
override getDefaultRegistryUrls(_packageName: string): string[] {
return ['https://cdn.cocoapods.org'];
}
override readonly registryStrategy = 'hunt';
githubHttp: GithubHttp;
constructor() {
super(PodDatasource.id);
this.githubHttp = new GithubHttp(PodDatasource.id);
}
private async requestCDN(
url: string,
packageName: string,
): Promise<string | null> {
try {
const resp = await this.http.getText(url);
if (resp?.body) {
return resp.body;
}
} catch (err) {
handleError(packageName, err);
}
return null;
}
private async requestGithub<T = unknown>(
url: string,
packageName: string,
): Promise<T | null> {
try {
const resp = await this.githubHttp.getJsonUnchecked<T>(url);
// v8 ignore else -- an empty json body throws before it gets here
if (resp?.body) {
return resp.body;
}
} catch (err) {
handleError(packageName, err);
}
return null;
}
private async getReleasesFromGithub(
packageName: string,
opts: { hostURL: string; account: string; repo: string },
useShard = true,
useSpecs = true,
urlFormatOptions: URLFormatOptions = 'withShardWithSpec',
): Promise<ReleaseResult | null> {
const url = releasesGithubUrl(packageName, { ...opts, useShard, useSpecs });
const resp = await this.requestGithub<{ name: string }[]>(url, packageName);
if (resp) {
const releases = resp.map(({ name }) => ({ version: name }));
return { releases };
}
// support different url formats
switch (urlFormatOptions) {
case 'withShardWithSpec':
return this.getReleasesFromGithub(
packageName,
opts,
true,
false,
'withShardWithoutSpec',
);
case 'withShardWithoutSpec':
return this.getReleasesFromGithub(
packageName,
opts,
false,
true,
'withSpecsWithoutShard',
);
case 'withSpecsWithoutShard':
return this.getReleasesFromGithub(
packageName,
opts,
false,
false,
'withoutSpecsWithoutShard',
);
case 'withoutSpecsWithoutShard':
default:
return null;
}
}
private async getReleasesFromCDN(
packageName: string,
registryUrl: string,
): Promise<ReleaseResult | null> {
const url = releasesCDNUrl(packageName, registryUrl);
const resp = await this.requestCDN(url, packageName);
if (resp) {
const lines = resp.split(newlineRegex);
for (const line of lines) {
const [name, ...versions] = line.split('/');
if (name === packageName.replace(regEx(/\/.*$/), '')) {
const releases = versions.map((version) => ({ version }));
return { releases };
}
}
}
return null;
}
private async _getReleases({
packageName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
/* v8 ignore next -- should never happen */
if (!registryUrl) {
return null;
}
const podName = packageName.replace(regEx(/\/.*$/), '');
let baseUrl = registryUrl.replace(regEx(/\/+$/), '');
// In order to not abuse github API limits, query CDN instead
if (isDefaultRepo(baseUrl)) {
[baseUrl] = this.getDefaultRegistryUrls('');
}
let result: ReleaseResult | null = null;
const match = githubRegex.exec(baseUrl);
// We would ideally have a reliable way to differentiate between
// a CDN URL and a Github URL, but we'll start with detecting Artifactory
if (match?.groups && !baseUrl.includes('/api/pods/')) {
baseUrl = massageGithubUrl(baseUrl);
const { hostURL, account, repo } = match.groups;
const opts = { hostURL, account, repo };
result = await this.getReleasesFromGithub(podName, opts);
} else {
result = await this.getReleasesFromCDN(podName, baseUrl);
}
return result;
}
getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
return withCache(
{
ttlMinutes: 30,
namespace: `datasource-${PodDatasource.id}`,
// TODO: types (#22198)
key: `${config.registryUrl}:${config.packageName}`,
fallback: true,
},
() => this._getReleases(config),
);
}
}