Repository: gruntwork-io/terragrunt

Issue:
Terraform Registry Support: Add support for token configuration that terraform supports.
The initial version of Terraform Registry support introduced an environment variable for providing API tokens to terragrunt, but this [is not how terraform authenticates](https://www.terraform.io/docs/cloud/registry/using.html#on-the-command-line). We should update the feature to support parsing the terraform authentication configuration.

Note the following caveat from the original PR:

> Ideally we would be able to reuse code from Terraform. However, terraform has moved to packaging all its libraries under internal so that you can't use them as a library outside of Terraform. To respect the direction and spirit of the Terraform team, I opted for not doing anything funky to workaround the limitation (like copying those files in here). 

Hints from issue comments:
@begemotik: Would be nice to get it implemented.@k2m30: Gentlemen, any updates?@pasinski: Upvoting it too.@luis-guimaraes-exoawk: Any news?@shaheerkhan12605: Any updates ?
@yhakbar: Hey folks! 

Your feedback has been heard, and we're going to actively look into what we can do to support this.@maciob: Hey, any updates on this feature? It's really highly requested by community.@VincentLim: Any update ? It's blocking if you have to work with multiple private registries@github-actions: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for raising this issue.@erpel: I think it's still a valid goal to have.@Kostadin-Lazarov: Any update on this? I'm surprised it hasn't been resolved after all this time.@yhakbar: Released in [v0.81.5](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.81.5).

Solution patch:
diff --git a/tf/getter.go b/tf/getter.go
index 91c98ae7..b92cc269 100644
--- a/tf/getter.go
+++ b/tf/getter.go
@@ -17,8 +17,10 @@ import (
 	"github.com/hashicorp/go-cleanhttp"
 	"github.com/hashicorp/go-getter"
 	safetemp "github.com/hashicorp/go-safetemp"
+	svchost "github.com/hashicorp/terraform-svchost"
 
 	"github.com/gruntwork-io/terragrunt/internal/errors"
+	"github.com/gruntwork-io/terragrunt/tf/cliconfig"
 	"github.com/gruntwork-io/terragrunt/util"
 )
 
@@ -326,6 +328,25 @@ func GetDownloadURLFromHeader(moduleURL url.URL, terraformGet string) (string, e
 	return terraformGet, nil
 }
 
+func applyHostToken(req *http.Request) (*http.Request, error) {
+	cliCfg, err := cliconfig.LoadUserConfig()
+	if err != nil {
+		return nil, err
+	}
+
+	if creds := cliCfg.CredentialsSource().ForHost(svchost.Hostname(req.URL.Hostname())); creds != nil {
+		creds.PrepareRequest(req)
+	} else {
+		// fall back to the TG_TF_REGISTRY_TOKEN
+		authToken := os.Getenv(authTokenEnvName)
+		if authToken != "" {
+			req.Header.Add("Authorization", "Bearer "+authToken)
+		}
+	}
+
+	return req, nil
+}
+
 // httpGETAndGetResponse is a helper function to make a GET request to the given URL using the http client. This
 // function will then read the response and return the contents + the response header.
 func httpGETAndGetResponse(ctx context.Context, logger log.Logger, getURL url.URL) ([]byte, *http.Header, error) {
@@ -336,9 +357,9 @@ func httpGETAndGetResponse(ctx context.Context, logger log.Logger, getURL url.UR
 
 	// Handle authentication via env var. Authentication is done by providing the registry token as a bearer token in
 	// the request header.
-	authToken := os.Getenv(authTokenEnvName)
-	if authToken != "" {
-		req.Header.Add("Authorization", "Bearer "+authToken)
+	req, err = applyHostToken(req)
+	if err != nil {
+		return nil, nil, errors.New(err)
 	}
 
 	resp, err := httpClient.Do(req)



Test patch:
diff --git a/test/fixtures/private-registry/env.tfrc b/test/fixtures/private-registry/env.tfrc
new file mode 100644
index 00000000..36354450
--- /dev/null
+++ b/test/fixtures/private-registry/env.tfrc
@@ -0,0 +1,3 @@
+credentials "__registry_host__" {
+    token = "__registry_token__"
+}
\ No newline at end of file
diff --git a/test/fixtures/private-registry/terragrunt.hcl b/test/fixtures/private-registry/terragrunt.hcl
new file mode 100644
index 00000000..3766b526
--- /dev/null
+++ b/test/fixtures/private-registry/terragrunt.hcl
@@ -0,0 +1,4 @@
+# Retrieve a module from the public terraform registry to use with terragrunt
+terraform {
+  source = "tfr://__registry_url__"
+}
diff --git a/test/integration_private_registry_test.go b/test/integration_private_registry_test.go
new file mode 100644
index 00000000..75543b1a
--- /dev/null
+++ b/test/integration_private_registry_test.go
@@ -0,0 +1,83 @@
+//go:build private_registry
+
+package test_test
+
+import (
+	"fmt"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/gruntwork-io/terragrunt/test/helpers"
+	"github.com/gruntwork-io/terragrunt/util"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	privateRegistryFixturePath = "fixtures/private-registry"
+)
+
+func setupPrivateRegistryTest(t *testing.T) (string, string, string) {
+	t.Helper()
+
+	registryToken := os.Getenv("PRIVATE_REGISTRY_TOKEN")
+
+	// the private registry test is recommended to be a clone of gruntwork-io/terraform-null-terragrunt-registry-test
+	registryUrl := os.Getenv("PRIVATE_REGISTRY_URL")
+
+	if registryToken == "" || registryUrl == "" {
+		t.Skip("Skipping test because it requires a valid Terraform registry token and url")
+	}
+
+	helpers.CleanupTerraformFolder(t, privateRegistryFixturePath)
+	tmpEnvPath := helpers.CopyEnvironment(t, privateRegistryFixturePath)
+	rootPath := util.JoinPath(tmpEnvPath, privateRegistryFixturePath)
+
+	URL, err := url.Parse("tfr://" + registryUrl)
+	if err != nil {
+		t.Fatalf("REGISTRY_URL is invalid: %v", err)
+	}
+
+	if URL.Hostname() == "" {
+		t.Fatal("REGISTRY_URL is invalid")
+	}
+
+	helpers.CopyAndFillMapPlaceholders(t, util.JoinPath(privateRegistryFixturePath, "terragrunt.hcl"), util.JoinPath(rootPath, "terragrunt.hcl"), map[string]string{
+		"__registry_url__": registryUrl,
+	})
+
+	return rootPath, URL.Hostname(), registryToken
+}
+
+func TestPrivateRegistryWithConfgFileToken(t *testing.T) {
+	rootPath, host, token := setupPrivateRegistryTest(t)
+
+	helpers.CopyAndFillMapPlaceholders(t, util.JoinPath(privateRegistryFixturePath, "env.tfrc"), util.JoinPath(rootPath, "env.tfrc"), map[string]string{
+		"__registry_token__": token,
+		"__registry_host__":  host,
+	})
+
+	t.Setenv("TF_CLI_CONFIG_FILE", util.JoinPath(rootPath, "env.tfrc"))
+
+	_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --non-interactive --log-level=trace --working-dir="+rootPath)
+
+	// the hashicorp/null provider errors on install, but that indicates that the private tfr module was downloaded
+	require.Contains(t, err.Error(), "hashicorp/null", "Error accessing the private registry")
+}
+
+func TestPrivateRegistryWithEnvToken(t *testing.T) {
+	rootPath, host, token := setupPrivateRegistryTest(t)
+
+	// Convert host to format suitable for Terraform env vars.
+	// This is based on the tf/cliconfig/credentials.go collectCredentialsFromEnv
+	host = strings.ReplaceAll(strings.ReplaceAll(host, ".", "_"), "-", "__")
+
+	t.Setenv(fmt.Sprintf("TF_TOKEN_%s", host), token)
+
+	_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt init --non-interactive --log-level=trace --working-dir="+rootPath)
+
+	// The main test is for authentication against the private registry, so if the null provider fails then we know
+	// that terragrunt authenticated and downloaded the module.
+	require.Contains(t, err.Error(), "hashicorp/null", "Error accessing the private registry")
+}