Repository: gruntwork-io/terragrunt

Issue:
When a provider version constraint uses `=`, the terragrunt provider cache overwrites the provider lock with bad syntax
## Describe the bug

When a provider version constraint uses `=`, the terragrunt provider cache overwrites the provider lock in `.terraform.lock.hcl` with invalid syntax.  e.g.
```
  version     = "5.100.0"
  constraints = "= 5.100.0"
```
should be
```
  version     = "5.100.0"
  constraints = "5.100.0"
```

## Steps To Reproduce

1. An empty `terragrunt.hcl` and a file with this tf content
```hcl
terraform {
  required_version = "~> 1.0"
  required_providers {

    aws = {
      source  = "hashicorp/aws"
      version = "= 5.100.0"
    }
  }
}

data "aws_caller_identity" "c" {}
```
2.
```
❯ terragrunt init --provider-cache
INFO[0000] Terragrunt Cache server is listening on 127.0.0.1:45643
INFO[0000] Start Terragrunt Cache server
INFO[0000] Caching terraform providers for /tmp/tg

Initializing the backend...
╷
│ Error: failed to read dependency lock file: Invalid provider version constraints: The recorded version constraints for provider registry.opentofu.org/hashicorp/aws must be written in normalized form: "5.100.0".
│ 
│ 
╵

ERRO[0001] tofu invocation failed in /tmp/tg
INFO[0001] Shutting down Terragrunt Cache server...
INFO[0001] Terragrunt Cache server stopped
ERRO[0001] error occurred:

* Failed to execute "tofu init" in /tmp/tg
  ╷
  │ Error: failed to read dependency lock file: Invalid provider version constraints: The recorded version constraints for provider registry.opentofu.org/hashicorp/aws must be written in normalized form: "5.100.0".
  │ 
  │ 
  ╵
  
  
  exit status 1
```

same result with terraform, too.

## Expected behavior

Don't create an invalid provider lock file.

## Nice to haves

- [ ] Terminal output
- [ ] Screenshots

## Versions

- Terragrunt version: 0.83.2
- OpenTofu/Terraform version: tofu 1.10.3
- Environment details (Ubuntu 20.04, Windows 10, etc.):

## Additional context

https://github.com/gruntwork-io/terragrunt/pull/4514 seems to be the culprit because 0.83.0 works fine.


Hints from issue comments:

Solution patch:
diff --git a/tf/getproviders/constraints.go b/tf/getproviders/constraints.go
index 6c57331c..b692875a 100644
--- a/tf/getproviders/constraints.go
+++ b/tf/getproviders/constraints.go
@@ -178,12 +178,12 @@ func parseProvidersFromRequiredProvidersBlock(opts *options.TerragruntOptions, b
 		if source != "" && version != "" {
 			// Normalize the source address to full registry format
 			providerAddr := normalizeProviderAddress(opts, source)
-			constraints[providerAddr] = version
+			constraints[providerAddr] = normalizeVersionConstraint(version)
 		} else if source == "" && version != "" {
 			// If only version is specified, assume it's a hashicorp provider
 			registryDomain := tf.GetDefaultRegistryDomain(opts)
 			providerAddr := fmt.Sprintf("%s/hashicorp/%s", registryDomain, name)
-			constraints[providerAddr] = version
+			constraints[providerAddr] = normalizeVersionConstraint(version)
 		}
 	}
 
@@ -216,3 +216,16 @@ func normalizeProviderAddress(opts *options.TerragruntOptions, source string) st
 		return source
 	}
 }
+
+// normalizeVersionConstraint normalizes version constraints by removing the "=" prefix if present
+// OpenTofu/Terraform expects constraints in normalized form without the "=" prefix
+func normalizeVersionConstraint(constraint string) string {
+	constraint = strings.TrimSpace(constraint)
+
+	// If constraint starts with "=" followed by whitespace, remove it
+	if after, ok := strings.CutPrefix(constraint, "="); ok {
+		return strings.TrimSpace(after)
+	}
+
+	return constraint
+}



Test patch:
diff --git a/test/fixtures/provider-cache/constraint-issue/app/main.tf b/test/fixtures/provider-cache/weak-constraint/app/main.tf
similarity index 100%
rename from test/fixtures/provider-cache/constraint-issue/app/main.tf
rename to test/fixtures/provider-cache/weak-constraint/app/main.tf
diff --git a/test/fixtures/provider-cache/constraint-issue/app/terragrunt.hcl b/test/fixtures/provider-cache/weak-constraint/app/terragrunt.hcl
similarity index 100%
rename from test/fixtures/provider-cache/constraint-issue/app/terragrunt.hcl
rename to test/fixtures/provider-cache/weak-constraint/app/terragrunt.hcl
diff --git a/test/integration_provider_cache_constraint_test.go b/test/integration_provider_cache_constraint_test.go
index 694e07ae..6f09136b 100644
--- a/test/integration_provider_cache_constraint_test.go
+++ b/test/integration_provider_cache_constraint_test.go
@@ -16,20 +16,20 @@ import (
 )
 
 const (
-	testFixtureProviderCacheConstraintIssue = "fixtures/provider-cache/constraint-issue"
+	testFixtureProviderCacheWeakConstraint = "fixtures/provider-cache/weak-constraint"
 )
 
-// TestTerragruntProviderCacheConstraintIssue tests that provider cache preserves
+// TestTerragruntProviderCacheWeakConstraint tests that provider cache preserves
 // module constraints instead of pinning exact versions in .terraform.lock.hcl files.
 // Reproduces and validates the fix for GitHub issue #4512.
 //
 //nolint:paralleltest,tparallel
-func TestTerragruntProviderCacheConstraintIssue(t *testing.T) {
+func TestTerragruntProviderCacheWeakConstraint(t *testing.T) {
 	t.Parallel()
 
-	helpers.CleanupTerraformFolder(t, testFixtureProviderCacheConstraintIssue)
-	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureProviderCacheConstraintIssue)
-	rootPath := util.JoinPath(tmpEnvPath, testFixtureProviderCacheConstraintIssue)
+	helpers.CleanupTerraformFolder(t, testFixtureProviderCacheWeakConstraint)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureProviderCacheWeakConstraint)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureProviderCacheWeakConstraint)
 	appPath := filepath.Join(rootPath, "app")
 
 	providerCacheDir := t.TempDir()
diff --git a/tf/getproviders/constraints_test.go b/tf/getproviders/constraints_test.go
index 3f02dbf9..dd3022e9 100644
--- a/tf/getproviders/constraints_test.go
+++ b/tf/getproviders/constraints_test.go
@@ -206,3 +206,57 @@ terraform {
 	assert.Equal(t, "~> 5.0", constraints["registry.terraform.io/hashicorp/aws"])
 	assert.Equal(t, "~> 3.0", constraints["registry.terraform.io/hashicorp/azurerm"])
 }
+
+func TestParseProviderConstraintsWithEqualsPrefix(t *testing.T) {
+	t.Parallel()
+
+	// Create a temporary directory for testing
+	testDir := t.TempDir()
+
+	// Create a test terraform file with "=" prefix in version constraints
+	terraformContent := `
+terraform {
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "= 5.100.0"
+    }
+    cloudflare = {
+      source  = "cloudflare/cloudflare"
+      version = "= 4.40.0"
+    }
+    time = {
+      source  = "hashicorp/time"
+      version = ">= 0.10.0"
+    }
+  }
+}
+`
+
+	err := os.WriteFile(filepath.Join(testDir, "main.tf"), []byte(terraformContent), 0644)
+	require.NoError(t, err)
+
+	// Test parsing with Terraform implementation
+	terraformOpts := &options.TerragruntOptions{
+		TerraformImplementation: options.TerraformImpl,
+	}
+	constraints, err := getproviders.ParseProviderConstraints(terraformOpts, testDir)
+	require.NoError(t, err)
+
+	// Verify the parsed constraints are normalized (no "=" prefix)
+	assert.Equal(t, "5.100.0", constraints["registry.terraform.io/hashicorp/aws"])
+	assert.Equal(t, "4.40.0", constraints["registry.terraform.io/cloudflare/cloudflare"])
+	assert.Equal(t, ">= 0.10.0", constraints["registry.terraform.io/hashicorp/time"])
+
+	// Test parsing with OpenTofu implementation
+	openTofuOpts := &options.TerragruntOptions{
+		TerraformImplementation: options.OpenTofuImpl,
+	}
+	constraints, err = getproviders.ParseProviderConstraints(openTofuOpts, testDir)
+	require.NoError(t, err)
+
+	// Verify the parsed constraints are normalized with OpenTofu registry
+	assert.Equal(t, "5.100.0", constraints["registry.opentofu.org/hashicorp/aws"])
+	assert.Equal(t, "4.40.0", constraints["registry.opentofu.org/cloudflare/cloudflare"])
+	assert.Equal(t, ">= 0.10.0", constraints["registry.opentofu.org/hashicorp/time"])
+}