renovate/lib/modules/datasource/terraform-provider/index.ts

334 lines
10 KiB
TypeScript

// TODO: types (#22198)
import is from '@sindresorhus/is';
import { logger } from '../../../logger';
import { ExternalHostError } from '../../../types/errors/external-host-error';
import { cache } from '../../../util/cache/package/decorator';
import * as p from '../../../util/promises';
import { regEx } from '../../../util/regex';
import { joinUrlParts } from '../../../util/url';
import * as hashicorpVersioning from '../../versioning/hashicorp';
import { TerraformDatasource } from '../terraform-module/base';
import type { ServiceDiscoveryResult } from '../terraform-module/types';
import { createSDBackendURL } from '../terraform-module/utils';
import type { GetReleasesConfig, ReleaseResult } from '../types';
import type {
TerraformBuild,
TerraformProvider,
TerraformProviderReleaseBackend,
TerraformProviderVersions,
TerraformRegistryBuildResponse,
TerraformRegistryVersions,
VersionDetailResponse,
} from './types';
export class TerraformProviderDatasource extends TerraformDatasource {
static override readonly id = 'terraform-provider';
static readonly defaultRegistryUrls = [
'https://registry.terraform.io',
'https://releases.hashicorp.com',
];
static repositoryRegex = regEx(/^hashicorp\/(?<packageName>\S+)$/);
constructor() {
super(TerraformProviderDatasource.id);
}
override readonly defaultRegistryUrls =
TerraformProviderDatasource.defaultRegistryUrls;
override readonly defaultVersioning = hashicorpVersioning.id;
override readonly registryStrategy = 'hunt';
override readonly releaseTimestampSupport = true;
override readonly releaseTimestampNote =
'The release timestamp is only supported for the latest version, and is determined from the `published_at` field in the results.';
override readonly sourceUrlSupport = 'package';
override readonly sourceUrlNote =
'The source URL is determined from the the `source` field in the results.';
@cache({
namespace: `datasource-${TerraformProviderDatasource.id}`,
key: (getReleasesConfig: GetReleasesConfig) => {
const url = getReleasesConfig.registryUrl;
const repo = TerraformProviderDatasource.getRepository(getReleasesConfig);
return `getReleases:${url}/${repo}`;
},
})
async getReleases({
packageName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
// istanbul ignore if
if (!registryUrl) {
return null;
}
logger.trace(
`terraform-provider.getDependencies() packageName: ${packageName}`,
);
if (registryUrl === this.defaultRegistryUrls[1]) {
return await this.queryReleaseBackend(packageName, registryUrl);
}
const repository = TerraformProviderDatasource.getRepository({
packageName,
});
const serviceDiscovery =
await this.getTerraformServiceDiscoveryResult(registryUrl);
if (registryUrl === this.defaultRegistryUrls[0]) {
return await this.queryRegistryExtendedApi(
serviceDiscovery,
registryUrl,
repository,
);
}
return await this.queryRegistryVersions(
serviceDiscovery,
registryUrl,
repository,
);
}
private static getRepository({ packageName }: GetReleasesConfig): string {
return packageName.includes('/') ? packageName : `hashicorp/${packageName}`;
}
/**
* this uses the api that terraform registry has in addition to the base api
* this endpoint provides more information, such as release date
* this api is undocumented.
*/
private async queryRegistryExtendedApi(
serviceDiscovery: ServiceDiscoveryResult,
registryUrl: string,
repository: string,
): Promise<ReleaseResult> {
const backendURL = createSDBackendURL(
registryUrl,
'providers.v1',
serviceDiscovery,
repository,
);
const res = (await this.http.getJson<TerraformProvider>(backendURL)).body;
const dep: ReleaseResult = {
releases: res.versions.map((version) => ({
version,
})),
};
if (res.source) {
dep.sourceUrl = res.source;
}
// set published date for latest release
const latestVersion = dep.releases.find(
(release) => res.version === release.version,
);
// istanbul ignore else
if (latestVersion) {
latestVersion.releaseTimestamp = res.published_at;
}
dep.homepage = `${registryUrl}/providers/${repository}`;
return dep;
}
/**
* this version uses the Provider Registry Protocol that all registries are required to implement
* https://www.terraform.io/internals/provider-registry-protocol
*/
private async queryRegistryVersions(
serviceDiscovery: ServiceDiscoveryResult,
registryUrl: string,
repository: string,
): Promise<ReleaseResult> {
const backendURL = createSDBackendURL(
registryUrl,
'providers.v1',
serviceDiscovery,
`${repository}/versions`,
);
const res = (await this.http.getJson<TerraformProviderVersions>(backendURL))
.body;
const dep: ReleaseResult = {
releases: res.versions.map(({ version }) => ({
version,
})),
};
return dep;
}
private async queryReleaseBackend(
packageName: string,
registryURL: string,
): Promise<ReleaseResult | null> {
const hashicorpPackage = packageName.replace('hashicorp/', '');
const backendLookUpName = `terraform-provider-${hashicorpPackage}`;
const backendURL = joinUrlParts(
registryURL,
backendLookUpName,
`index.json`,
);
const res = (
await this.http.getJson<TerraformProviderReleaseBackend>(backendURL)
).body;
const dep: ReleaseResult = {
releases: Object.keys(res.versions).map((version) => ({
version,
})),
sourceUrl: joinUrlParts(
'https://github.com/terraform-providers',
backendLookUpName,
),
};
return dep;
}
@cache({
namespace: `datasource-${TerraformProviderDatasource.id}`,
key: (registryURL: string, repository: string, version: string) =>
`getBuilds:${registryURL}/${repository}/${version}`,
})
async getBuilds(
registryURL: string,
repository: string,
version: string,
): Promise<TerraformBuild[] | null> {
if (registryURL === TerraformProviderDatasource.defaultRegistryUrls[1]) {
// check if registryURL === secondary backend
const repositoryRegexResult =
TerraformProviderDatasource.repositoryRegex.exec(repository)?.groups;
if (!repositoryRegexResult) {
// non hashicorp builds are not supported with releases.hashicorp.com
return null;
}
const packageName = repositoryRegexResult.packageName;
const backendLookUpName = `terraform-provider-${packageName}`;
let versionReleaseBackend: VersionDetailResponse;
try {
versionReleaseBackend = await this.getReleaseBackendIndex(
backendLookUpName,
version,
);
} catch (err) {
/* istanbul ignore next */
if (err instanceof ExternalHostError) {
throw err;
}
logger.debug(
{ err, backendLookUpName, version },
`Failed to retrieve builds for ${backendLookUpName} ${version}`,
);
return null;
}
return versionReleaseBackend.builds;
}
// check public or private Terraform registry
const serviceDiscovery =
await this.getTerraformServiceDiscoveryResult(registryURL);
if (!serviceDiscovery) {
logger.trace(`Failed to retrieve service discovery from ${registryURL}`);
return null;
}
const backendURL = createSDBackendURL(
registryURL,
'providers.v1',
serviceDiscovery,
repository,
);
const versionsResponse = (
await this.http.getJson<TerraformRegistryVersions>(
`${backendURL}/versions`,
)
).body;
if (!versionsResponse.versions) {
logger.trace(`Failed to retrieve version list for ${backendURL}`);
return null;
}
const builds = versionsResponse.versions.find(
(value) => value.version === version,
);
if (!builds) {
logger.trace(
`No builds found for ${repository}:${version} on ${registryURL}`,
);
return null;
}
const result = await p.map(
builds.platforms,
async (platform) => {
const buildURL = `${backendURL}/${version}/download/${platform.os}/${platform.arch}`;
try {
const res = (
await this.http.getJson<TerraformRegistryBuildResponse>(buildURL)
).body;
const newBuild: TerraformBuild = {
name: repository,
url: res.download_url,
version,
...res,
};
return newBuild;
} catch (err) {
/* istanbul ignore next */
if (err instanceof ExternalHostError) {
throw err;
}
logger.debug({ err, url: buildURL }, 'Failed to retrieve build');
return null;
}
},
{ concurrency: 4 },
);
const filteredResult = result.filter(is.truthy);
return filteredResult.length === result.length ? filteredResult : null;
}
@cache({
namespace: `datasource-${TerraformProviderDatasource.id}`,
key: (zipHashUrl: string) => `getZipHashes:${zipHashUrl}`,
})
async getZipHashes(zipHashUrl: string): Promise<string[] | undefined> {
// The hashes are formatted as the result of sha256sum in plain text, each line: <hash>\t<filename>
let rawHashData: string;
try {
rawHashData = (await this.http.get(zipHashUrl)).body;
} catch (err) {
/* istanbul ignore next */
if (err instanceof ExternalHostError) {
throw err;
}
logger.debug(
{ err, zipHashUrl },
`Failed to retrieve zip hashes from ${zipHashUrl}`,
);
return undefined;
}
return rawHashData
.trimEnd()
.split('\n')
.map((line) => line.split(/\s/)[0]);
}
@cache({
namespace: `datasource-${TerraformProviderDatasource.id}`,
key: (backendLookUpName: string, version: string) =>
`getReleaseBackendIndex:${backendLookUpName}/${version}`,
})
async getReleaseBackendIndex(
backendLookUpName: string,
version: string,
): Promise<VersionDetailResponse> {
return (
await this.http.getJson<VersionDetailResponse>(
`${TerraformProviderDatasource.defaultRegistryUrls[1]}/${backendLookUpName}/${version}/index.json`,
)
).body;
}
}