Repository: gruntwork-io/terragrunt (language: go)

Issue:
TFR Shorthand handling for OpenTofu
**Describe the solution you'd like**
Improve handling of shorthand TFR urls in case of OpenTofu to allow usage of OpenTofu registry.

**Describe alternatives you've considered**
N/A

**Additional context**

https://terragrunt.gruntwork.io/docs/reference/config-blocks-and-attributes/

> The tfr protocol supports a shorthand notation where the REGISTRY_HOST can be omitted to default to the public registry (registry.terraform.io) if you use tfr:/// (note the three /). For example, the following will fetch the terraform-aws-modules/vpc/aws module from the public registry: tfr:///terraform-aws-modules/vpc/aws?version=3.3.0.

https://github.com/gruntwork-io/terragrunt/blob/master/options/options.go#L526




Discussion / hints:

Fix patch:
diff --git a/cli/commands/terraform/download_source.go b/cli/commands/terraform/download_source.go
index 04f047a363..b789fc1894 100644
--- a/cli/commands/terraform/download_source.go
+++ b/cli/commands/terraform/download_source.go
@@ -181,7 +181,7 @@ func readVersionFile(terraformSource *terraform.Source) (string, error) {
 //
 // This creates a closure that returns a function so that we have access to the terragrunt configuration, which is
 // necessary for customizing the behavior of the file getter.
-func updateGetters(terragruntConfig *config.TerragruntConfig) func(*getter.Client) error {
+func updateGetters(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) func(*getter.Client) error {
 	return func(client *getter.Client) error {
 		// We copy all the default getters from the go-getter library, but replace the "file" getter. We shallow clone the
 		// getter map here rather than using getter.Getters directly because (a) we shouldn't change the original,
@@ -201,7 +201,9 @@ func updateGetters(terragruntConfig *config.TerragruntConfig) func(*getter.Clien
 		}
 
 		// Load in custom getters that are only supported in Terragrunt
-		client.Getters["tfr"] = &terraform.RegistryGetter{}
+		client.Getters["tfr"] = &terraform.RegistryGetter{
+			TerragruntOptions: terragruntOptions,
+		}
 
 		return nil
 	}
@@ -211,7 +213,7 @@ func updateGetters(terragruntConfig *config.TerragruntConfig) func(*getter.Clien
 func downloadSource(terraformSource *terraform.Source, terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) error {
 	terragruntOptions.Logger.Infof("Downloading Terraform configurations from %s into %s", terraformSource.CanonicalSourceURL, terraformSource.DownloadDir)
 
-	if err := getter.GetAny(terraformSource.DownloadDir, terraformSource.CanonicalSourceURL.String(), updateGetters(terragruntConfig)); err != nil {
+	if err := getter.GetAny(terraformSource.DownloadDir, terraformSource.CanonicalSourceURL.String(), updateGetters(terragruntOptions, terragruntConfig)); err != nil {
 		return errors.WithStackTrace(err)
 	}
 
diff --git a/docs/_docs/04_reference/config-blocks-and-attributes.md b/docs/_docs/04_reference/config-blocks-and-attributes.md
index 26612ad3df..4b80e61a7b 100644
--- a/docs/_docs/04_reference/config-blocks-and-attributes.md
+++ b/docs/_docs/04_reference/config-blocks-and-attributes.md
@@ -52,7 +52,10 @@ The `terraform` block supports the following arguments:
       you can provide the authentication to Terragrunt as an environment variable with the key `TG_TF_REGISTRY_TOKEN`.
       This token can be any registry API token.
     - The `tfr` protocol supports a shorthand notation where the `REGISTRY_HOST` can be omitted to default to the public
-      registry (`registry.terraform.io`) if you use `tfr:///` (note the three `/`). For example, the following will
+      registry. The default registry depends on the wrapped executable: for Terraform, it is `registry.terraform.io`,
+      and for Opentofu, it is `registry.opentofu.org`. Additionally, if the environment variable `TG_TF_DEFAULT_REGISTRY_HOST`
+      is set, this value will be used as the default registry host instead, overriding the standard defaults for the wrapped executable.
+    - If you use `tfr:///` (note the three `/`). For example, the following will
       fetch the `terraform-aws-modules/vpc/aws` module from the public registry:
       `tfr:///terraform-aws-modules/vpc/aws?version=3.3.0`.
     - You can also use submodules from the registry using `//`. For example, to use the `iam-policy` submodule from the
diff --git a/terraform/getter.go b/terraform/getter.go
index 7be669fdfa..35953f19a2 100644
--- a/terraform/getter.go
+++ b/terraform/getter.go
@@ -13,6 +13,8 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/gruntwork-io/terragrunt/options"
+
 	"github.com/hashicorp/go-cleanhttp"
 	"github.com/hashicorp/go-getter"
 	safetemp "github.com/hashicorp/go-safetemp"
@@ -26,10 +28,12 @@ var httpClient = cleanhttp.DefaultClient()
 
 // Constants relevant to the module registry
 const (
-	defaultRegistryDomain = "registry.terraform.io"
-	serviceDiscoveryPath  = "/.well-known/terraform.json"
-	versionQueryKey       = "version"
-	authTokenEnvVarName   = "TG_TF_REGISTRY_TOKEN"
+	defaultRegistryDomain     = "registry.terraform.io"
+	defaultOtRegistryDomain   = "registry.opentofu.org"
+	serviceDiscoveryPath      = "/.well-known/terraform.json"
+	versionQueryKey           = "version"
+	authTokenEnvVarName       = "TG_TF_REGISTRY_TOKEN"
+	defaultRegistryEnvVarName = "TG_TF_DEFAULT_REGISTRY_HOST"
 )
 
 // RegistryServicePath is a struct for extracting the modules service path in the Registry.
@@ -65,7 +69,8 @@ type RegistryServicePath struct {
 // over the file detector. We deferred the implementation for that to a future release.
 // GH issue: https://github.com/gruntwork-io/terragrunt/issues/1772
 type RegistryGetter struct {
-	client *getter.Client
+	client            *getter.Client
+	TerragruntOptions *options.TerragruntOptions
 }
 
 // SetClient allows the getter to know what getter client (different from the underlying HTTP client) to use for
@@ -82,6 +87,23 @@ func (tfrGetter *RegistryGetter) Context() context.Context {
 	return tfrGetter.client.Ctx
 }
 
+// registryDomain returns the default registry domain to use for the getter.
+func (tfrGetter *RegistryGetter) registryDomain() string {
+	if tfrGetter.TerragruntOptions == nil {
+		return defaultRegistryDomain
+	}
+
+	// if is set TG_TF_DEFAULT_REGISTRY env var, use it as default registry
+	if defaultRegistry := os.Getenv(defaultRegistryEnvVarName); defaultRegistry != "" {
+		return defaultRegistry
+	}
+	// if binary is set to use OpenTofu registry, use OpenTofu as default registry
+	if tfrGetter.TerragruntOptions.TerraformImplementation == options.OpenTofuImpl {
+		return defaultOtRegistryDomain
+	}
+	return defaultRegistryDomain
+}
+
 // ClientMode returns the download mode based on the given URL. Since this getter is designed around the Terraform
 // module registry, we always use Dir mode so that we can download the full Terraform module.
 func (tfrGetter *RegistryGetter) ClientMode(u *url.URL) (getter.ClientMode, error) {
@@ -97,7 +119,7 @@ func (tfrGetter *RegistryGetter) Get(dstPath string, srcURL *url.URL) error {
 
 	registryDomain := srcURL.Host
 	if registryDomain == "" {
-		registryDomain = defaultRegistryDomain
+		registryDomain = tfrGetter.registryDomain()
 	}
 	queryValues := srcURL.Query()
 	modulePath, moduleSubDir := getter.SourceDirSubdir(srcURL.Path)
@@ -234,18 +256,34 @@ func getModuleRegistryURLBasePath(ctx context.Context, domain string) (string, e
 	return respJSON.ModulesPath, nil
 }
 
-// getTerraformGetHeader makes an http GET call to the given registry URL and return the contents of the header
-// X-Terraform-Get. This function will return an error if the response does not contain the header.
+// getTerraformGetHeader makes an http GET call to the given registry URL and return the contents of location json
+// body or the header X-Terraform-Get. This function will return an error if the response does not contain the header.
 func getTerraformGetHeader(ctx context.Context, url url.URL) (string, error) {
-	_, header, err := httpGETAndGetResponse(ctx, url)
+	body, header, err := httpGETAndGetResponse(ctx, url)
 	if err != nil {
 		details := "error receiving HTTP data"
 		return "", errors.WithStackTrace(ModuleDownloadErr{sourceURL: url.String(), details: details})
 	}
 
 	terraformGet := header.Get("X-Terraform-Get")
+	if terraformGet != "" {
+		return terraformGet, nil
+	}
+
+	// parse response from body as json
+	var responseJSON map[string]string
+	if err := json.Unmarshal(body, &responseJSON); err != nil {
+		reason := fmt.Sprintf("Error parsing response body %s: %s", string(body), err)
+		return "", errors.WithStackTrace(ModuleDownloadErr{sourceURL: url.String(), details: reason})
+	}
+	// get location value from responseJSON
+	terraformGet = responseJSON["location"]
+	if terraformGet != "" {
+		return terraformGet, nil
+	}
+
 	if terraformGet == "" {
-		details := "no source URL was returned in header X-Terraform-Get from download URL"
+		details := "no source URL was returned in header X-Terraform-Get and in location response from download URL"
 		return "", errors.WithStackTrace(ModuleDownloadErr{sourceURL: url.String(), details: details})
 	}
 	return terraformGet, nil


Test patch:
diff --git a/test/fixture-tfr/root-shorthand/terragrunt.hcl b/test/fixture-tfr/root-shorthand/terragrunt.hcl
index a01311208b..e8a86a58a5 100644
--- a/test/fixture-tfr/root-shorthand/terragrunt.hcl
+++ b/test/fixture-tfr/root-shorthand/terragrunt.hcl
@@ -1,4 +1,4 @@
-# Retrieve a module from the public terraform registry to use with terragrunt
+# Retrieve a module from the public Hashicorp/OpenTofu registry to use with terragrunt
 terraform {
-  source = "tfr:///yorinasub17/terragrunt-registry-test/null?version=0.0.2"
+  source = "tfr:///gruntwork-io/terragrunt-registry-test/null?version=0.0.2"
 }
diff --git a/test/integration_registry_test.go b/test/integration_registry_test.go
index b11598de6a..654e9ae662 100644
--- a/test/integration_registry_test.go
+++ b/test/integration_registry_test.go
@@ -24,7 +24,7 @@ func TestTerraformRegistryFetchingRootModule(t *testing.T) {
 	testTerraformRegistryFetching(t, registryFixtureRootModulePath, "root_null_resource")
 }
 
-func TestTerraformRegistryFetchingRootShorthandModule(t *testing.T) {
+func TestRegistryFetchingRootShorthandModule(t *testing.T) {
 	t.Parallel()
 	testTerraformRegistryFetching(t, registryFixtureRootShorthandModulePath, "root_null_resource")
 }