From 57c6a9915911025a7c476d51375f6fd7ff1cb12d Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez Date: Thu, 19 Feb 2026 09:39:38 -0500 Subject: [PATCH 1/5] Support name and provider when display identifier unconventional --- CHANGELOG.md | 3 + .../provider/resource_tfe_registry_module.go | 55 +++++++++++++++- .../resource_tfe_registry_module_test.go | 64 +++++++++++++++++-- website/docs/r/registry_module.html.markdown | 34 +++++++++- 4 files changed, 150 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0415b63..ba35ba8a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ BUG FIXES: * `r/tfe_stack`: Fixed a bug where omitting the `speculative_enabled` attribute caused plans to fail. The attribute is optional and defaults to `false` if not provided. By @arunatibm [#1972](https://github.com/hashicorp/terraform-provider-tfe/pull/1972) +ENHANCEMENTS: +* `r/tfe_registry_module`: Adds support for `name` and `module_provider` alongside `vcs_repo` with `source_directory`, by @jillirami [#1959](https://github.com/hashicorp/terraform-provider-tfe/pull/1959) + ## v0.74.0 FEATURES: diff --git a/internal/provider/resource_tfe_registry_module.go b/internal/provider/resource_tfe_registry_module.go index 01c449b25..a8fc3cada 100644 --- a/internal/provider/resource_tfe_registry_module.go +++ b/internal/provider/resource_tfe_registry_module.go @@ -34,6 +34,9 @@ func resourceTFERegistryModule() *schema.Resource { }, CustomizeDiff: func(c context.Context, d *schema.ResourceDiff, meta interface{}) error { + if err := validateNameAndProvider(d); err != nil { + return err + } if err := validateVcsRepo(d); err != nil { return err } @@ -87,7 +90,6 @@ func resourceTFERegistryModule() *schema.Resource { Optional: true, Computed: true, ForceNew: true, - ExactlyOneOf: []string{"vcs_repo"}, RequiredWith: []string{"organization", "name"}, }, "name": { @@ -230,6 +232,14 @@ func resourceTFERegistryModuleCreateWithVCS(v interface{}, meta interface{}, d * log.Printf("[WARN] Error getting organization name: %s", err) } + if name, ok := d.GetOk("name"); ok { + options.Name = tfe.String(name.(string)) + } + + if provider, ok := d.GetOk("module_provider"); ok { + options.Provider = tfe.String(provider.(string)) + } + options.VCSRepo = &tfe.RegistryModuleVCSRepoOptions{ Identifier: tfe.String(vcsRepo["identifier"].(string)), GHAInstallationID: tfe.String(vcsRepo["github_app_installation_id"].(string)), @@ -567,6 +577,49 @@ func resourceTFERegistryModuleDelete(d *schema.ResourceData, meta interface{}) e return nil } +func validateNameAndProvider(d *schema.ResourceDiff) error { + configMap := d.GetRawConfig().AsValueMap() + nameValue, hasName := configMap["name"] + providerValue, hasProvider := configMap["module_provider"] + vcsRepoValue := configMap["vcs_repo"] + + nameProvided := hasName && !nameValue.IsNull() + providerProvided := hasProvider && !providerValue.IsNull() + if vcsRepoValue.LengthInt() == 0 { + return nil + } + + vcsRepoBlock := vcsRepoValue.AsValueSlice()[0] + sourceDirectory := vcsRepoBlock.GetAttr("source_directory") + if sourceDirectory.IsNull() || (sourceDirectory.IsKnown() && sourceDirectory.AsString() == "") { + return nil + } + + // Check if identifier follows terraform-- convention + displayIdentifier := vcsRepoBlock.GetAttr("display_identifier") + if displayIdentifier.IsNull() || !displayIdentifier.IsKnown() { + return nil + } + // Extract repo name from "org/repo" format + repoName := displayIdentifier.AsString() + if idx := strings.LastIndex(repoName, "/"); idx >= 0 { + repoName = repoName[idx+1:] + } + + nameParts := strings.Split(repoName, "-") + followsConvention := len(nameParts) == 3 + // Standard repos: neither name nor provider is required + if followsConvention { + return nil + } + // Non-standard repos: requires both name and provider fields + if !followsConvention && (!nameProvided || !providerProvided) { + return fmt.Errorf("name and module_provider are required when the repository name does not follow the terraform-- convention") + } + + return nil +} + func resourceTFERegistryModuleImporter(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { // First we'll check for an identity identity, err := d.Identity() diff --git a/internal/provider/resource_tfe_registry_module_test.go b/internal/provider/resource_tfe_registry_module_test.go index e652a5232..7bf6e6586 100644 --- a/internal/provider/resource_tfe_registry_module_test.go +++ b/internal/provider/resource_tfe_registry_module_test.go @@ -402,6 +402,32 @@ func TestAccTFERegistryModule_vcsRepoWithTagPrefixMonorepo(t *testing.T) { }) } +// TestAccTFERegistryModule_monorepoNonStandardName tests using source_directory with +// a repository that doesn't follow the terraform-- naming convention. +func TestAccTFERegistryModule_monorepoNonStandardName(t *testing.T) { + skipUnlessBeta(t) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheckTFERegistryModule(t) + }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFERegistryModule_monorepoNonStandardName(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("tfe_registry_module.foobar", "name", "nestedA"), + resource.TestCheckResourceAttr("tfe_registry_module.foobar", "module_provider", "aws"), + resource.TestCheckResourceAttr("tfe_registry_module.foobar", "vcs_repo.0.source_directory", "modules/nestedA"), + resource.TestCheckResourceAttr("tfe_registry_module.foobar", "vcs_repo.0.branch", "main"), + resource.TestCheckResourceAttr("tfe_registry_module.foobar", "vcs_repo.0.tags", "false"), + ), + }, + }, + }) +} + func TestAccTFERegistryModule_noCodeModule(t *testing.T) { skipIfEnterprise(t) @@ -933,7 +959,7 @@ func TestAccTFERegistryModuleImport_publicRM(t *testing.T) { }) } -func TestAccTFERegistryModule_invalidWithBothVCSRepoAndModuleProvider(t *testing.T) { +func TestAccTFERegistryModule_validWithBothVCSRepoAndModuleProvider(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t) @@ -941,8 +967,8 @@ func TestAccTFERegistryModule_invalidWithBothVCSRepoAndModuleProvider(t *testing ProtoV6ProviderFactories: testAccMuxedProviders, Steps: []resource.TestStep{ { - Config: testAccTFERegistryModule_invalidWithBothVCSRepoAndModuleProvider(), - ExpectError: regexp.MustCompile("\"module_provider\": only one of `module_provider,vcs_repo` can be specified,\nbut `module_provider,vcs_repo` were specified."), + Config: testAccTFERegistryModule_validWithBothVCSRepoAndModuleProvider(), + ExpectError: regexp.MustCompile("\"module_provider\": all of `module_provider,name,organization` must be\nspecified"), }, }, }) @@ -1783,7 +1809,7 @@ resource "tfe_registry_module" "foobar" { rInt) } -func testAccTFERegistryModule_invalidWithBothVCSRepoAndModuleProvider() string { +func testAccTFERegistryModule_validWithBothVCSRepoAndModuleProvider() string { return ` resource "tfe_registry_module" "foobar" { module_provider = "aws" @@ -2149,3 +2175,33 @@ resource "tfe_registry_module" "foobar" { } }`, rInt, envGithubToken, envGithubRegistryModuleIdentifer, envGithubRegistryModuleIdentifer) } + +func testAccTFERegistryModule_monorepoNonStandardName() string { + return fmt.Sprintf(` +resource "tfe_oauth_client" "foobar" { + organization = "hashicorp" + api_url = "https://api.github.com" + http_url = "https://github.com" + oauth_token = "%s" + service_provider = "github" +} + +resource "tfe_registry_module" "foobar" { + organization = "hashicorp" + name = "nestedA" + module_provider = "aws" + + vcs_repo { + display_identifier = "%s" + identifier = "%s" + oauth_token_id = tfe_oauth_client.foobar.oauth_token_id + branch = "main" + tags = false + source_directory = "modules/nestedA" + } +}`, + envGithubToken, + envGithubRegistryModuleIdentifer, + envGithubRegistryModuleIdentifer, + ) +} diff --git a/website/docs/r/registry_module.html.markdown b/website/docs/r/registry_module.html.markdown index dc984600b..d8b4d2049 100644 --- a/website/docs/r/registry_module.html.markdown +++ b/website/docs/r/registry_module.html.markdown @@ -129,6 +129,38 @@ resource "tfe_registry_module" "petstore" { } ``` +Create private registry module from a monorepo with source_directory (BETA): + +```hcl +resource "tfe_organization" "test-organization" { + name = "my-org-name" + email = "admin@company.com" +} + +resource "tfe_oauth_client" "test-oauth-client" { + organization = tfe_organization.test-organization.name + api_url = "https://api.github.com" + http_url = "https://github.com" + oauth_token = "my-vcs-provider-token" + service_provider = "github" +} + +resource "tfe_registry_module" "monorepo-module" { + organization = tfe_organization.test-organization.name + name = "vpc" + module_provider = "aws" + + vcs_repo { + display_identifier = "my-org-name/private-modules" + identifier = "my-org-name/private-modules" + oauth_token_id = tfe_oauth_client.test-oauth-client.oauth_token_id + source_directory = "modules/vpc" + } +} +``` + +~> **NOTE:** When using `source_directory`, you **must** explicitly specify both `name` and `module_provider`. This is required because monorepos and repositories with non-standard names (not following `terraform--` convention) cannot have these values automatically inferred by the API. + Create private registry module without VCS: ```hcl @@ -190,7 +222,7 @@ The following arguments are supported: * `vcs_repo` - (Optional) Settings for the registry module's VCS repository. Forces a new resource if changed. One of `vcs_repo` or `module_provider` is required. -* `module_provider` - (Optional) Specifies the Terraform provider that this module is used for. For example, "aws" +* `module_provider` - (Optional) Specifies the Terraform provider that this module is used for. For example, "aws". * `name` - (Optional) The name of registry module. It must be set if `module_provider` is used. * `organization` - (Optional) The name of the organization associated with the registry module. It must be set if `module_provider` is used, or if `vcs_repo` is used via a GitHub App. * `namespace` - (Optional) The namespace of a public registry module. It can be used if `module_provider` is set and `registry_name` is public. From 7b81e4ba590a4a0c51259475173bec3ed3704c78 Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez Date: Fri, 20 Feb 2026 11:58:40 -0500 Subject: [PATCH 2/5] Support name and provider when display identifier unconventional --- .../resource_tfe_registry_module_test.go | 80 ++++++++++++++++--- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/internal/provider/resource_tfe_registry_module_test.go b/internal/provider/resource_tfe_registry_module_test.go index 7bf6e6586..b8d750e65 100644 --- a/internal/provider/resource_tfe_registry_module_test.go +++ b/internal/provider/resource_tfe_registry_module_test.go @@ -406,6 +406,7 @@ func TestAccTFERegistryModule_vcsRepoWithTagPrefixMonorepo(t *testing.T) { // a repository that doesn't follow the terraform-- naming convention. func TestAccTFERegistryModule_monorepoNonStandardName(t *testing.T) { skipUnlessBeta(t) + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() resource.Test(t, resource.TestCase{ PreCheck: func() { @@ -415,7 +416,7 @@ func TestAccTFERegistryModule_monorepoNonStandardName(t *testing.T) { ProtoV6ProviderFactories: testAccMuxedProviders, Steps: []resource.TestStep{ { - Config: testAccTFERegistryModule_monorepoNonStandardName(), + Config: testAccTFERegistryModule_monorepoNonStandardName(rInt), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr("tfe_registry_module.foobar", "name", "nestedA"), resource.TestCheckResourceAttr("tfe_registry_module.foobar", "module_provider", "aws"), @@ -428,6 +429,26 @@ func TestAccTFERegistryModule_monorepoNonStandardName(t *testing.T) { }) } +func TestAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(t *testing.T) { + skipUnlessBeta(t) + rInt := rand.New(rand.NewSource(time.Now().UnixNano())).Int() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheckTFERegistryModule(t) + }, + ProtoV6ProviderFactories: testAccMuxedProviders, + Steps: []resource.TestStep{ + { + Config: testAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(rInt), + ExpectError: regexp.MustCompile(`name and module_provider are required when the repository name does not follow the terraform-- convention`), + }, + }, + }) +} + + func TestAccTFERegistryModule_noCodeModule(t *testing.T) { skipIfEnterprise(t) @@ -2168,18 +2189,23 @@ resource "tfe_registry_module" "foobar" { initial_version = "1.0.0" - test_config { - tests_enabled = true - agent_execution_mode = "agent" - agent_pool_id = "apool-fake-id" - } + test_config { + tests_enabled = true + agent_execution_mode = "agent" + agent_pool_id = "apool-fake-id" + } }`, rInt, envGithubToken, envGithubRegistryModuleIdentifer, envGithubRegistryModuleIdentifer) } -func testAccTFERegistryModule_monorepoNonStandardName() string { +func testAccTFERegistryModule_monorepoNonStandardName(rInt int) string { return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + resource "tfe_oauth_client" "foobar" { - organization = "hashicorp" + organization = tfe_organization.foobar.name api_url = "https://api.github.com" http_url = "https://github.com" oauth_token = "%s" @@ -2187,7 +2213,8 @@ resource "tfe_oauth_client" "foobar" { } resource "tfe_registry_module" "foobar" { - organization = "hashicorp" + organization = tfe_organization.foobar.name + name = "nestedA" module_provider = "aws" @@ -2200,6 +2227,41 @@ resource "tfe_registry_module" "foobar" { source_directory = "modules/nestedA" } }`, + rInt, + envGithubToken, + envGithubRegistryModuleIdentifer, + envGithubRegistryModuleIdentifer, + ) +} + +func testAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(rInt int) string { + return fmt.Sprintf(` +resource "tfe_organization" "foobar" { + name = "tst-terraform-%d" + email = "admin@company.com" +} + +resource "tfe_oauth_client" "foobar" { + organization = tfe_organization.foobar.name + api_url = "https://api.github.com" + http_url = "https://github.com" + oauth_token = "%s" + service_provider = "github" +} + +resource "tfe_registry_module" "foobar" { + organization = tfe_organization.foobar.name + + vcs_repo { + display_identifier = "%s" + identifier = "%s" + oauth_token_id = tfe_oauth_client.foobar.oauth_token_id + branch = "main" + tags = false + source_directory = "modules/nestedA" + } +}`, + rInt, envGithubToken, envGithubRegistryModuleIdentifer, envGithubRegistryModuleIdentifer, From 4be3a30a13c43cd2b7a1dfad3322cfc8512b558d Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez <36245782+jillirami@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:00:10 -0500 Subject: [PATCH 3/5] Update internal/provider/resource_tfe_registry_module.go Co-authored-by: Brandon Croft --- internal/provider/resource_tfe_registry_module.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/resource_tfe_registry_module.go b/internal/provider/resource_tfe_registry_module.go index a8fc3cada..4e559f381 100644 --- a/internal/provider/resource_tfe_registry_module.go +++ b/internal/provider/resource_tfe_registry_module.go @@ -606,7 +606,7 @@ func validateNameAndProvider(d *schema.ResourceDiff) error { repoName = repoName[idx+1:] } - nameParts := strings.Split(repoName, "-") + nameParts := strings.SplitN(repoName, "-", 3) followsConvention := len(nameParts) == 3 // Standard repos: neither name nor provider is required if followsConvention { From f089dc9b4d1616118c1f2356a691a5c319bf403c Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez Date: Fri, 20 Feb 2026 12:01:00 -0500 Subject: [PATCH 4/5] correct formatting --- internal/provider/resource_tfe_registry_module_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/provider/resource_tfe_registry_module_test.go b/internal/provider/resource_tfe_registry_module_test.go index b8d750e65..d8c09a94c 100644 --- a/internal/provider/resource_tfe_registry_module_test.go +++ b/internal/provider/resource_tfe_registry_module_test.go @@ -441,14 +441,13 @@ func TestAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(t *t ProtoV6ProviderFactories: testAccMuxedProviders, Steps: []resource.TestStep{ { - Config: testAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(rInt), + Config: testAccTFERegistryModule_monorepoNonStandardNameWithoutNameandProvider(rInt), ExpectError: regexp.MustCompile(`name and module_provider are required when the repository name does not follow the terraform-- convention`), }, }, }) } - func TestAccTFERegistryModule_noCodeModule(t *testing.T) { skipIfEnterprise(t) From af38c9e442e9f6cf039d6c164378af89c40496db Mon Sep 17 00:00:00 2001 From: Jillianne Ramirez Date: Mon, 23 Feb 2026 16:42:03 -0500 Subject: [PATCH 5/5] update changelog --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba35ba8a3..f84d90b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,6 @@ BUG FIXES: * `r/tfe_stack`: Fixed a bug where omitting the `speculative_enabled` attribute caused plans to fail. The attribute is optional and defaults to `false` if not provided. By @arunatibm [#1972](https://github.com/hashicorp/terraform-provider-tfe/pull/1972) - -ENHANCEMENTS: * `r/tfe_registry_module`: Adds support for `name` and `module_provider` alongside `vcs_repo` with `source_directory`, by @jillirami [#1959](https://github.com/hashicorp/terraform-provider-tfe/pull/1959) ## v0.74.0