mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 15:01:49 +00:00
feat(terraform): add OCI protocol support for modules and providers (#38934)
* feat(terraform): add OCI protocol support for modules and providers Adds support for Terraform modules and providers hosted in OCI-compatible container registries using the `oci://` protocol. Delegates OCI sources to Docker datasource for version resolution. Supports both version in separate field and embedded in URL. Closes https://github.com/renovatebot/renovate/discussions/38927 * refactor(terraform): address code review feedback for OCI support cd - Move ociRefMatchRegex to shared location in base.ts cd - Extract OCI parsing into parseOciRef function to reduce duplication cd - Remove unnecessary registryUrls assignments cd - Add skipReason when OCI tag is unspecified cd - Inline oci.tf fixture to avoid triggering vulnerability scanners cd - Refactor OCI regex tests to use it.each * refactor(terraform): address PR review, round 2 - Replace custom ociRefMatchRegex/parseOciRef with getDep pattern, reusing isOCIRegistry/removeOCIPrefix from helmv3/oci.ts - Remove docs/development/oci-terraform-support.md - Remove ociRefMatchRegex/parseOciRef from base.ts and modules.ts - Simplify OCI test in extract.spec.ts - Remove ociRefMatchRegex describe block from modules.spec.ts * refactor(terraform): address PR review, round 3 extract duplicate function Signed-off-by: Norman Stetter <85173861+norman-zon@users.noreply.github.com> * fix: only allow valid oci urls oci sources only support query params for versioning * feat: pass through registryaliases * fix: fix broken tests * fix: address PR review round 4 * fix: replace new URL with parseUrl Signed-off-by: Norman Stetter <85173861+norman-zon@users.noreply.github.com> * fix: add tests for new path Signed-off-by: Norman Stetter <85173861+norman-zon@users.noreply.github.com> --------- Signed-off-by: Norman Stetter <85173861+norman-zon@users.noreply.github.com>
This commit is contained in:
@@ -2,10 +2,15 @@ import { isNonEmptyString } from '@sindresorhus/is';
|
||||
import { logger } from '../../../logger/index.ts';
|
||||
import { regEx } from '../../../util/regex.ts';
|
||||
import { TerraformProviderDatasource } from '../../datasource/terraform-provider/index.ts';
|
||||
import { isOCIRegistry } from '../helmv3/oci.ts';
|
||||
import type { ExtractConfig, PackageDependency } from '../types.ts';
|
||||
import type { TerraformDefinitionFile } from './hcl/types.ts';
|
||||
import type { ProviderLock } from './lockfile/types.ts';
|
||||
import { getLockedVersion, massageProviderLookupName } from './util.ts';
|
||||
import {
|
||||
applyOciDependency,
|
||||
getLockedVersion,
|
||||
massageProviderLookupName,
|
||||
} from './util.ts';
|
||||
|
||||
export abstract class DependencyExtractor {
|
||||
/**
|
||||
@@ -35,6 +40,7 @@ export abstract class TerraformProviderExtractor extends DependencyExtractor {
|
||||
dep: PackageDependency,
|
||||
locks: ProviderLock[],
|
||||
depType: string,
|
||||
config: ExtractConfig,
|
||||
): PackageDependency {
|
||||
dep.depType = depType;
|
||||
dep.depName = dep.managerData?.moduleName;
|
||||
@@ -42,6 +48,11 @@ export abstract class TerraformProviderExtractor extends DependencyExtractor {
|
||||
|
||||
if (isNonEmptyString(dep.managerData?.source)) {
|
||||
// TODO #22198
|
||||
if (isOCIRegistry(dep.managerData.source)) {
|
||||
applyOciDependency(dep, dep.managerData.source, config.registryAliases);
|
||||
return dep;
|
||||
}
|
||||
|
||||
const source = this.sourceExtractionRegex.exec(dep.managerData.source);
|
||||
if (!source?.groups) {
|
||||
dep.skipReason = 'unsupported-url';
|
||||
|
||||
@@ -335,6 +335,131 @@ describe('modules/manager/terraform/extract', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves OCI registry aliases', async () => {
|
||||
const src = codeBlock`
|
||||
module "aliased_oci" {
|
||||
source = "oci://hub.proxy.test/terraform-modules/vpc?tag=1.0.0"
|
||||
}
|
||||
`;
|
||||
const res = await extractPackageFile(src, 'oci.tf', {
|
||||
registryAliases: { 'hub.proxy.test': 'index.docker.io' },
|
||||
});
|
||||
expect(res?.deps).toIncludeAllPartialMembers([
|
||||
{
|
||||
currentValue: '1.0.0',
|
||||
datasource: 'docker',
|
||||
depName: 'aliased_oci',
|
||||
depType: 'module',
|
||||
packageName: 'index.docker.io/terraform-modules/vpc',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles invalid OCI source URL', async () => {
|
||||
const src = codeBlock`
|
||||
module "bad_oci" {
|
||||
source = "oci://not a valid url"
|
||||
}
|
||||
`;
|
||||
const res = await extractPackageFile(src, 'oci.tf', {});
|
||||
expect(res?.deps).toIncludeAllPartialMembers([
|
||||
{
|
||||
depName: 'bad_oci',
|
||||
depType: 'module',
|
||||
skipReason: 'invalid-url',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts OCI modules and providers', async () => {
|
||||
const src = codeBlock`
|
||||
module "vpc_oci" {
|
||||
source = "oci://registry.example.com/terraform-modules/vpc?tag=1.2.3"
|
||||
}
|
||||
|
||||
module "storage_oci_tagged" {
|
||||
source = "oci://ghcr.io/terraform-modules/storage?tag=3.1.0"
|
||||
}
|
||||
|
||||
module "digest_oci" {
|
||||
source = "oci://ghcr.io/terraform-modules/pinned?digest=sha256:abc123"
|
||||
}
|
||||
|
||||
module "no_version_oci" {
|
||||
source = "oci://registry.example.com/terraform-modules/noversion"
|
||||
}
|
||||
|
||||
terraform {
|
||||
required_providers {
|
||||
custom_oci = {
|
||||
source = "oci://registry.example.com/providers/custom?tag=1.0.0"
|
||||
}
|
||||
|
||||
tagged_oci = {
|
||||
source = "oci://ghcr.io/providers/tagged?tag=4.2.0"
|
||||
}
|
||||
|
||||
no_version_oci = {
|
||||
source = "oci://registry.example.com/providers/noversion"
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const res = await extractPackageFile(src, 'oci.tf', {});
|
||||
expect(res?.deps).toHaveLength(7);
|
||||
expect(res?.deps).toIncludeAllPartialMembers([
|
||||
{
|
||||
currentValue: '1.2.3',
|
||||
datasource: 'docker',
|
||||
depName: 'vpc_oci',
|
||||
depType: 'module',
|
||||
packageName: 'registry.example.com/terraform-modules/vpc',
|
||||
},
|
||||
{
|
||||
currentValue: '3.1.0',
|
||||
datasource: 'docker',
|
||||
depName: 'storage_oci_tagged',
|
||||
depType: 'module',
|
||||
packageName: 'ghcr.io/terraform-modules/storage',
|
||||
},
|
||||
{
|
||||
currentDigest: 'sha256:abc123',
|
||||
datasource: 'docker',
|
||||
depName: 'digest_oci',
|
||||
depType: 'module',
|
||||
packageName: 'ghcr.io/terraform-modules/pinned',
|
||||
},
|
||||
{
|
||||
datasource: 'docker',
|
||||
depName: 'no_version_oci',
|
||||
depType: 'module',
|
||||
packageName: 'registry.example.com/terraform-modules/noversion',
|
||||
skipReason: 'unspecified-version',
|
||||
},
|
||||
{
|
||||
currentValue: '1.0.0',
|
||||
datasource: 'docker',
|
||||
depName: 'custom_oci',
|
||||
depType: 'required_provider',
|
||||
packageName: 'registry.example.com/providers/custom',
|
||||
},
|
||||
{
|
||||
currentValue: '4.2.0',
|
||||
datasource: 'docker',
|
||||
depName: 'tagged_oci',
|
||||
depType: 'required_provider',
|
||||
packageName: 'ghcr.io/providers/tagged',
|
||||
},
|
||||
{
|
||||
datasource: 'docker',
|
||||
depName: 'no_version_oci',
|
||||
depType: 'required_provider',
|
||||
packageName: 'registry.example.com/providers/noversion',
|
||||
skipReason: 'unspecified-version',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts providers', async () => {
|
||||
const res = await extractPackageFile(providers, 'providers.tf', {});
|
||||
expect(res?.deps).toHaveLength(15);
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('modules/manager/terraform/extractors/others/modules', () => {
|
||||
const extractor = new ModuleExtractor();
|
||||
|
||||
it('return empty array if no module is found', () => {
|
||||
const res = extractor.extract({});
|
||||
const res = extractor.extract({}, [], {});
|
||||
expect(res).toBeArrayOfSize(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { BitbucketTagsDatasource } from '../../../../datasource/bitbucket-tags/i
|
||||
import { GitTagsDatasource } from '../../../../datasource/git-tags/index.ts';
|
||||
import { GithubTagsDatasource } from '../../../../datasource/github-tags/index.ts';
|
||||
import { TerraformModuleDatasource } from '../../../../datasource/terraform-module/index.ts';
|
||||
import type { PackageDependency } from '../../../types.ts';
|
||||
import { isOCIRegistry } from '../../../helmv3/oci.ts';
|
||||
import type { ExtractConfig, PackageDependency } from '../../../types.ts';
|
||||
import { DependencyExtractor } from '../../base.ts';
|
||||
import type { TerraformDefinitionFile } from '../../hcl/types.ts';
|
||||
import type { ProviderLock } from '../../lockfile/types.ts';
|
||||
import { applyOciDependency } from '../../util.ts';
|
||||
|
||||
export const githubRefMatchRegex = regEx(
|
||||
/github\.com([/:])(?<project>[^/]+\/[a-z0-9-_.]+).*\?(depth=\d+&)?ref=(?<tag>.*?)(&depth=\d+)?$/i,
|
||||
@@ -21,6 +24,7 @@ export const gitTagsRefMatchRegex = regEx(
|
||||
export const azureDevOpsSshRefMatchRegex = regEx(
|
||||
/(?:git::)?(?<url>git@ssh\.dev\.azure\.com:v3\/(?<organization>[^/]*)\/(?<project>[^/]*)\/(?<repository>[^/]*))(?<modulepath>.*)?\?(depth=\d+&)?ref=(?<tag>.*?)(&depth=\d+)?$/,
|
||||
);
|
||||
|
||||
export const hostnameMatchRegex = regEx(
|
||||
/^(?<hostname>[a-zA-Z\d]([a-zA-Z\d-]*\.)+[a-zA-Z\d]+)/,
|
||||
);
|
||||
@@ -30,7 +34,11 @@ export class ModuleExtractor extends DependencyExtractor {
|
||||
return ['module'];
|
||||
}
|
||||
|
||||
extract(hclRoot: TerraformDefinitionFile): PackageDependency[] {
|
||||
extract(
|
||||
hclRoot: TerraformDefinitionFile,
|
||||
_locks: ProviderLock[],
|
||||
config: ExtractConfig,
|
||||
): PackageDependency[] {
|
||||
const modules = hclRoot.module;
|
||||
if (isNullOrUndefined(modules)) {
|
||||
return [];
|
||||
@@ -53,16 +61,25 @@ export class ModuleExtractor extends DependencyExtractor {
|
||||
source: moduleElement.source,
|
||||
},
|
||||
};
|
||||
dependencies.push(this.analyseTerraformModule(dep));
|
||||
dependencies.push(this.analyseTerraformModule(dep, config));
|
||||
}
|
||||
}
|
||||
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
private analyseTerraformModule(dep: PackageDependency): PackageDependency {
|
||||
private analyseTerraformModule(
|
||||
dep: PackageDependency,
|
||||
config: ExtractConfig,
|
||||
): PackageDependency {
|
||||
// TODO #22198
|
||||
const source = dep.managerData!.source as string;
|
||||
|
||||
if (isOCIRegistry(source)) {
|
||||
applyOciDependency(dep, source, config.registryAliases);
|
||||
return dep;
|
||||
}
|
||||
|
||||
const githubRefMatch = githubRefMatchRegex.exec(source);
|
||||
const bitbucketRefMatch = bitbucketRefMatchRegex.exec(source);
|
||||
const gitTagsRefMatch = gitTagsRefMatchRegex.exec(source);
|
||||
|
||||
@@ -4,7 +4,7 @@ describe('modules/manager/terraform/extractors/others/providers', () => {
|
||||
const extractor = new ProvidersExtractor();
|
||||
|
||||
it('return null if no provider returned', () => {
|
||||
const result = extractor.extract({}, []);
|
||||
const result = extractor.extract({}, [], {});
|
||||
expect(result).toBeArrayOfSize(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isNullOrUndefined, isPlainObject } from '@sindresorhus/is';
|
||||
import { logger } from '../../../../../logger/index.ts';
|
||||
import type { PackageDependency } from '../../../types.ts';
|
||||
import type { ExtractConfig, PackageDependency } from '../../../types.ts';
|
||||
import { TerraformProviderExtractor } from '../../base.ts';
|
||||
import type { TerraformDefinitionFile } from '../../hcl/types.ts';
|
||||
import type { ProviderLock } from '../../lockfile/types.ts';
|
||||
@@ -13,6 +13,7 @@ export class ProvidersExtractor extends TerraformProviderExtractor {
|
||||
extract(
|
||||
hclRoot: TerraformDefinitionFile,
|
||||
locks: ProviderLock[],
|
||||
config: ExtractConfig,
|
||||
): PackageDependency[] {
|
||||
const providerTypes = hclRoot?.provider;
|
||||
if (isNullOrUndefined(providerTypes)) {
|
||||
@@ -40,6 +41,7 @@ export class ProvidersExtractor extends TerraformProviderExtractor {
|
||||
},
|
||||
locks,
|
||||
'provider',
|
||||
config,
|
||||
);
|
||||
dependencies.push(dep);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ describe('modules/manager/terraform/extractors/terraform-block/required-provider
|
||||
const extractor = new RequiredProviderExtractor();
|
||||
|
||||
it('return empty array if no terraform block is found', () => {
|
||||
const res = extractor.extract({}, []);
|
||||
const res = extractor.extract({}, [], {});
|
||||
expect(res).toBeArrayOfSize(0);
|
||||
});
|
||||
|
||||
it('return empty array if no required_providers block is found', () => {
|
||||
const res = extractor.extract({ terraform: [{}] }, []);
|
||||
const res = extractor.extract({ terraform: [{}] }, [], {});
|
||||
expect(res).toBeArrayOfSize(0);
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('modules/manager/terraform/extractors/terraform-block/required-provider
|
||||
constraints: '1.3.0',
|
||||
},
|
||||
]),
|
||||
{},
|
||||
);
|
||||
expect(res).toEqual([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isNullOrUndefined, isString } from '@sindresorhus/is';
|
||||
import type { PackageDependency } from '../../../types.ts';
|
||||
import type { ExtractConfig, PackageDependency } from '../../../types.ts';
|
||||
import { TerraformProviderExtractor } from '../../base.ts';
|
||||
import type {
|
||||
TerraformDefinitionFile,
|
||||
@@ -15,6 +15,7 @@ export class RequiredProviderExtractor extends TerraformProviderExtractor {
|
||||
extract(
|
||||
hclRoot: TerraformDefinitionFile,
|
||||
locks: ProviderLock[],
|
||||
config: ExtractConfig,
|
||||
): PackageDependency[] {
|
||||
const terraformBlocks = hclRoot?.terraform;
|
||||
if (isNullOrUndefined(terraformBlocks)) {
|
||||
@@ -51,7 +52,12 @@ export class RequiredProviderExtractor extends TerraformProviderExtractor {
|
||||
};
|
||||
}
|
||||
dependencies.push(
|
||||
this.analyzeTerraformProvider(dep, locks, 'required_provider'),
|
||||
this.analyzeTerraformProvider(
|
||||
dep,
|
||||
locks,
|
||||
'required_provider',
|
||||
config,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isNonEmptyArray } from '@sindresorhus/is';
|
||||
import { regEx } from '../../../util/regex.ts';
|
||||
import { parseUrl } from '../../../util/url.ts';
|
||||
import { TerraformProviderDatasource } from '../../datasource/terraform-provider/index.ts';
|
||||
import { getDep } from '../dockerfile/extract.ts';
|
||||
import type { PackageDependency } from '../types.ts';
|
||||
import type { ProviderLock } from './lockfile/types.ts';
|
||||
import { extractLocks, findLockFile, readLockFile } from './lockfile/util.ts';
|
||||
@@ -49,6 +51,31 @@ export function getLockedVersion(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyOciDependency(
|
||||
dep: PackageDependency,
|
||||
source: string,
|
||||
registryAliases?: Record<string, string>,
|
||||
): void {
|
||||
const url = parseUrl(source);
|
||||
if (!url) {
|
||||
dep.skipReason = 'invalid-url';
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip optional `//subfolder` sub-path (e.g. `example.com/repo//modules/vpc`)
|
||||
const imageRef = (url.host + url.pathname).replace(regEx(/\/\/.+$/), '');
|
||||
|
||||
const parsed = getDep(imageRef, false, registryAliases);
|
||||
dep.packageName = parsed.packageName;
|
||||
dep.datasource = parsed.datasource;
|
||||
dep.currentValue = url.searchParams.get('tag') ?? undefined;
|
||||
dep.currentDigest = url.searchParams.get('digest') ?? undefined;
|
||||
|
||||
if (!dep.currentValue && !dep.currentDigest) {
|
||||
dep.skipReason = 'unspecified-version';
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractLocksForPackageFile(
|
||||
fileName: string,
|
||||
): Promise<ProviderLock[]> {
|
||||
|
||||
Reference in New Issue
Block a user