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

Issue:
Add OpenTofu support
**Describe the solution you'd like**
[As recently announced](https://opentofu.org/blog/the-opentofu-fork-is-now-available), the OpenTofu fork should drop soon. We should make sure that Terragrunt supports it out of the box. 

Here's an idea for an initial implementation: anywhere Terragrunt calls out to the `terraform` binary, we should update it to check for the `terraform` binary first; if that's not found, then try the `tofu` binary; and if that's not found, show an error (as we do today). Whatever we pick, we should mention in the debug log.

**Describe alternatives you've considered**
In theory, you can already use OpenTofu by using the [`terraform_binary`  config](https://terragrunt.gruntwork.io/docs/reference/config-blocks-and-attributes/#terraform_binary) or `--terragrunt-tfpath` CLI flag or `TERRAGRUNT_TFPATH` env var, but those all require extra steps, whereas I'd like OpenTofu to "just work" with Terragrunt. 

**Additional context**
OpenTofu should work as a "drop in" replacement for Terraform, so in theory, no other changes should be necessary. However, we should run our tests to make sure once the `tofu` binary is available for testing. One place where OpenTofu will differ is `stdout` and `stderr` output will say OpenTofu instead of Terraform; if Terragrunt is parsing that log output anywhere, we'll need to update the code accordingly.

This is just the initial implementation in the sense that it should be backward compatible. In the future, we can reverse the lookup order, and perhaps even drop support for `terraform`, but those would be significant breaking changes (especially the latter), so we'd want to see how things go with OpenTofu and what the community wants first.

**Note on registry usage**
HashiCorp recently did an update (one that was totally unannounced, BTW) to its [Registry terms](https://registry.terraform.io/terms) to say:

> You may download providers, modules, policy libraries and/or other Services or Content from this website solely for use with, or in support of, HashiCorp Terraform.

Terragrunt uses go-getter to download modules specified in the `source` URL, and it's possible that URL points to the Terraform Registry. For now, Terragrunt only works with Terraform, so that is still inline with the terms. If we add support for OpenTofu, then in theory, anyone using Terragrunt with OpenTofu might be violating the registry terms.

Possible solutions:

1. Cheapest/fastest: If we detect a registry `source` URL and `tofu` as the binary, give the user a warning.
2. Better solution: If we detect a registry `source` URL and `tofu` as the binary, automatically use OpenTofu's registry (coming soon) instead of HashiCorp's.


Discussion / hints:
How about modifying the `tfpath` argument to support a list (presuming it doesn't support that already), and just adding `opentf` to the list?

---
Fwiw, personally and for my own teams, I'm likely to use an alias, or the `terraform_binary` config argument, or both!
From a technical perspective, I expect that it will be a no-op for the time being as OpenTF anticipates maintaining compat/interop for the foreseeable future. (However, OpenTF has stated that _some_ functionality may be added to OpenTF that is a _superset_ of what's in base Terraform. [[source](https://github.com/opentffoundation/roadmap/issues/24)])

From a process perspective:

1. If there is a bug in OpenTF that does not exist in Terraform (or vice-versa), will bug reports (as they pertain to this project) be treated equally on both platforms?

1. Will testing be performed with code targeting both platforms?

1. For OpenTF's potential future "superset" functionality, will that be treated as a first-class citizen?

Came across this comment last night.

https://www.linkedin.com/feed/update/urn:li:ugcPost:7102220371797389313?commentUrn=urn%3Ali%3Acomment%3A%28ugcPost%3A7102220371797389313%2C7102263805740503040%29&replyUrn=urn%3Ali%3Acomment%3A%28ugcPost%3A7102220371797389313%2C7102264759957213184%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287102263805740503040%2Curn%3Ali%3AugcPost%3A7102220371797389313%29&dashReplyUrn=urn%3Ali%3Afsd_comment%3A%287102264759957213184%2Curn%3Ali%3AugcPost%3A7102220371797389313%29
I just updated the issue description with:


**Note on registry usage**
HashiCorp recently did an update (one that was totally unannounced, BTW) to its [Registry terms](https://registry.terraform.io/terms) to say:

> You may download providers, modules, policy libraries and/or other Services or Content from this website solely for use with, or in support of, HashiCorp Terraform.

Terragrunt uses go-getter to download modules specified in the `source` URL, and it's possible that URL points to the Terraform Registry. For now, Terragrunt only works with Terraform, so that is still inline with the terms. If we add support for OpenTF, then in theory, anyone using Terragrunt with OpenTF might be violating the registry terms.

Possible solutions:

1. Cheapest/fastest: If we detect a registry `source` URL and `opentf` as the binary, give the user a warning.
2. Better solution: If we detect a registry `source` URL and `opentf` as the binary, automatically use OpenTF's registry (coming soon) instead of HashiCorp's.
> From a technical perspective, I expect that it will be a no-op for the time being as OpenTF anticipates maintaining compat/interop for the foreseeable future. (However, OpenTF has stated that _some_ functionality may be added to OpenTF that is a _superset_ of what's in base Terraform. [[source](https://github.com/opentffoundation/roadmap/issues/24)])
> 
> From a process perspective:
> 
> 1. If there is a bug in OpenTF that does not exist in Terraform (or vice-versa), will bug reports (as they pertain to this project) be treated equally on both platforms?

Yup. Until otherwise announced, we should support both platforms equally.

> 2. Will testing be performed with code targeting both platforms?

Yes, we should test both platforms. 

> 3. For OpenTF's potential future "superset" functionality, will that be treated as a first-class citizen?

Probably, but hard to say until we know what that functionality is. I think we'll have to decide on a case by case basis, with input from the community.
Your response is greatly appreciated.

This whole situation has created uncertainty at a time when we could really do without it. My company leverages several open-source and SaaS offerings in the Terraform ecosystem, and I've been reaching out across the ecosystem to understand where everyone is on this topic, and identifying what we need to watch out for.

Hoping for the very best from this whole situation, but planning ahead for the absolute worst.
I've been using the unofficial dev snap [OpenTF v1.6.0-dev](https://github.com/jnsgruk/opentf-snap), but even with `--terragrunt-tfpath` set to `/snap/bin/opentf` I get an error: `Unable to parse Terraform version output: OpenTF v1.6.0-dev on linux_amd64`. I'd say parsing that was *bound* to be brittle :-P

Fix patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index b3bd27b59d..e6d454cf90 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -13,6 +13,32 @@ defaults: &defaults
   docker:
     - image: 087285199408.dkr.ecr.us-east-1.amazonaws.com/circle-ci-test-image-base:go1.21-tf1.5-tg39.1-pck1.8-ci50.7
 
+install_tofu: &install_tofu
+  name: Install OpenTofu
+  command: |
+    pushd .
+    cd /tmp
+    curl -L "https://github.com/opentofu/opentofu/releases/download/v1.6.0-alpha1/tofu_1.6.0-alpha1_linux_amd64.zip" -o tofu.zip
+    unzip -o tofu.zip
+    sudo install -m 0755 tofu /usr/local/bin/tofu
+    rm -rf tofu
+    rm -rf tofu.zip
+    popd
+    tofu --version
+
+install_tflint: &install_tflint
+  name: Install Tflint
+  command: |
+    pushd .
+    cd /tmp
+    curl -L "https://github.com/terraform-linters/tflint/releases/download/v0.47.0/tflint_linux_amd64.zip" -o tflint.zip
+    unzip -o tflint.zip
+    sudo install -m 0755 tflint /usr/local/bin/tflint
+    rm -rf tflint
+    rm -rf tflint.zip
+    popd
+    tflint --version
+
 version: 2.1
 jobs:
   test_windows:
@@ -29,12 +55,16 @@ jobs:
           name: Install Terraform
           shell: powershell.exe
           command: ./_ci/install-terraform.ps1
+      - run:
+          name: Install Opentofu
+          shell: powershell.exe
+          command: ./_ci/install-opentofu.ps1
       - run:
           name: Install TFLint
           shell: powershell.exe
           command: ./_ci/install-tflint.ps1
       - run:
-          name: Run go tests
+          name: Run go terraform tests
           shell: powershell.exe
           no_output_timeout: 45m
           command: |
@@ -42,6 +72,17 @@ jobs:
             # means of reproducing Terragrunt issues that only occur on that platform
             go test -v ./... -run TestWindowsTerragruntSourceMapDebug -timeout 45m
             go test -v ./... -run TestWindowsTflintIsInvoked -timeout 45m
+      - run:
+          name: Run go tofu tests
+          shell: powershell.exe
+          no_output_timeout: 45m
+          environment:
+            TERRAGRUNT_TFPATH: tofu
+          command: |
+            # We're running this test only on Windows currently to provide a convenient
+            # means of reproducing Terragrunt issues that only occur on that platform
+            go test -v ./... -run TestWindowsTerragruntSourceMapDebug -timeout 45m
+            go test -v ./... -run TestWindowsTflintIsInvoked -timeout 45m
   # We're running unit tests separately from integration tests - with no parallelization.
   # With heavy parallelization coupled with re-use of test fixtures we've witnessed slight
   # instability with the tests. The unit tests are fast to execute, so there is negligible
@@ -74,24 +115,44 @@ jobs:
           path: logs
       - store_test_results:
           path: logs
-  integration_test:
+
+  integration_test_terraform:
     resource_class: large
     <<: *defaults
     steps:
       - checkout
       - run: gruntwork-install --binary-name 'terratest_log_parser' --repo 'https://github.com/gruntwork-io/terratest' --tag 'v0.30.0'
-      # install tflint
+      - run:
+          <<: *install_tflint
+
+      # Make GCP Service Account creds available as a file
+      - run: echo $GCLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json
+      - run: echo 'export GOOGLE_APPLICATION_CREDENTIALS=${HOME}/gcloud-service-key.json' >> $BASH_ENV
+      # Import test / dev key for SOPS
       - run:
           command: |
-            pushd .
-            cd /tmp
-            curl -L "https://github.com/terraform-linters/tflint/releases/download/v0.47.0/tflint_linux_amd64.zip" -o tflint.zip
-            unzip -o tflint.zip
-            sudo install -m 0755 tflint /usr/local/bin/tflint
-            rm -rf tflint
-            rm -rf tflint.zip
-            popd
-            tflint --version
+            gpg --import --no-tty --batch --yes ./test/fixture-sops/test_pgp_key.asc
+            mkdir -p logs
+            run-go-tests --packages "$(go list ./... | grep /test | tr '\n' ' ')" | tee logs/integration.log
+          no_output_timeout: 30m
+      - run:
+          command: terratest_log_parser --testlog logs/integration.log --outputdir logs
+          when: always
+      - store_artifacts:
+          path: logs
+      - store_test_results:
+          path: logs
+
+  integration_test_tofu:
+    resource_class: large
+    <<: *defaults
+    steps:
+      - checkout
+      - run: gruntwork-install --binary-name 'terratest_log_parser' --repo 'https://github.com/gruntwork-io/terratest' --tag 'v0.30.0'
+      - run:
+          <<: *install_tofu
+      - run:
+          <<: *install_tflint
       # Make GCP Service Account creds available as a file
       - run: echo $GCLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json
       - run: echo 'export GOOGLE_APPLICATION_CREDENTIALS=${HOME}/gcloud-service-key.json' >> $BASH_ENV
@@ -99,15 +160,44 @@ jobs:
       - run:
           command: |
             gpg --import --no-tty --batch --yes ./test/fixture-sops/test_pgp_key.asc
+            # remove terraform 
+            sudo rm -f $(which terraform)
             mkdir -p logs
             run-go-tests --packages "$(go list ./... | grep /test | tr '\n' ' ')" | tee logs/integration.log
           no_output_timeout: 30m
+          environment:
+            # use tofu as wrapper
+            TERRAGRUNT_TFPATH: tofu
       - run:
           command: terratest_log_parser --testlog logs/integration.log --outputdir logs
           when: always
+      - store_artifacts:
+          path: logs
+      - store_test_results:
+          path: logs
+
+  integration_test_tflint:
+    resource_class: large
+    <<: *defaults
+    steps:
+      - checkout
+      - run: gruntwork-install --binary-name 'terratest_log_parser' --repo 'https://github.com/gruntwork-io/terratest' --tag 'v0.30.0'
+      - run:
+          <<: *install_tofu
+      - run:
+          <<: *install_tflint
+      # Make GCP Service Account creds available as a file
+      - run: echo $GCLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json
+      - run: echo 'export GOOGLE_APPLICATION_CREDENTIALS=${HOME}/gcloud-service-key.json' >> $BASH_ENV
+      # Import test / dev key for SOPS
+      - run:
+          command: |
+            gpg --import --no-tty --batch --yes ./test/fixture-sops/test_pgp_key.asc
+          no_output_timeout: 30m
       # Run TFLint tests separately as tflint during execution change working directory.
       - run:
           command: |
+            mkdir -p logs
             run-go-tests --packages "-tags tflint -run TestTflint ./test" | tee logs/integration.log
           no_output_timeout: 30m
       - run:
@@ -117,6 +207,7 @@ jobs:
           path: logs
       - store_test_results:
           path: logs
+
   build:
     resource_class: large
     <<: *defaults
@@ -184,7 +275,25 @@ workflows:
             - GITHUB__PAT__gruntwork-ci
             - GCP__automated-tests
             - GITHUB__PAT__gruntwork-ci
-      - integration_test:
+      - integration_test_terraform:
+          filters:
+            tags:
+              only: /^v.*/
+          context:
+            - AWS__PHXDEVOPS__circle-ci-test
+            - GITHUB__PAT__gruntwork-ci
+            - GCP__automated-tests
+            - GITHUB__PAT__gruntwork-ci
+      - integration_test_tofu:
+          filters:
+            tags:
+              only: /^v.*/
+          context:
+            - AWS__PHXDEVOPS__circle-ci-test
+            - GITHUB__PAT__gruntwork-ci
+            - GCP__automated-tests
+            - GITHUB__PAT__gruntwork-ci
+      - integration_test_tflint:
           filters:
             tags:
               only: /^v.*/
diff --git a/_ci/install-opentofu.ps1 b/_ci/install-opentofu.ps1
new file mode 100755
index 0000000000..48a6b98ff0
--- /dev/null
+++ b/_ci/install-opentofu.ps1
@@ -0,0 +1,29 @@
+OpenTofuInstallPath = "C:\Program Files\tofu\tofu.exe"
+$OpenTofuTmpPath = "C:\OpenTofutmp"
+$OpenTofuTmpBinaryPath = "C:\OpenTofutmp\tofu.exe"
+$OpenTofuPath = "C:\Program Files\tofu"
+# Remove any old OpenTofu installation, if present
+if (Test-Path -Path $OpenTofuInstallPath)
+{
+	Remove-Item $OpenTofuInstallPath -Recurse
+}
+# Download OpenTofu and unpack it
+$OpenTofuURI = "https://github.com/opentofu/opentofu/releases/download/v1.6.0-alpha1/tofu_1.6.0-alpha1_windows_amd64.zip"
+$output = "tofu_1.6.0-alpha1_windows_amd64.zip"
+$ProgressPreference = "SilentlyContinue"
+Invoke-WebRequest -Uri $OpenTofuURI -OutFile $output
+New-Item -ItemType "directory" -Path $OpenTofuTmpPath
+# Unpack OpenTofu to temp directory
+Expand-Archive -LiteralPath $output -DestinationPath $OpenTofuTmpPath
+# Make new OpenTofu directory to hold binary
+New-Item -ItemType "directory" -Path $OpenTofuPath
+Move-Item $OpenTofuTmpBinaryPath $OpenTofuPath
+# Add new OpenTofu path to system
+$OldPath = [System.Environment]::GetEnvironmentVariable('PATH', "Machine")
+$NewPath = "$OldPath;$OpenTofuPath"
+[Environment]::SetEnvironmentVariable("PATH", "$NewPath", "Machine")
+# Load System and User PATHs into latest $env:Path, which has the effect of "refreshing" the latest path
+# in the current PowerShell session
+$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
+# Verify installation
+tofu version
diff --git a/cli/commands/terraform/action.go b/cli/commands/terraform/action.go
index f46f036910..124d8bde09 100644
--- a/cli/commands/terraform/action.go
+++ b/cli/commands/terraform/action.go
@@ -407,7 +407,7 @@ func runTerraformWithRetry(terragruntOptions *options.TerragruntOptions) error {
 				terragruntOptions.Logger.Infof("Encountered an error eligible for retrying. Sleeping %v before retrying.\n", terragruntOptions.RetrySleepIntervalSec)
 				time.Sleep(terragruntOptions.RetrySleepIntervalSec)
 			} else {
-				terragruntOptions.Logger.Errorf("Terraform invocation failed in %s", terragruntOptions.WorkingDir)
+				terragruntOptions.Logger.Errorf("%s invocation failed in %s", terragruntOptions.TerraformImplementation, terragruntOptions.WorkingDir)
 				return tferr
 			}
 		} else {
diff --git a/cli/commands/terraform/download_source.go b/cli/commands/terraform/download_source.go
index 742a614c7e..57dc379934 100644
--- a/cli/commands/terraform/download_source.go
+++ b/cli/commands/terraform/download_source.go
@@ -78,7 +78,7 @@ func downloadTerraformSourceIfNecessary(terraformSource *terraform.Source, terra
 		if err := validateWorkingDir(terraformSource); err != nil {
 			return err
 		}
-		terragruntOptions.Logger.Debugf("Terraform files in %s are up to date. Will not download again.", terraformSource.WorkingDir)
+		terragruntOptions.Logger.Debugf("%s files in %s are up to date. Will not download again.", terragruntOptions.TerraformImplementation, terraformSource.WorkingDir)
 		return nil
 	}
 
diff --git a/cli/commands/terraform/version_check.go b/cli/commands/terraform/version_check.go
index ebe4442c40..29e244cf97 100644
--- a/cli/commands/terraform/version_check.go
+++ b/cli/commands/terraform/version_check.go
@@ -22,7 +22,7 @@ const DefaultTerraformVersionConstraint = ">= v0.12.0"
 // - Terraform v0.13.0-beta2
 // - Terraform v0.12.27
 // We only make sure the "v#.#.#" part is present in the output.
-var TerraformVersionRegex = regexp.MustCompile(`Terraform (v?\d+\.\d+\.\d+).*`)
+var TerraformVersionRegex = regexp.MustCompile(`^(.*?)\s(v?\d+\.\d+\.\d+).*`)
 
 // Check the version constraints of both terragrunt and terraform. Note that as a side effect this will set the
 // following settings on terragruntOptions:
@@ -42,7 +42,7 @@ func checkVersionConstraints(terragruntOptions *options.TerragruntOptions) error
 
 	// Change the terraform binary path before checking the version
 	// if the path is not changed from default and set in the config.
-	if terragruntOptions.TerraformPath == options.TerraformDefaultPath && partialTerragruntConfig.TerraformBinary != "" {
+	if terragruntOptions.TerraformPath == options.DefaultWrappedPath && partialTerragruntConfig.TerraformBinary != "" {
 		terragruntOptions.TerraformPath = partialTerragruntConfig.TerraformBinary
 	}
 	if err := PopulateTerraformVersion(terragruntOptions); err != nil {
@@ -92,8 +92,20 @@ func PopulateTerraformVersion(terragruntOptions *options.TerragruntOptions) erro
 		return err
 	}
 
+	tfImplementation, err := parseTerraformImplementationType(output.Stdout)
+	if err != nil {
+		return err
+	}
+
 	terragruntOptions.TerraformVersion = terraformVersion
-	terragruntOptions.Logger.Debugf("Terraform version: %s", terraformVersion)
+	terragruntOptions.TerraformImplementation = tfImplementation
+
+	if tfImplementation == options.UnknownImpl {
+		terragruntOptions.TerraformImplementation = options.TerraformImpl
+		terragruntOptions.Logger.Warnf("Failed to identify Terraform implementation, fallback to terraform version: %s", terraformVersion)
+	} else {
+		terragruntOptions.Logger.Debugf("%s version: %s", tfImplementation, terraformVersion)
+	}
 	return nil
 }
 
@@ -141,11 +153,29 @@ func checkTerraformVersionMeetsConstraint(currentVersion *version.Version, const
 func parseTerraformVersion(versionCommandOutput string) (*version.Version, error) {
 	matches := TerraformVersionRegex.FindStringSubmatch(versionCommandOutput)
 
-	if len(matches) != 2 {
+	if len(matches) != 3 {
 		return nil, errors.WithStackTrace(InvalidTerraformVersionSyntax(versionCommandOutput))
 	}
 
-	return version.NewVersion(matches[1])
+	return version.NewVersion(matches[2])
+}
+
+// parseTerraformImplementationType - Parse terraform implementation from --version command output
+func parseTerraformImplementationType(versionCommandOutput string) (options.TerraformImplementationType, error) {
+	matches := TerraformVersionRegex.FindStringSubmatch(versionCommandOutput)
+
+	if len(matches) != 3 {
+		return options.UnknownImpl, errors.WithStackTrace(InvalidTerraformVersionSyntax(versionCommandOutput))
+	}
+	rawType := strings.ToLower(matches[1])
+	switch rawType {
+	case "terraform":
+		return options.TerraformImpl, nil
+	case "opentofu":
+		return options.OpenTofuImpl, nil
+	default:
+		return options.UnknownImpl, nil
+	}
 }
 
 // Custom error types
diff --git a/docs/_docs/01_getting-started/supported-terraform-versions.md b/docs/_docs/01_getting-started/supported-versions.md
similarity index 85%
rename from docs/_docs/01_getting-started/supported-terraform-versions.md
rename to docs/_docs/01_getting-started/supported-versions.md
index ed19433bda..f236a358c7 100644
--- a/docs/_docs/01_getting-started/supported-terraform-versions.md
+++ b/docs/_docs/01_getting-started/supported-versions.md
@@ -1,14 +1,22 @@
 ---
 layout: collection-browser-doc
-title: Terraform Version Compatibility Table
+title: Terraform and OpenTofu Version Compatibility Table
 category: getting-started
-excerpt: Learn which Terraform versions are compatible with which versions of Terragrunt.
+excerpt: Learn which Terraform and OpenTofu versions are compatible with which versions of Terragrunt.
 tags: ["install"]
 order: 102
 nav_title: Documentation
 nav_title_link: /docs/
 ---
 
+## Supported OpenTofu Versions
+
+The officially supported versions are:
+
+| OpenTofu Version | Terragrunt Version                                                           |
+|------------------|------------------------------------------------------------------------------|
+| 1.6.x            | >= [0.52.0](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.52.0) |
+
 ## Supported Terraform Versions
 
 The officially supported versions are:
@@ -27,7 +35,6 @@ The officially supported versions are:
 | 0.12.x            | [0.19.0](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.19.0) - [0.24.4](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.24.4) |
 | 0.11.x            | [0.14.0](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.14.0) - [0.18.7](https://github.com/gruntwork-io/terragrunt/releases/tag/v0.18.7) |
 
-
 However, note that these are the versions that are officially tested in the CI process. In practice, the version
 compatibility is more relaxed than documented above. For example, we've found that Terraform 0.13 works with any version
 above 0.19.0, and we've also found that terraform 0.11 works with any version above 0.19.18 as well.
diff --git a/options/options.go b/options/options.go
index b51ace14c4..0955c39d63 100644
--- a/options/options.go
+++ b/options/options.go
@@ -27,6 +27,9 @@ const (
 	// no limits on parallelism by default (limited by GOPROCS)
 	DefaultParallelism = math.MaxInt32
 
+	// TofuDefaultPath command to run tofu
+	TofuDefaultPath = "tofu"
+
 	// TerraformDefaultPath just takes terraform from the path
 	TerraformDefaultPath = "terraform"
 
@@ -40,8 +43,18 @@ const (
 
 const ContextKey ctxKey = iota
 
+var DefaultWrappedPath = identifyDefaultWrappedExecutable()
+
 type ctxKey byte
 
+type TerraformImplementationType string
+
+const (
+	TerraformImpl TerraformImplementationType = "terraform"
+	OpenTofuImpl  TerraformImplementationType = "tofu"
+	UnknownImpl   TerraformImplementationType = "unknown"
+)
+
 // TerragruntOptions represents options that configure the behavior of the Terragrunt program
 type TerragruntOptions struct {
 	// Location of the Terragrunt config file
@@ -70,6 +83,9 @@ type TerragruntOptions struct {
 	// if you run `apply-all` (which is a terragrunt command), this variable will be set to `apply`.
 	OriginalTerraformCommand string
 
+	// Terraform implementation tool (e.g. terraform, tofu) that terragrunt is wrapping
+	TerraformImplementation TerraformImplementationType
+
 	// Version of terraform (obtained by running 'terraform version')
 	TerraformVersion *version.Version
 
@@ -265,7 +281,7 @@ func MergeIAMRoleOptions(target IAMRoleOptions, source IAMRoleOptions) IAMRoleOp
 // Create a new TerragruntOptions object with reasonable defaults for real usage
 func NewTerragruntOptions() *TerragruntOptions {
 	return &TerragruntOptions{
-		TerraformPath:                  TerraformDefaultPath,
+		TerraformPath:                  DefaultWrappedPath,
 		OriginalTerraformCommand:       "",
 		TerraformCommand:               "",
 		AutoInit:                       true,
@@ -301,6 +317,7 @@ func NewTerragruntOptions() *TerragruntOptions {
 		OutputPrefix:                   "",
 		IncludeModulePrefix:            false,
 		JSONOut:                        DefaultJSONOutName,
+		TerraformImplementation:        UnknownImpl,
 		RunTerragrunt: func(opts *TerragruntOptions) error {
 			return errors.WithStackTrace(RunTerragruntCommandNotSet)
 		},
@@ -425,6 +442,7 @@ func (opts *TerragruntOptions) Clone(terragruntConfigPath string) *TerragruntOpt
 		IncludeModulePrefix:            opts.IncludeModulePrefix,
 		FailIfBucketCreationRequired:   opts.FailIfBucketCreationRequired,
 		DisableBucketUpdate:            opts.DisableBucketUpdate,
+		TerraformImplementation:        opts.TerraformImplementation,
 	}
 }
 
@@ -502,6 +520,15 @@ func (opts *TerragruntOptions) DataDir() string {
 	return util.JoinPath(opts.WorkingDir, tfDataDir)
 }
 
+// identifyDefaultWrappedExecutable - return default path used for wrapped executable
+func identifyDefaultWrappedExecutable() string {
+	if util.IsCommandExecutable(TerraformDefaultPath, "-version") {
+		return TerraformDefaultPath
+	}
+	// fallback to Tofu if terraform is not available
+	return TofuDefaultPath
+}
+
 // Custom error types
 
 var RunTerragruntCommandNotSet = fmt.Errorf("The RunTerragrunt option has not been set on this TerragruntOptions object")
diff --git a/util/shell.go b/util/shell.go
new file mode 100644
index 0000000000..a6f3d3d2e0
--- /dev/null
+++ b/util/shell.go
@@ -0,0 +1,19 @@
+package util
+
+import "os/exec"
+
+// IsCommandExecutable - returns true if a command can be executed without errors.
+func IsCommandExecutable(command string, args ...string) bool {
+	cmd := exec.Command(command, args...)
+	cmd.Stdin = nil
+	cmd.Stdout = nil
+	cmd.Stderr = nil
+
+	if err := cmd.Run(); err != nil {
+		if exitErr, ok := err.(*exec.ExitError); ok {
+			return exitErr.ExitCode() == 0
+		}
+		return false
+	}
+	return true
+}


Test patch:
diff --git a/test/fixture-download/custom-lock-file-module/main.tf b/test/fixture-download/custom-lock-file-module/main.tf
index 11c21c5b13..c0aa371eb7 100644
--- a/test/fixture-download/custom-lock-file-module/main.tf
+++ b/test/fixture-download/custom-lock-file-module/main.tf
@@ -4,6 +4,15 @@
 # when we run 'init'. If the lock file copying works as expected in the custom-lock-file test, then we'll end up
 # with an older version of the provider. If there is a bug, Terraform will end up downloading the latest version of
 # the provider, as we're not pinning the version in the Terraform code (only in the lock file).
+terraform {
+  required_providers {
+    aws = {
+      source  = "hashicorp/aws"
+      version = "5.19.0"
+    }
+  }
+}
+
 provider "aws" {
   region = "eu-west-1"
 }
diff --git a/test/fixture-download/custom-lock-file/.terraform.lock.hcl b/test/fixture-download/custom-lock-file/.terraform.lock.hcl
index 0b14755889..4bea8c4000 100755
--- a/test/fixture-download/custom-lock-file/.terraform.lock.hcl
+++ b/test/fixture-download/custom-lock-file/.terraform.lock.hcl
@@ -1,20 +1,20 @@
-# This file is maintained automatically by "terraform init".
+# This file is maintained automatically by "tofu init".
 # Manual edits may be lost in future updates.
 
-provider "registry.terraform.io/hashicorp/aws" {
-  version = "3.0.0"
+provider "registry.opentofu.org/hashicorp/aws" {
+  version     = "5.19.0"
+  constraints = "5.19.0"
   hashes = [
-    "h1:ULKfwySvQ4pDhy027ryRhLxDhg640wsojYc+7NHMFBU=",
-    "h1:UyKRcHE2W3io32hVQD7s9Ovb9LSyTIvhAAPS9RX5vtg=",
-    "zh:25294510ae9c250502f2e37ac32b01017439735f098f82a1728772427626a2fd",
-    "zh:3b723e7772d47bd8cc11bea6e5d3e0b5e1df8398c0e7aaf510e3a8a54e0f1874",
-    "zh:4b7b73b86f4a0705d5d2a7f1d3ad3279706bdb3957a48f4a389c36918fba838e",
-    "zh:9e26cdc3be97e3001c253c0ca28c5c8ff2d5476373ca1beb849f3f3957ce7f1a",
-    "zh:9e73cf1304bf57968d3048d70c0b766d41497430a2a9a7a718a196f3a385106a",
-    "zh:a30b5b66facfbb2b02814e4cd33ca9899f9ade5bbf478f78c41d2fe789f0582a",
-    "zh:b06fb5da094db41cb5e430c95c988b73f32695e9f90f25499e926842dbd21b21",
-    "zh:c5a4ff607e9e9edee3fcd6d6666241fb532adf88ea1fe24f2aa1eb36845b3ca3",
-    "zh:df568a69087831c1780fac4395630a2cfb3cdf67b7dffbfe16bd78c64770bb75",
-    "zh:fce1b69dd673aace19508640b0b9b7eb1ef7e746d76cb846b49e7d52e0f5fb7e",
+    "h1:xoQFH3TCqCvdaBuqxChcPaooKWOuz3mHI3VKdgxnxEk=",
+    "zh:0c1887bd8efd1da9458ad6d8c59f33de21041d06db3a1d3fa8eae53404051943",
+    "zh:1c3604f15481eff8ccbbeb31b47acf11454a391c1247c7b23d4e3636f1ce4547",
+    "zh:3363e23fe87cb7b0fbbb5e6fc4ef58d2aff05ef5fdf17605e5fbd7e4ee91a312",
+    "zh:3cba361ff181877cb261762a8225a42c5d1053503cb53796b6277bfedeb1baf9",
+    "zh:7a9d7dfaa5878ab51c45d8b5b6bec74a42e12d755753efaee6b2075875d3afd0",
+    "zh:b160fd637ebf3ffb8ee3f4f17e8d51f50e176e26f7889464fd019d20dd045159",
+    "zh:b32b9da9dba98e5be8b9b110c5306e99f49e6608eb434e93211db5bc3a78412e",
+    "zh:b5b60196dd0ce47111fbe2792670a0aca4d8814d15c291d23f1cb29224fbd9a7",
+    "zh:bd349062b14435a8d8924b37208e51caa197617b7a5f2c26e488007c7c6f3298",
+    "zh:f574b9f4652b3d4209fe41f360868e2a90aebebff5f9000f94c507b520490f74",
   ]
 }
diff --git a/test/fixture-no-submodules/terragrunt.hcl b/test/fixture-no-submodules/terragrunt.hcl
index 114814a801..02bbc793ad 100644
--- a/test/fixture-no-submodules/terragrunt.hcl
+++ b/test/fixture-no-submodules/terragrunt.hcl
@@ -1,3 +1,3 @@
 terraform {
-  source = "git::git@github.com:terraform-google-modules/terraform-google-folders.git?ref=v3.0.0"
+  source = "git::git@github.com:terraform-google-modules/terraform-google-folders.git?ref=v4.0.0"
 }
diff --git a/test/fixture-render-json-metadata/attributes/terragrunt.hcl b/test/fixture-render-json-metadata/attributes/terragrunt.hcl
index 7cd83cb682..b524326703 100644
--- a/test/fixture-render-json-metadata/attributes/terragrunt.hcl
+++ b/test/fixture-render-json-metadata/attributes/terragrunt.hcl
@@ -15,7 +15,7 @@ skip = true
 iam_role = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME"
 iam_assume_role_duration = 666
 
-terraform_binary = "terraform"
+terraform_binary = get_env("TERRAGRUNT_TFPATH", "terraform")
 terraform_version_constraint = ">= 0.11"
 
 retryable_errors = [
diff --git a/test/integration_download_test.go b/test/integration_download_test.go
index 11aa2131e9..37237bb144 100644
--- a/test/integration_download_test.go
+++ b/test/integration_download_test.go
@@ -222,7 +222,7 @@ func TestCustomLockFile(t *testing.T) {
 	// In our lock file, we intentionally have hashes for an older version of the AWS provider. If the lock file
 	// copying works, then Terraform will stick with this older version. If there is a bug, Terraform will end up
 	// installing a newer version (since the version is not pinned in the .tf code, only in the lock file).
-	assert.Contains(t, string(readFile), `version = "3.0.0"`)
+	assert.Contains(t, string(readFile), `version     = "5.19.0"`)
 }
 
 func TestExcludeDirs(t *testing.T) {
diff --git a/test/integration_test.go b/test/integration_test.go
index 41cd5c7e01..02544c8551 100644
--- a/test/integration_test.go
+++ b/test/integration_test.go
@@ -174,6 +174,7 @@ const (
 	TEST_FIXTURE_DISABLED_MODULE                                             = "fixture-disabled/"
 	TEST_FIXTURE_EMPTY_STATE                                                 = "fixture-empty-state/"
 	TERRAFORM_BINARY                                                         = "terraform"
+	TOFU_BINARY                                                              = "tofu"
 	TERRAFORM_FOLDER                                                         = ".terraform"
 	TERRAFORM_STATE                                                          = "terraform.tfstate"
 	TERRAFORM_STATE_BACKUP                                                   = "terraform.tfstate.backup"
@@ -1337,22 +1338,22 @@ func TestTerraformCommandCliArgs(t *testing.T) {
 	}{
 		{
 			[]string{"version"},
-			"terraform version",
+			wrappedBinary() + " version",
 			nil,
 		},
 		{
 			[]string{"version", "foo"},
-			"terraform version foo",
+			wrappedBinary() + " version foo",
 			nil,
 		},
 		{
 			[]string{"version", "foo", "bar", "baz"},
-			"terraform version foo bar baz",
+			wrappedBinary() + " version foo bar baz",
 			nil,
 		},
 		{
 			[]string{"version", "foo", "bar", "baz", "foobar"},
-			"terraform version foo bar baz foobar",
+			wrappedBinary() + " version foo bar baz foobar",
 			nil,
 		},
 		{
@@ -1362,7 +1363,7 @@ func TestTerraformCommandCliArgs(t *testing.T) {
 		},
 		{
 			[]string{"paln", "--terragrunt-disable-command-validation"},
-			"Terraform invocation failed", // error caused by running terraform with the wrong command
+			wrappedBinary() + " invocation failed", // error caused by running terraform with the wrong command
 			nil,
 		},
 	}
@@ -1397,19 +1398,19 @@ func TestTerraformSubcommandCliArgs(t *testing.T) {
 	}{
 		{
 			[]string{"force-unlock"},
-			"terraform force-unlock",
+			wrappedBinary() + " force-unlock",
 		},
 		{
 			[]string{"force-unlock", "foo"},
-			"terraform force-unlock foo",
+			wrappedBinary() + " force-unlock foo",
 		},
 		{
 			[]string{"force-unlock", "foo", "bar", "baz"},
-			"terraform force-unlock foo bar baz",
+			wrappedBinary() + " force-unlock foo bar baz",
 		},
 		{
 			[]string{"force-unlock", "foo", "bar", "baz", "foobar"},
-			"terraform force-unlock foo bar baz foobar",
+			wrappedBinary() + " force-unlock foo bar baz foobar",
 		},
 	}
 
@@ -1925,11 +1926,11 @@ func TestTerragruntInfo(t *testing.T) {
 	logBufferContentsLineByLine(t, showStdout, "show stdout")
 
 	var dat terragruntinfo.TerragruntInfoGroup
-	err_unmarshal := json.Unmarshal(showStdout.Bytes(), &dat)
-	assert.Nil(t, err_unmarshal)
+	errUnmarshal := json.Unmarshal(showStdout.Bytes(), &dat)
+	assert.Nil(t, errUnmarshal)
 
 	assert.Equal(t, dat.DownloadDir, fmt.Sprintf("%s/%s", rootPath, TERRAGRUNT_CACHE))
-	assert.Equal(t, dat.TerraformBinary, TERRAFORM_BINARY)
+	assert.Equal(t, dat.TerraformBinary, wrappedBinary())
 	assert.Equal(t, dat.IamRole, "")
 }
 
@@ -1967,7 +1968,7 @@ func TestYamlDecodeRegressions(t *testing.T) {
 // module has been destroyed.
 func TestDependencyOutputOptimization(t *testing.T) {
 	expectOutputLogs := []string{
-		`Running command: terraform init -get=false prefix=\[.*fixture-get-output/nested-optimization/dep\]`,
+		`Running command: ` + wrappedBinary() + ` init -get=false prefix=\[.*fixture-get-output/nested-optimization/dep\]`,
 	}
 	dependencyOutputOptimizationTest(t, "nested-optimization", true, expectOutputLogs)
 }
@@ -1981,7 +1982,7 @@ func TestDependencyOutputOptimizationSkipInit(t *testing.T) {
 
 func TestDependencyOutputOptimizationNoGenerate(t *testing.T) {
 	expectOutputLogs := []string{
-		`Running command: terraform init -get=false prefix=\[.*fixture-get-output/nested-optimization-nogen/dep\]`,
+		`Running command: ` + wrappedBinary() + ` init -get=false prefix=\[.*fixture-get-output/nested-optimization-nogen/dep\]`,
 	}
 	dependencyOutputOptimizationTest(t, "nested-optimization-nogen", true, expectOutputLogs)
 }
@@ -3975,7 +3976,7 @@ func TestLogFailingDependencies(t *testing.T) {
 	output := stderr.String()
 
 	assert.Error(t, err)
-	assert.Contains(t, output, "Terraform invocation failed in fixture-broken-dependency/dependency")
+	assert.Contains(t, output, wrappedBinary()+" invocation failed in fixture-broken-dependency/dependency")
 }
 
 func cleanupTerraformFolder(t *testing.T, templatesPath string) {
@@ -4882,7 +4883,7 @@ func TestNoMultipleInitsWithoutSourceChange(t *testing.T) {
 	err := runTerragruntCommand(t, fmt.Sprintf("terragrunt plan --terragrunt-non-interactive --terragrunt-working-dir %s", testPath), &stdout, &stderr)
 	require.NoError(t, err)
 	// providers initialization during first plan
-	assert.Equal(t, 1, strings.Count(stdout.String(), "Terraform has been successfully initialized!"))
+	assert.Equal(t, 1, strings.Count(stdout.String(), "has been successfully initialized!"))
 
 	stdout = bytes.Buffer{}
 	stderr = bytes.Buffer{}
@@ -4891,7 +4892,7 @@ func TestNoMultipleInitsWithoutSourceChange(t *testing.T) {
 	require.NoError(t, err)
 	// no initialization expected for second plan run
 	// https://github.com/gruntwork-io/terragrunt/issues/1921
-	assert.Equal(t, 0, strings.Count(stdout.String(), "Terraform has been successfully initialized!"))
+	assert.Equal(t, 0, strings.Count(stdout.String(), "has been successfully initialized!"))
 }
 
 func TestAutoInitWhenSourceIsChanged(t *testing.T) {
@@ -4915,7 +4916,7 @@ func TestAutoInitWhenSourceIsChanged(t *testing.T) {
 	err = runTerragruntCommand(t, fmt.Sprintf("terragrunt plan --terragrunt-non-interactive --terragrunt-working-dir %s", testPath), &stdout, &stderr)
 	require.NoError(t, err)
 	// providers initialization during first plan
-	assert.Equal(t, 1, strings.Count(stdout.String(), "Terraform has been successfully initialized!"))
+	assert.Equal(t, 1, strings.Count(stdout.String(), "has been successfully initialized!"))
 
 	updatedHcl = strings.Replace(contents, "__TAG_VALUE__", "v0.35.2", -1)
 	require.NoError(t, os.WriteFile(terragruntHcl, []byte(updatedHcl), 0444))
@@ -4926,7 +4927,7 @@ func TestAutoInitWhenSourceIsChanged(t *testing.T) {
 	err = runTerragruntCommand(t, fmt.Sprintf("terragrunt plan --terragrunt-non-interactive --terragrunt-working-dir %s", testPath), &stdout, &stderr)
 	require.NoError(t, err)
 	// auto initialization when source is changed
-	assert.Equal(t, 1, strings.Count(stdout.String(), "Terraform has been successfully initialized!"))
+	assert.Equal(t, 1, strings.Count(stdout.String(), "has been successfully initialized!"))
 }
 
 func TestNoColor(t *testing.T) {
@@ -4942,7 +4943,7 @@ func TestNoColor(t *testing.T) {
 	err := runTerragruntCommand(t, fmt.Sprintf("terragrunt plan -no-color --terragrunt-working-dir %s", testPath), &stdout, &stderr)
 	require.NoError(t, err)
 	// providers initialization during first plan
-	assert.Equal(t, 1, strings.Count(stdout.String(), "Terraform has been successfully initialized!"))
+	assert.Equal(t, 1, strings.Count(stdout.String(), "has been successfully initialized!"))
 
 	assert.NotContains(t, stdout.String(), "[")
 }
@@ -5037,7 +5038,7 @@ func TestRenderJsonAttributesMetadata(t *testing.T) {
 	var terraformBinary = renderedJson[config.MetadataTerraformBinary]
 	expectedTerraformBinary := map[string]interface{}{
 		"metadata": expectedMetadata,
-		"value":    "terraform",
+		"value":    wrappedBinary(),
 	}
 	assert.True(t, reflect.DeepEqual(expectedTerraformBinary, terraformBinary))
 
@@ -5830,8 +5831,8 @@ func TestInitSkipCache(t *testing.T) {
 	)
 
 	// verify that init was invoked
-	assert.Contains(t, stdout.String(), "Terraform has been successfully initialized!")
-	assert.Contains(t, stderr.String(), "Running command: terraform init")
+	assert.Contains(t, stdout.String(), "has been successfully initialized!")
+	assert.Contains(t, stderr.String(), "Running command: "+wrappedBinary()+" init")
 
 	stdout = bytes.Buffer{}
 	stderr = bytes.Buffer{}
@@ -5842,8 +5843,8 @@ func TestInitSkipCache(t *testing.T) {
 	)
 
 	// verify that init wasn't invoked second time since cache directories are ignored
-	assert.NotContains(t, stdout.String(), "Terraform has been successfully initialized!")
-	assert.NotContains(t, stderr.String(), "Running command: terraform init")
+	assert.NotContains(t, stdout.String(), "has been successfully initialized!")
+	assert.NotContains(t, stderr.String(), "Running command: "+wrappedBinary()+" init")
 
 	// verify that after adding new file, init is executed
 	tfFile := util.JoinPath(tmpEnvPath, TEST_FIXTURE_INIT_CACHE, "app", "project.tf")
@@ -5860,8 +5861,8 @@ func TestInitSkipCache(t *testing.T) {
 	)
 
 	// verify that init was invoked
-	assert.Contains(t, stdout.String(), "Terraform has been successfully initialized!")
-	assert.Contains(t, stderr.String(), "Running command: terraform init")
+	assert.Contains(t, stdout.String(), "has been successfully initialized!")
+	assert.Contains(t, stderr.String(), "Running command: "+wrappedBinary()+" init")
 }
 
 func TestRenderJsonWithInputsNotExistingOutput(t *testing.T) {
@@ -6148,7 +6149,7 @@ func TestRenderJsonDependentModulesTerraform(t *testing.T) {
 	jsonOut := filepath.Join(tmpDir, "terragrunt_rendered.json")
 	runTerragrunt(t, fmt.Sprintf("terragrunt render-json --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s  --terragrunt-json-out %s", tmpDir, jsonOut))
 
-	jsonBytes, err := ioutil.ReadFile(jsonOut)
+	jsonBytes, err := os.ReadFile(jsonOut)
 	require.NoError(t, err)
 
 	var renderedJson = map[string]interface{}{}
@@ -6170,7 +6171,7 @@ func TestRenderJsonDependentModulesMetadataTerraform(t *testing.T) {
 	jsonOut := filepath.Join(tmpDir, "terragrunt_rendered.json")
 	runTerragrunt(t, fmt.Sprintf("terragrunt render-json --with-metadata --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir %s  --terragrunt-json-out %s", tmpDir, jsonOut))
 
-	jsonBytes, err := ioutil.ReadFile(jsonOut)
+	jsonBytes, err := os.ReadFile(jsonOut)
 	require.NoError(t, err)
 
 	var renderedJson = map[string]map[string]interface{}{}
@@ -6189,3 +6190,12 @@ func validateOutput(t *testing.T, outputs map[string]TerraformOutput, key string
 	require.Truef(t, hasPlatform, "Expected output %s to be defined", key)
 	require.Equalf(t, output.Value, value, "Expected output %s to be %t", key, value)
 }
+
+// wrappedBinary - return which binary will be wrapped by Terragrunt, useful in CICD to run same tests against tofu and terraform
+func wrappedBinary() string {
+	value, found := os.LookupEnv("TERRAGRUNT_TFPATH")
+	if !found {
+		return TERRAFORM_BINARY
+	}
+	return value
+}
diff --git a/util/shell_test.go b/util/shell_test.go
new file mode 100644
index 0000000000..7b1c342b14
--- /dev/null
+++ b/util/shell_test.go
@@ -0,0 +1,19 @@
+package util
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExistingCommand(t *testing.T) {
+	t.Parallel()
+
+	assert.True(t, IsCommandExecutable("pwd"))
+}
+
+func TestNotExistingCommand(t *testing.T) {
+	t.Parallel()
+
+	assert.False(t, IsCommandExecutable("not-existing-command", "--version"))
+}