diff --git a/lib/modules/manager/terraform/base.ts b/lib/modules/manager/terraform/base.ts index f07de10a28..3f09800c3f 100644 --- a/lib/modules/manager/terraform/base.ts +++ b/lib/modules/manager/terraform/base.ts @@ -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'; diff --git a/lib/modules/manager/terraform/extract.spec.ts b/lib/modules/manager/terraform/extract.spec.ts index 37b985f384..1b26f956be 100644 --- a/lib/modules/manager/terraform/extract.spec.ts +++ b/lib/modules/manager/terraform/extract.spec.ts @@ -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); diff --git a/lib/modules/manager/terraform/extractors/others/modules.spec.ts b/lib/modules/manager/terraform/extractors/others/modules.spec.ts index eb1d3c5ac6..dcf4a8b600 100644 --- a/lib/modules/manager/terraform/extractors/others/modules.spec.ts +++ b/lib/modules/manager/terraform/extractors/others/modules.spec.ts @@ -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); }); diff --git a/lib/modules/manager/terraform/extractors/others/modules.ts b/lib/modules/manager/terraform/extractors/others/modules.ts index eeeb699bce..0a15b9360b 100644 --- a/lib/modules/manager/terraform/extractors/others/modules.ts +++ b/lib/modules/manager/terraform/extractors/others/modules.ts @@ -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([/:])(?[^/]+\/[a-z0-9-_.]+).*\?(depth=\d+&)?ref=(?.*?)(&depth=\d+)?$/i, @@ -21,6 +24,7 @@ export const gitTagsRefMatchRegex = regEx( export const azureDevOpsSshRefMatchRegex = regEx( /(?:git::)?(?git@ssh\.dev\.azure\.com:v3\/(?[^/]*)\/(?[^/]*)\/(?[^/]*))(?.*)?\?(depth=\d+&)?ref=(?.*?)(&depth=\d+)?$/, ); + export const hostnameMatchRegex = regEx( /^(?[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); diff --git a/lib/modules/manager/terraform/extractors/others/providers.spec.ts b/lib/modules/manager/terraform/extractors/others/providers.spec.ts index b2929a13b3..b3d83490e6 100644 --- a/lib/modules/manager/terraform/extractors/others/providers.spec.ts +++ b/lib/modules/manager/terraform/extractors/others/providers.spec.ts @@ -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); }); }); diff --git a/lib/modules/manager/terraform/extractors/others/providers.ts b/lib/modules/manager/terraform/extractors/others/providers.ts index 4f09173074..ccc2793918 100644 --- a/lib/modules/manager/terraform/extractors/others/providers.ts +++ b/lib/modules/manager/terraform/extractors/others/providers.ts @@ -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); } diff --git a/lib/modules/manager/terraform/extractors/terraform-block/required-provider.spec.ts b/lib/modules/manager/terraform/extractors/terraform-block/required-provider.spec.ts index 6f17986c90..c09905c8b1 100644 --- a/lib/modules/manager/terraform/extractors/terraform-block/required-provider.spec.ts +++ b/lib/modules/manager/terraform/extractors/terraform-block/required-provider.spec.ts @@ -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([ { diff --git a/lib/modules/manager/terraform/extractors/terraform-block/required-provider.ts b/lib/modules/manager/terraform/extractors/terraform-block/required-provider.ts index 5db3a21380..adf4f90939 100644 --- a/lib/modules/manager/terraform/extractors/terraform-block/required-provider.ts +++ b/lib/modules/manager/terraform/extractors/terraform-block/required-provider.ts @@ -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, + ), ); } } diff --git a/lib/modules/manager/terraform/util.ts b/lib/modules/manager/terraform/util.ts index 201a689479..56cd444e61 100644 --- a/lib/modules/manager/terraform/util.ts +++ b/lib/modules/manager/terraform/util.ts @@ -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, +): 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 {