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:
Norman
2026-04-29 12:57:45 +02:00
committed by GitHub
parent 0a9053b13c
commit 4e1553583c
9 changed files with 201 additions and 12 deletions
+12 -1
View File
@@ -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,
),
);
}
}
+27
View File
@@ -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[]> {