Repository: runatlantis/atlantis (language: go)

Issue:
Support OpenTofu (epic)
<!--- Please keep this note for the community --->

### Community Note

- Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request. Searching for pre-existing feature requests helps us consolidate datapoints for identical requirements into a single place, thank you!
- Please do not leave "+1" or other comments that do not add relevant new information or questions, they generate extra noise for issue followers and do not help prioritize the request.
- If you are interested in working on this issue or have submitted a pull request, please leave a comment.

<!--- Thank you for keeping this note for the community --->

---

- [ ] I'd be willing to implement this feature ([contributing guide](https://github.com/runatlantis/atlantis/blob/main/CONTRIBUTING.md))

**Describe the user story**
<!--
A clear and concise description of what workflow is meant to be improved.
Example: "As a developer, I often want to do <something>, but I often face <problem>".
-->
Please support usage of opentofu

https://github.com/opentffoundation/opentf

**Describe the solution you'd like**
<!--
A clear and concise description of what you want to happen. Consider that atlantis is used
by many people, and your particular use case might not make sense to implement in the core.
-->
See above
- #4337
- #4338 
- #4339 

**Describe the drawbacks of your solution**
<!--
This section is important not only to identify future issues, but also for us to see whether
you thought through your request. When filling it, ask yourself what are the problems we could
have maintaining what you propose. How often will it break?
-->
N/A

**Describe alternatives you've considered**
<!--
A clear and concise description of any alternative solutions or features you've considered,
and why you think they wouldn't be good enough.
-->
N/A




Discussion / hints:
This Ticket should be renamed to `Support OpenTofu terraform fork`, to make it easier to find.
Should be just `Support OpenTofu` (I think almost everyone knows OpenTofu at this point)
@nitrocode @chenrui333 @raynigon I would like to work on this issue, could you assign me?

@sirvantedbrl feel free to kick off a PR and we can start from there. Thanks!
@chenrui333 İ'm starting to work on sending a PR, thank you.

The idea is that we will still support both Terraform and OpenTofu. There may be some hard-code references to Terraform. We want to abstract that so that we don't have to do this again for even another tool. Atlantis long-term should be vendor-agnostic. 
opentofu just GA today, https://github.com/opentofu/opentofu/releases/tag/v1.6.0
A workaround for users that want to go down the opentofu path before support is official could do the following.

1. Set [`--tf-download=false`](https://www.runatlantis.io/docs/server-configuration.html#tf-download) to avoid downloading terraform versions
2. Use a custom container to [download a opentofu release](https://github.com/opentofu/opentofu/releases) as `/usr/bin/opentofu_<version>`
3. Use a custom workflow to override the init, plan, and apply steps to use the opentofu cli instead of the terraform cli

Worked for me

Installation with helm: 

`values.yaml`

```yaml
initConfig:
  enabled: true
  image: alpine:latest
  imagePullPolicy: IfNotPresent
  # sharedDir is set as env var INIT_SHARED_DIR
  sharedDir: /plugins
  workDir: /tmp
  sizeLimit: 250Mi
  # example of how the script can be configured to install tools/providers required by the atlantis pod
  script: |
    #!/bin/sh
    set -eoux pipefail

    # terragrunt
    TG_VERSION="0.55.10"
    TG_SHA256_SUM="1ad609399352348a41bb5ea96fdff5c7a18ac223742f60603a557a54fc8c6cff"
    TG_FILE="${INIT_SHARED_DIR}/terragrunt"
    wget https://github.com/gruntwork-io/terragrunt/releases/download/v${TG_VERSION}/terragrunt_linux_amd64 -O "${TG_FILE}"
    echo "${TG_SHA256_SUM} ${TG_FILE}" | sha256sum -c
    chmod 755 "${TG_FILE}"
    terragrunt -v

    # OpenTofu
    TF_VERSION="1.6.2"
    TF_FILE="${INIT_SHARED_DIR}/tofu"
    wget https://github.com/opentofu/opentofu/releases/download/v${TF_VERSION}/tofu_${TF_VERSION}_linux_amd64.zip
    unzip tofu_${TF_VERSION}_linux_amd64.zip
    mv tofu ${INIT_SHARED_DIR}
    chmod 755 "${TF_FILE}"
    tofu -v
```

```yaml
environment: 
  ATLANTIS_TF_DOWNLOAD: false
  TERRAGRUNT_TFPATH: /plugins/tofu
```

```yaml
repoConfig: |
  ---
  repos:
  - id: /.*/
    apply_requirements: [approved, mergeable]
    allow_custom_workflows: true
    allowed_overrides: [workflow, apply_requirements, delete_source_branch_on_merge]
```

`atlantis.yaml`

```yaml
version: 3
automerge: true
parallel_plan: true
parallel_apply: false
projects:
- name: terragrunt
  dir: .
  workspace: terragrunt
  delete_source_branch_on_merge: true
  autoplan:
    enabled: false
  apply_requirements: [mergeable, approved]
  workflow: terragrunt
workflows:
  terragrunt:
    plan:
      steps:
      - env:
          name: TF_IN_AUTOMATION
          value: 'true'
      - run: find . -name '.terragrunt-cache' | xargs rm -rf
      - run: terragrunt init -reconfigure
      - run:
          command: terragrunt plan -input=false -out=$PLANFILE
          output: strip_refreshing
    apply:  
      steps:      
        - run: terragrunt apply $PLANFILE
```

I hope it helps :)
Nice work! I think the above is worth documenting on the site. The one suggestion I have would be to keep the version as a suffix in the binary name for consistency with how we currently use terraform.

We'd also need to document without using a wrapper like terragrunt/atmos/terramate/etc

Reading the description of this issue over and it's more targeting the auto download of opentofu than the usage of opentofu. Maybe we can document the above and re-title this issue as "Support autodownload of opentofu"?

Your solution of installing opentofu manually and disabling auto download should work as expected.

I also saw that the new aws 5.40 aws providers, which contain custom functions, are backwards compatible (but without custom function support) with older versions of terraform and current versions of opentofu. One less worry with switching from tf to opentofu.
Perhaps since so many people have upvoted this issue, we should move the contents and title of this issue into its own issue for autodownloading opentofu

- https://github.com/runatlantis/atlantis/issues/4339

Then this this issue can be (informally) an epic for full opentofu support so it can be broken up into smaller issues/subtasks
Hi! I think that in scope of it you can start to use [tenv](https://github.com/tofuutils/tenv): tool for managing Terraform, Terragrunt and OpenTofu by a single binary file.

You're also welcome to open any issues or contribute to [tenv](https://github.com/tofuutils/tenv).

This is my doc to deployment Atlantis with OpenTofu.

https://medium.com/@contato.matthewd/deployment-atlantis-with-opentofu-85ca0fbe45e5

Fix patch:
diff --git a/cmd/server.go b/cmd/server.go
index 0bb589411e..e53ca20418 100644
--- a/cmd/server.go
+++ b/cmd/server.go
@@ -37,6 +37,12 @@ const (
 	CheckoutStrategyMerge  = "merge"
 )
 
+// TF distributions
+const (
+	TFDistributionTerraform = "terraform"
+	TFDistributionOpenTofu  = "opentofu"
+)
+
 // To add a new flag you must:
 // 1. Add a const with the flag name (in alphabetic order).
 // 2. Add a new field to server.UserConfig and set the mapstructure tag equal to the flag name.
@@ -134,6 +140,7 @@ const (
 	SSLCertFileFlag                  = "ssl-cert-file"
 	SSLKeyFileFlag                   = "ssl-key-file"
 	RestrictFileList                 = "restrict-file-list"
+	TFDistributionFlag               = "tf-distribution"
 	TFDownloadFlag                   = "tf-download"
 	TFDownloadURLFlag                = "tf-download-url"
 	UseTFPluginCache                 = "use-tf-plugin-cache"
@@ -176,6 +183,7 @@ const (
 	DefaultRedisPort                    = 6379
 	DefaultRedisTLSEnabled              = false
 	DefaultRedisInsecureSkipVerify      = false
+	DefaultTFDistribution               = TFDistributionTerraform
 	DefaultTFDownloadURL                = "https://releases.hashicorp.com"
 	DefaultTFDownload                   = true
 	DefaultTFEHostname                  = "app.terraform.io"
@@ -406,6 +414,10 @@ var stringFlags = map[string]stringFlag{
 	SSLKeyFileFlag: {
 		description: fmt.Sprintf("File containing x509 private key matching --%s.", SSLCertFileFlag),
 	},
+	TFDistributionFlag: {
+		description:  fmt.Sprintf("Which TF distribution to use. Can be set to %s or %s.", TFDistributionTerraform, TFDistributionOpenTofu),
+		defaultValue: DefaultTFDistribution,
+	},
 	TFDownloadURLFlag: {
 		description:  "Base URL to download Terraform versions from.",
 		defaultValue: DefaultTFDownloadURL,
@@ -897,6 +909,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig, v *viper.Viper) {
 	if c.RedisPort == 0 {
 		c.RedisPort = DefaultRedisPort
 	}
+	if c.TFDistribution == "" {
+		c.TFDistribution = DefaultTFDistribution
+	}
 	if c.TFDownloadURL == "" {
 		c.TFDownloadURL = DefaultTFDownloadURL
 	}
@@ -923,6 +938,11 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
 		return fmt.Errorf("invalid log level: must be one of %v", ValidLogLevels)
 	}
 
+	if userConfig.TFDistribution != TFDistributionTerraform && userConfig.TFDistribution != TFDistributionOpenTofu {
+		return fmt.Errorf("invalid tf distribution: expected one of %s or %s",
+			TFDistributionTerraform, TFDistributionOpenTofu)
+	}
+
 	checkoutStrategy := userConfig.CheckoutStrategy
 	if checkoutStrategy != CheckoutStrategyBranch && checkoutStrategy != CheckoutStrategyMerge {
 		return fmt.Errorf("invalid checkout strategy: not one of %s or %s",
diff --git a/go.mod b/go.mod
index 67c2589bad..2f151b610e 100644
--- a/go.mod
+++ b/go.mod
@@ -32,6 +32,7 @@ require (
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/moby/patternmatcher v0.6.0
 	github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826
+	github.com/opentofu/tofudl v0.0.0-20240923062014-8c1e00f33ce6
 	github.com/petergtz/pegomock/v4 v4.1.0
 	github.com/pkg/errors v0.9.1
 	github.com/redis/go-redis/v9 v9.6.1
@@ -67,13 +68,15 @@ require (
 	github.com/Masterminds/goutils v1.1.1 // indirect
 	github.com/Masterminds/semver/v3 v3.3.0 // indirect
 	github.com/ProtonMail/go-crypto v1.1.0-alpha.2 // indirect
+	github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
+	github.com/ProtonMail/gopenpgp/v2 v2.7.5 // indirect
 	github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect
 	github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
-	github.com/cloudflare/circl v1.3.7 // indirect
+	github.com/cloudflare/circl v1.3.9 // indirect
 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
 	github.com/davidmz/go-pageant v1.0.2 // indirect
 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
diff --git a/go.sum b/go.sum
index 3d140ac128..110c8ecba7 100644
--- a/go.sum
+++ b/go.sum
@@ -45,8 +45,13 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
 github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
 github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
 github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
 github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg=
 github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
+github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k=
+github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
+github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA=
+github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g=
 github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -78,6 +83,7 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cactus/go-statsd-client/v5 v5.0.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY=
 github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4=
 github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY=
@@ -90,8 +96,9 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
-github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
+github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
+github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE=
+github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@@ -347,6 +354,8 @@ github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
 github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
 github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
 github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
+github.com/opentofu/tofudl v0.0.0-20240923062014-8c1e00f33ce6 h1:+1yJm0gEoDaxYmMhmmU3gRAOMx3A43z84bokm1dQroU=
+github.com/opentofu/tofudl v0.0.0-20240923062014-8c1e00f33ce6/go.mod h1:CD1BhvxxNPp4ZBwNBjWycf5isG9UaPrzfE7J/E/s6RY=
 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/petergtz/pegomock/v4 v4.1.0 h1:Reoy2rlwshuxNaD2ZWp5TrSCrmoFH5SSLHb5U1z2pog=
@@ -434,6 +443,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -490,6 +500,8 @@ golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
+golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
 golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
 golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -525,6 +537,7 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
 golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -560,6 +573,9 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
 golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
 golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -582,6 +598,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -625,11 +642,17 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
 golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
 golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
 golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -640,6 +663,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
 golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -688,6 +714,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md
index 1fd17cbfdc..610838f262 100644
--- a/runatlantis.io/docs/server-configuration.md
+++ b/runatlantis.io/docs/server-configuration.md
@@ -1241,6 +1241,14 @@ This is useful when you have many projects and want to keep the pull request cle
 
   Namespace for emitting stats/metrics. See [stats](stats.md) section.
 
+### `--tf-distribution`
+  ```bash
+  atlantis server --tf-distribution="terraform"
+  # or
+  ATLANTIS_TF_DISTRIBUTION="terraform"
+  ```
+  Which TF distribution to use. Can be set to `terraform` or `opentofu`.
+
 ### `--tf-download`
 
   ```bash
@@ -1266,6 +1274,8 @@ Setting this to `false` can be useful in an air-gapped environment where a downl
 
   This has no impact if `--tf-download` is set to `false`.
 
+  This setting is not yet supported when `--tf-distribution` is set to `opentofu`.
+
 ### `--tfe-hostname`
 
   ```bash
diff --git a/server/core/runtime/policy/mocks/mock_downloader.go b/server/core/runtime/policy/mocks/mock_downloader.go
new file mode 100644
index 0000000000..03a1d73d47
--- /dev/null
+++ b/server/core/runtime/policy/mocks/mock_downloader.go
@@ -0,0 +1,112 @@
+// Code generated by pegomock. DO NOT EDIT.
+// Source: github.com/runatlantis/atlantis/server/core/runtime/policy (interfaces: Downloader)
+
+package mocks
+
+import (
+	pegomock "github.com/petergtz/pegomock/v4"
+	"reflect"
+	"time"
+)
+
+type MockDownloader struct {
+	fail func(message string, callerSkip ...int)
+}
+
+func NewMockDownloader(options ...pegomock.Option) *MockDownloader {
+	mock := &MockDownloader{}
+	for _, option := range options {
+		option.Apply(mock)
+	}
+	return mock
+}
+
+func (mock *MockDownloader) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
+func (mock *MockDownloader) FailHandler() pegomock.FailHandler      { return mock.fail }
+
+func (mock *MockDownloader) GetAny(dst string, src string) error {
+	if mock == nil {
+		panic("mock must not be nil. Use myMock := NewMockDownloader().")
+	}
+	_params := []pegomock.Param{dst, src}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("GetAny", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(error)
+		}
+	}
+	return _ret0
+}
+
+func (mock *MockDownloader) VerifyWasCalledOnce() *VerifierMockDownloader {
+	return &VerifierMockDownloader{
+		mock:                   mock,
+		invocationCountMatcher: pegomock.Times(1),
+	}
+}
+
+func (mock *MockDownloader) VerifyWasCalled(invocationCountMatcher pegomock.InvocationCountMatcher) *VerifierMockDownloader {
+	return &VerifierMockDownloader{
+		mock:                   mock,
+		invocationCountMatcher: invocationCountMatcher,
+	}
+}
+
+func (mock *MockDownloader) VerifyWasCalledInOrder(invocationCountMatcher pegomock.InvocationCountMatcher, inOrderContext *pegomock.InOrderContext) *VerifierMockDownloader {
+	return &VerifierMockDownloader{
+		mock:                   mock,
+		invocationCountMatcher: invocationCountMatcher,
+		inOrderContext:         inOrderContext,
+	}
+}
+
+func (mock *MockDownloader) VerifyWasCalledEventually(invocationCountMatcher pegomock.InvocationCountMatcher, timeout time.Duration) *VerifierMockDownloader {
+	return &VerifierMockDownloader{
+		mock:                   mock,
+		invocationCountMatcher: invocationCountMatcher,
+		timeout:                timeout,
+	}
+}
+
+type VerifierMockDownloader struct {
+	mock                   *MockDownloader
+	invocationCountMatcher pegomock.InvocationCountMatcher
+	inOrderContext         *pegomock.InOrderContext
+	timeout                time.Duration
+}
+
+func (verifier *VerifierMockDownloader) GetAny(dst string, src string) *MockDownloader_GetAny_OngoingVerification {
+	_params := []pegomock.Param{dst, src}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetAny", _params, verifier.timeout)
+	return &MockDownloader_GetAny_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
+}
+
+type MockDownloader_GetAny_OngoingVerification struct {
+	mock              *MockDownloader
+	methodInvocations []pegomock.MethodInvocation
+}
+
+func (c *MockDownloader_GetAny_OngoingVerification) GetCapturedArguments() (string, string) {
+	dst, src := c.GetAllCapturedArguments()
+	return dst[len(dst)-1], src[len(src)-1]
+}
+
+func (c *MockDownloader_GetAny_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string) {
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(string)
+			}
+		}
+		if len(_params) > 1 {
+			_param1 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(string)
+			}
+		}
+	}
+	return
+}
diff --git a/server/core/terraform/distribution.go b/server/core/terraform/distribution.go
new file mode 100644
index 0000000000..0fd781765d
--- /dev/null
+++ b/server/core/terraform/distribution.go
@@ -0,0 +1,133 @@
+package terraform
+
+import (
+	"context"
+	"fmt"
+	"sort"
+
+	"github.com/hashicorp/go-version"
+	"github.com/hashicorp/hc-install/product"
+	"github.com/hashicorp/hc-install/releases"
+	"github.com/opentofu/tofudl"
+)
+
+type Distribution interface {
+	BinName() string
+	Downloader() Downloader
+	// ResolveConstraint gets the latest version for the given constraint
+	ResolveConstraint(context.Context, string) (*version.Version, error)
+}
+
+type DistributionOpenTofu struct {
+	downloader Downloader
+}
+
+func NewDistributionOpenTofu() Distribution {
+	return &DistributionOpenTofu{
+		downloader: &TofuDownloader{},
+	}
+}
+
+func NewDistributionOpenTofuWithDownloader(downloader Downloader) Distribution {
+	return &DistributionOpenTofu{
+		downloader: downloader,
+	}
+}
+
+func (*DistributionOpenTofu) BinName() string {
+	return "tofu"
+}
+
+func (d *DistributionOpenTofu) Downloader() Downloader {
+	return d.downloader
+}
+
+func (*DistributionOpenTofu) ResolveConstraint(ctx context.Context, constraintStr string) (*version.Version, error) {
+	dl, err := tofudl.New()
+	if err != nil {
+		return nil, err
+	}
+
+	vc, err := version.NewConstraint(constraintStr)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing constraint string: %s", err)
+	}
+
+	allVersions, err := dl.ListVersions(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("error listing OpenTofu versions: %s", err)
+	}
+
+	var versions []*version.Version
+	for _, ver := range allVersions {
+		v, err := version.NewVersion(string(ver.ID))
+		if err != nil {
+			return nil, err
+		}
+
+		if vc.Check(v) {
+			versions = append(versions, v)
+		}
+	}
+	sort.Sort(version.Collection(versions))
+
+	if len(versions) == 0 {
+		return nil, fmt.Errorf("no OpenTofu versions found for constraints %s", constraintStr)
+	}
+
+	// We want to select the highest version that satisfies the constraint.
+	version := versions[len(versions)-1]
+
+	// Get the Version object from the versionDownloader.
+	return version, nil
+}
+
+type DistributionTerraform struct {
+	downloader Downloader
+}
+
+func NewDistributionTerraform() Distribution {
+	return &DistributionTerraform{
+		downloader: &TerraformDownloader{},
+	}
+}
+
+func NewDistributionTerraformWithDownloader(downloader Downloader) Distribution {
+	return &DistributionTerraform{
+		downloader: downloader,
+	}
+}
+
+func (*DistributionTerraform) BinName() string {
+	return "terraform"
+}
+
+func (d *DistributionTerraform) Downloader() Downloader {
+	return d.downloader
+}
+
+func (*DistributionTerraform) ResolveConstraint(ctx context.Context, constraintStr string) (*version.Version, error) {
+	vc, err := version.NewConstraint(constraintStr)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing constraint string: %s", err)
+	}
+
+	constrainedVersions := &releases.Versions{
+		Product:     product.Terraform,
+		Constraints: vc,
+	}
+
+	installCandidates, err := constrainedVersions.List(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("error listing available versions: %s", err)
+	}
+	if len(installCandidates) == 0 {
+		return nil, fmt.Errorf("no Terraform versions found for constraints %s", constraintStr)
+	}
+
+	// We want to select the highest version that satisfies the constraint.
+	versionDownloader := installCandidates[len(installCandidates)-1]
+
+	// Get the Version object from the versionDownloader.
+	return versionDownloader.(*releases.ExactVersion).Version, nil
+}
diff --git a/server/core/terraform/downloader.go b/server/core/terraform/downloader.go
new file mode 100644
index 0000000000..36cc4e1071
--- /dev/null
+++ b/server/core/terraform/downloader.go
@@ -0,0 +1,70 @@
+package terraform
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+
+	"github.com/hashicorp/go-version"
+	install "github.com/hashicorp/hc-install"
+	"github.com/hashicorp/hc-install/product"
+	"github.com/hashicorp/hc-install/releases"
+	"github.com/hashicorp/hc-install/src"
+	"github.com/opentofu/tofudl"
+)
+
+//go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader
+
+// Downloader is for downloading terraform versions.
+type Downloader interface {
+	Install(ctx context.Context, dir string, downloadURL string, v *version.Version) (string, error)
+}
+
+type TofuDownloader struct{}
+
+func (d *TofuDownloader) Install(ctx context.Context, dir string, _downloadURL string, v *version.Version) (string, error) {
+	// Initialize the downloader:
+	dl, err := tofudl.New()
+	if err != nil {
+		return "", err
+	}
+
+	binary, err := dl.Download(ctx, tofudl.DownloadOptVersion(tofudl.Version(v.String())))
+	if err != nil {
+		return "", err
+	}
+
+	// Write out the tofu binary to the disk:
+	file := filepath.Join(dir, "tofu"+v.String())
+	if err := os.WriteFile(file, binary, 0755); /* #nosec G306 */ err != nil {
+		return "", err
+	}
+
+	return file, nil
+}
+
+type TerraformDownloader struct{}
+
+func (d *TerraformDownloader) Install(ctx context.Context, dir string, downloadURL string, v *version.Version) (string, error) {
+	installer := install.NewInstaller()
+	execPath, err := installer.Install(ctx, []src.Installable{
+		&releases.ExactVersion{
+			Product:    product.Terraform,
+			Version:    v,
+			InstallDir: dir,
+			ApiBaseURL: downloadURL,
+		},
+	})
+	if err != nil {
+		return "", err
+	}
+
+	// hc-install installs terraform binary as just "terraform".
+	// We need to rename it to terraform{version} to be consistent with current naming convention.
+	newPath := filepath.Join(dir, "terraform"+v.String())
+	if err := os.Rename(execPath, newPath); err != nil {
+		return "", err
+	}
+
+	return newPath, nil
+}
diff --git a/server/core/terraform/mocks/mock_downloader.go b/server/core/terraform/mocks/mock_downloader.go
index 06b82f6706..8f2e57c24d 100644
--- a/server/core/terraform/mocks/mock_downloader.go
+++ b/server/core/terraform/mocks/mock_downloader.go
@@ -4,6 +4,7 @@
 package mocks
 
 import (
+	context "context"
 	go_version "github.com/hashicorp/go-version"
 	pegomock "github.com/petergtz/pegomock/v4"
 	"reflect"
@@ -25,38 +26,23 @@ func NewMockDownloader(options ...pegomock.Option) *MockDownloader {
 func (mock *MockDownloader) SetFailHandler(fh pegomock.FailHandler) { mock.fail = fh }
 func (mock *MockDownloader) FailHandler() pegomock.FailHandler      { return mock.fail }
 
-func (mock *MockDownloader) GetAny(dst string, src string) error {
+func (mock *MockDownloader) Install(ctx context.Context, dir string, downloadURL string, v *go_version.Version) (string, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockDownloader().")
 	}
-	params := []pegomock.Param{dst, src}
-	result := pegomock.GetGenericMockFrom(mock).Invoke("GetAny", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
-	var ret0 error
-	if len(result) != 0 {
-		if result[0] != nil {
-			ret0 = result[0].(error)
+	_params := []pegomock.Param{ctx, dir, downloadURL, v}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("Install", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 string
+	var _ret1 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(string)
 		}
-	}
-	return ret0
-}
-
-func (mock *MockDownloader) Install(dir string, downloadURL string, v *go_version.Version) (string, error) {
-	if mock == nil {
-		panic("mock must not be nil. Use myMock := NewMockDownloader().")
-	}
-	params := []pegomock.Param{dir, downloadURL, v}
-	result := pegomock.GetGenericMockFrom(mock).Invoke("Install", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
-	var ret0 string
-	var ret1 error
-	if len(result) != 0 {
-		if result[0] != nil {
-			ret0 = result[0].(string)
-		}
-		if result[1] != nil {
-			ret1 = result[1].(error)
+		if _result[1] != nil {
+			_ret1 = _result[1].(error)
 		}
 	}
-	return ret0, ret1
+	return _ret0, _ret1
 }
 
 func (mock *MockDownloader) VerifyWasCalledOnce() *VerifierMockDownloader {
@@ -96,40 +82,9 @@ type VerifierMockDownloader struct {
 	timeout                time.Duration
 }
 
-func (verifier *VerifierMockDownloader) GetAny(dst string, src string) *MockDownloader_GetAny_OngoingVerification {
-	params := []pegomock.Param{dst, src}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "GetAny", params, verifier.timeout)
-	return &MockDownloader_GetAny_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
-}
-
-type MockDownloader_GetAny_OngoingVerification struct {
-	mock              *MockDownloader
-	methodInvocations []pegomock.MethodInvocation
-}
-
-func (c *MockDownloader_GetAny_OngoingVerification) GetCapturedArguments() (string, string) {
-	dst, src := c.GetAllCapturedArguments()
-	return dst[len(dst)-1], src[len(src)-1]
-}
-
-func (c *MockDownloader_GetAny_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string) {
-	params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(params) > 0 {
-		_param0 = make([]string, len(c.methodInvocations))
-		for u, param := range params[0] {
-			_param0[u] = param.(string)
-		}
-		_param1 = make([]string, len(c.methodInvocations))
-		for u, param := range params[1] {
-			_param1[u] = param.(string)
-		}
-	}
-	return
-}
-
-func (verifier *VerifierMockDownloader) Install(dir string, downloadURL string, v *go_version.Version) *MockDownloader_Install_OngoingVerification {
-	params := []pegomock.Param{dir, downloadURL, v}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Install", params, verifier.timeout)
+func (verifier *VerifierMockDownloader) Install(ctx context.Context, dir string, downloadURL string, v *go_version.Version) *MockDownloader_Install_OngoingVerification {
+	_params := []pegomock.Param{ctx, dir, downloadURL, v}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "Install", _params, verifier.timeout)
 	return &MockDownloader_Install_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
@@ -138,25 +93,37 @@ type MockDownloader_Install_OngoingVerification struct {
 	methodInvocations []pegomock.MethodInvocation
 }
 
-func (c *MockDownloader_Install_OngoingVerification) GetCapturedArguments() (string, string, *go_version.Version) {
-	dir, downloadURL, v := c.GetAllCapturedArguments()
-	return dir[len(dir)-1], downloadURL[len(downloadURL)-1], v[len(v)-1]
+func (c *MockDownloader_Install_OngoingVerification) GetCapturedArguments() (context.Context, string, string, *go_version.Version) {
+	ctx, dir, downloadURL, v := c.GetAllCapturedArguments()
+	return ctx[len(ctx)-1], dir[len(dir)-1], downloadURL[len(downloadURL)-1], v[len(v)-1]
 }
 
-func (c *MockDownloader_Install_OngoingVerification) GetAllCapturedArguments() (_param0 []string, _param1 []string, _param2 []*go_version.Version) {
-	params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(params) > 0 {
-		_param0 = make([]string, len(c.methodInvocations))
-		for u, param := range params[0] {
-			_param0[u] = param.(string)
+func (c *MockDownloader_Install_OngoingVerification) GetAllCapturedArguments() (_param0 []context.Context, _param1 []string, _param2 []string, _param3 []*go_version.Version) {
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]context.Context, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(context.Context)
+			}
+		}
+		if len(_params) > 1 {
+			_param1 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(string)
+			}
 		}
-		_param1 = make([]string, len(c.methodInvocations))
-		for u, param := range params[1] {
-			_param1[u] = param.(string)
+		if len(_params) > 2 {
+			_param2 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[2] {
+				_param2[u] = param.(string)
+			}
 		}
-		_param2 = make([]*go_version.Version, len(c.methodInvocations))
-		for u, param := range params[2] {
-			_param2[u] = param.(*go_version.Version)
+		if len(_params) > 3 {
+			_param3 = make([]*go_version.Version, len(c.methodInvocations))
+			for u, param := range _params[3] {
+				_param3[u] = param.(*go_version.Version)
+			}
 		}
 	}
 	return
diff --git a/server/core/terraform/mocks/mock_terraform_client.go b/server/core/terraform/mocks/mock_terraform_client.go
index dae620f2e1..279de1a751 100644
--- a/server/core/terraform/mocks/mock_terraform_client.go
+++ b/server/core/terraform/mocks/mock_terraform_client.go
@@ -31,49 +31,49 @@ func (mock *MockClient) DetectVersion(log logging.SimpleLogging, projectDirector
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockClient().")
 	}
-	params := []pegomock.Param{log, projectDirectory}
-	result := pegomock.GetGenericMockFrom(mock).Invoke("DetectVersion", params, []reflect.Type{reflect.TypeOf((**go_version.Version)(nil)).Elem()})
-	var ret0 *go_version.Version
-	if len(result) != 0 {
-		if result[0] != nil {
-			ret0 = result[0].(*go_version.Version)
+	_params := []pegomock.Param{log, projectDirectory}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("DetectVersion", _params, []reflect.Type{reflect.TypeOf((**go_version.Version)(nil)).Elem()})
+	var _ret0 *go_version.Version
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(*go_version.Version)
 		}
 	}
-	return ret0
+	return _ret0
 }
 
 func (mock *MockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.Version) error {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockClient().")
 	}
-	params := []pegomock.Param{log, v}
-	result := pegomock.GetGenericMockFrom(mock).Invoke("EnsureVersion", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
-	var ret0 error
-	if len(result) != 0 {
-		if result[0] != nil {
-			ret0 = result[0].(error)
+	_params := []pegomock.Param{log, v}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("EnsureVersion", _params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(error)
 		}
 	}
-	return ret0
+	return _ret0
 }
 
 func (mock *MockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) (string, error) {
 	if mock == nil {
 		panic("mock must not be nil. Use myMock := NewMockClient().")
 	}
-	params := []pegomock.Param{ctx, path, args, envs, v, workspace}
-	result := pegomock.GetGenericMockFrom(mock).Invoke("RunCommandWithVersion", params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
-	var ret0 string
-	var ret1 error
-	if len(result) != 0 {
-		if result[0] != nil {
-			ret0 = result[0].(string)
+	_params := []pegomock.Param{ctx, path, args, envs, v, workspace}
+	_result := pegomock.GetGenericMockFrom(mock).Invoke("RunCommandWithVersion", _params, []reflect.Type{reflect.TypeOf((*string)(nil)).Elem(), reflect.TypeOf((*error)(nil)).Elem()})
+	var _ret0 string
+	var _ret1 error
+	if len(_result) != 0 {
+		if _result[0] != nil {
+			_ret0 = _result[0].(string)
 		}
-		if result[1] != nil {
-			ret1 = result[1].(error)
+		if _result[1] != nil {
+			_ret1 = _result[1].(error)
 		}
 	}
-	return ret0, ret1
+	return _ret0, _ret1
 }
 
 func (mock *MockClient) VerifyWasCalledOnce() *VerifierMockClient {
@@ -114,8 +114,8 @@ type VerifierMockClient struct {
 }
 
 func (verifier *VerifierMockClient) DetectVersion(log logging.SimpleLogging, projectDirectory string) *MockClient_DetectVersion_OngoingVerification {
-	params := []pegomock.Param{log, projectDirectory}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DetectVersion", params, verifier.timeout)
+	_params := []pegomock.Param{log, projectDirectory}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DetectVersion", _params, verifier.timeout)
 	return &MockClient_DetectVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
@@ -130,23 +130,27 @@ func (c *MockClient_DetectVersion_OngoingVerification) GetCapturedArguments() (l
 }
 
 func (c *MockClient_DetectVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []string) {
-	params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(params) > 0 {
-		_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
-		for u, param := range params[0] {
-			_param0[u] = param.(logging.SimpleLogging)
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(logging.SimpleLogging)
+			}
 		}
-		_param1 = make([]string, len(c.methodInvocations))
-		for u, param := range params[1] {
-			_param1[u] = param.(string)
+		if len(_params) > 1 {
+			_param1 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(string)
+			}
 		}
 	}
 	return
 }
 
 func (verifier *VerifierMockClient) EnsureVersion(log logging.SimpleLogging, v *go_version.Version) *MockClient_EnsureVersion_OngoingVerification {
-	params := []pegomock.Param{log, v}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "EnsureVersion", params, verifier.timeout)
+	_params := []pegomock.Param{log, v}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "EnsureVersion", _params, verifier.timeout)
 	return &MockClient_EnsureVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
@@ -161,23 +165,27 @@ func (c *MockClient_EnsureVersion_OngoingVerification) GetCapturedArguments() (l
 }
 
 func (c *MockClient_EnsureVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []logging.SimpleLogging, _param1 []*go_version.Version) {
-	params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(params) > 0 {
-		_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
-		for u, param := range params[0] {
-			_param0[u] = param.(logging.SimpleLogging)
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]logging.SimpleLogging, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(logging.SimpleLogging)
+			}
 		}
-		_param1 = make([]*go_version.Version, len(c.methodInvocations))
-		for u, param := range params[1] {
-			_param1[u] = param.(*go_version.Version)
+		if len(_params) > 1 {
+			_param1 = make([]*go_version.Version, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(*go_version.Version)
+			}
 		}
 	}
 	return
 }
 
 func (verifier *VerifierMockClient) RunCommandWithVersion(ctx command.ProjectContext, path string, args []string, envs map[string]string, v *go_version.Version, workspace string) *MockClient_RunCommandWithVersion_OngoingVerification {
-	params := []pegomock.Param{ctx, path, args, envs, v, workspace}
-	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommandWithVersion", params, verifier.timeout)
+	_params := []pegomock.Param{ctx, path, args, envs, v, workspace}
+	methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "RunCommandWithVersion", _params, verifier.timeout)
 	return &MockClient_RunCommandWithVersion_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
 }
 
@@ -192,31 +200,43 @@ func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetCapturedArgume
 }
 
 func (c *MockClient_RunCommandWithVersion_OngoingVerification) GetAllCapturedArguments() (_param0 []command.ProjectContext, _param1 []string, _param2 [][]string, _param3 []map[string]string, _param4 []*go_version.Version, _param5 []string) {
-	params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
-	if len(params) > 0 {
-		_param0 = make([]command.ProjectContext, len(c.methodInvocations))
-		for u, param := range params[0] {
-			_param0[u] = param.(command.ProjectContext)
+	_params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
+	if len(_params) > 0 {
+		if len(_params) > 0 {
+			_param0 = make([]command.ProjectContext, len(c.methodInvocations))
+			for u, param := range _params[0] {
+				_param0[u] = param.(command.ProjectContext)
+			}
 		}
-		_param1 = make([]string, len(c.methodInvocations))
-		for u, param := range params[1] {
-			_param1[u] = param.(string)
+		if len(_params) > 1 {
+			_param1 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[1] {
+				_param1[u] = param.(string)
+			}
 		}
-		_param2 = make([][]string, len(c.methodInvocations))
-		for u, param := range params[2] {
-			_param2[u] = param.([]string)
+		if len(_params) > 2 {
+			_param2 = make([][]string, len(c.methodInvocations))
+			for u, param := range _params[2] {
+				_param2[u] = param.([]string)
+			}
 		}
-		_param3 = make([]map[string]string, len(c.methodInvocations))
-		for u, param := range params[3] {
-			_param3[u] = param.(map[string]string)
+		if len(_params) > 3 {
+			_param3 = make([]map[string]string, len(c.methodInvocations))
+			for u, param := range _params[3] {
+				_param3[u] = param.(map[string]string)
+			}
 		}
-		_param4 = make([]*go_version.Version, len(c.methodInvocations))
-		for u, param := range params[4] {
-			_param4[u] = param.(*go_version.Version)
+		if len(_params) > 4 {
+			_param4 = make([]*go_version.Version, len(c.methodInvocations))
+			for u, param := range _params[4] {
+				_param4[u] = param.(*go_version.Version)
+			}
 		}
-		_param5 = make([]string, len(c.methodInvocations))
-		for u, param := range params[5] {
-			_param5[u] = param.(string)
+		if len(_params) > 5 {
+			_param5 = make([]string, len(c.methodInvocations))
+			for u, param := range _params[5] {
+				_param5[u] = param.(string)
+			}
 		}
 	}
 	return
diff --git a/server/core/terraform/terraform_client.go b/server/core/terraform/terraform_client.go
index 792b028612..6c8df5afaf 100644
--- a/server/core/terraform/terraform_client.go
+++ b/server/core/terraform/terraform_client.go
@@ -27,12 +27,7 @@ import (
 	"sync"
 	"time"
 
-	"github.com/hashicorp/go-getter/v2"
 	"github.com/hashicorp/go-version"
-	install "github.com/hashicorp/hc-install"
-	"github.com/hashicorp/hc-install/product"
-	"github.com/hashicorp/hc-install/releases"
-	"github.com/hashicorp/hc-install/src"
 	"github.com/hashicorp/terraform-config-inspect/tfconfig"
 	"github.com/mitchellh/go-homedir"
 	"github.com/pkg/errors"
@@ -62,6 +57,9 @@ type Client interface {
 }
 
 type DefaultClient struct {
+	// Distribution handles logic specific to the TF distribution being used by Atlantis
+	distribution Distribution
+
 	// defaultVersion is the default version of terraform to use if another
 	// version isn't specified.
 	defaultVersion *version.Version
@@ -72,8 +70,7 @@ type DefaultClient struct {
 	// overrideTF can be used to override the terraform binary during testing
 	// with another binary, ex. echo.
 	overrideTF string
-	// downloader downloads terraform versions.
-	downloader      Downloader
+	// settings for the downloader.
 	downloadBaseURL string
 	downloadAllowed bool
 	// versions maps from the string representation of a tf version (ex. 0.11.10)
@@ -90,14 +87,6 @@ type DefaultClient struct {
 	projectCmdOutputHandler jobs.ProjectCommandOutputHandler
 }
 
-//go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader
-
-// Downloader is for downloading terraform versions.
-type Downloader interface {
-	Install(dir string, downloadURL string, v *version.Version) (string, error)
-	GetAny(dst, src string) error
-}
-
 // versionRegex extracts the version from `terraform version` output.
 //
 //	    Terraform v0.12.0-alpha4 (2c36829d3265661d8edbd5014de8090ea7e2a076)
@@ -105,11 +94,15 @@ type Downloader interface {
 //
 //	    Terraform v0.11.10
 //		   => 0.11.10
-var versionRegex = regexp.MustCompile("Terraform v(.*?)(\\s.*)?\n")
+//
+//	    OpenTofu v1.0.0
+//		   => 1.0.0
+var versionRegex = regexp.MustCompile("(?:Terraform|OpenTofu) v(.*?)(\\s.*)?\n")
 
 // NewClientWithDefaultVersion creates a new terraform client and pre-fetches the default version
 func NewClientWithDefaultVersion(
 	log logging.SimpleLogging,
+	distribution Distribution,
 	binDir string,
 	cacheDir string,
 	tfeToken string,
@@ -117,7 +110,6 @@ func NewClientWithDefaultVersion(
 	defaultVersionStr string,
 	defaultVersionFlagName string,
 	tfDownloadURL string,
-	tfDownloader Downloader,
 	tfDownloadAllowed bool,
 	usePluginCache bool,
 	fetchAsync bool,
@@ -128,9 +120,9 @@ func NewClientWithDefaultVersion(
 	versions := make(map[string]string)
 	var versionsLock sync.Mutex
 
-	localPath, err := exec.LookPath("terraform")
+	localPath, err := exec.LookPath(distribution.BinName())
 	if err != nil && defaultVersionStr == "" {
-		return nil, fmt.Errorf("terraform not found in $PATH. Set --%s or download terraform from https://developer.hashicorp.com/terraform/downloads", defaultVersionFlagName)
+		return nil, fmt.Errorf("%s not found in $PATH. Set --%s or download terraform from https://developer.hashicorp.com/terraform/downloads", distribution.BinName(), defaultVersionFlagName)
 	}
 	if err == nil {
 		localVersion, err = getVersion(localPath)
@@ -139,6 +131,7 @@ func NewClientWithDefaultVersion(
 		}
 		versions[localVersion.String()] = localPath
 		if defaultVersionStr == "" {
+
 			// If they haven't set a default version, then whatever they had
 			// locally is now the default.
 			finalDefaultVersion = localVersion
@@ -155,10 +148,10 @@ func NewClientWithDefaultVersion(
 			// Since ensureVersion might end up downloading terraform,
 			// we call it asynchronously so as to not delay server startup.
 			versionsLock.Lock()
-			_, err := ensureVersion(log, tfDownloader, versions, defaultVersion, binDir, tfDownloadURL, tfDownloadAllowed)
+			_, err := ensureVersion(log, distribution, versions, defaultVersion, binDir, tfDownloadURL, tfDownloadAllowed)
 			versionsLock.Unlock()
 			if err != nil {
-				log.Err("could not download terraform %s: %s", defaultVersion.String(), err)
+				log.Err("could not download %s %s: %s", distribution.BinName(), defaultVersion.String(), err)
 			}
 		}
 
@@ -180,10 +173,10 @@ func NewClientWithDefaultVersion(
 		}
 	}
 	return &DefaultClient{
+		distribution:            distribution,
 		defaultVersion:          finalDefaultVersion,
 		terraformPluginCacheDir: cacheDir,
 		binDir:                  binDir,
-		downloader:              tfDownloader,
 		downloadBaseURL:         tfDownloadURL,
 		downloadAllowed:         tfDownloadAllowed,
 		versionsLock:            &versionsLock,
@@ -196,6 +189,7 @@ func NewClientWithDefaultVersion(
 
 func NewTestClient(
 	log logging.SimpleLogging,
+	distribution Distribution,
 	binDir string,
 	cacheDir string,
 	tfeToken string,
@@ -203,13 +197,13 @@ func NewTestClient(
 	defaultVersionStr string,
 	defaultVersionFlagName string,
 	tfDownloadURL string,
-	tfDownloader Downloader,
 	tfDownloadAllowed bool,
 	usePluginCache bool,
 	projectCmdOutputHandler jobs.ProjectCommandOutputHandler,
 ) (*DefaultClient, error) {
 	return NewClientWithDefaultVersion(
 		log,
+		distribution,
 		binDir,
 		cacheDir,
 		tfeToken,
@@ -217,7 +211,6 @@ func NewTestClient(
 		defaultVersionStr,
 		defaultVersionFlagName,
 		tfDownloadURL,
-		tfDownloader,
 		tfDownloadAllowed,
 		usePluginCache,
 		false,
@@ -231,10 +224,10 @@ func NewTestClient(
 // a specific version is set.
 // defaultVersionFlagName is the name of the flag that sets the default terraform
 // version.
-// tfDownloader is used to download terraform versions.
 // Will asynchronously download the required version if it doesn't exist already.
 func NewClient(
 	log logging.SimpleLogging,
+	distribution Distribution,
 	binDir string,
 	cacheDir string,
 	tfeToken string,
@@ -242,13 +235,13 @@ func NewClient(
 	defaultVersionStr string,
 	defaultVersionFlagName string,
 	tfDownloadURL string,
-	tfDownloader Downloader,
 	tfDownloadAllowed bool,
 	usePluginCache bool,
 	projectCmdOutputHandler jobs.ProjectCommandOutputHandler,
 ) (*DefaultClient, error) {
 	return NewClientWithDefaultVersion(
 		log,
+		distribution,
 		binDir,
 		cacheDir,
 		tfeToken,
@@ -256,7 +249,6 @@ func NewClient(
 		defaultVersionStr,
 		defaultVersionFlagName,
 		tfDownloadURL,
-		tfDownloader,
 		tfDownloadAllowed,
 		usePluginCache,
 		true,
@@ -324,32 +316,11 @@ func (c *DefaultClient) DetectVersion(log logging.SimpleLogging, projectDirector
 		return version
 	}
 
-	constraintStr := requiredVersionSetting
-	vc, err := version.NewConstraint(constraintStr)
-	if err != nil {
-		log.Err("Error parsing constraint string: %s", err)
-		return nil
-	}
-
-	constrainedVersions := &releases.Versions{
-		Product:     product.Terraform,
-		Constraints: vc,
-	}
-	installCandidates, err := constrainedVersions.List(context.Background())
+	downloadVersion, err := c.distribution.ResolveConstraint(context.Background(), requiredVersionSetting)
 	if err != nil {
-		log.Err("error listing available versions: %s", err)
+		log.Err("%s", err)
 		return nil
 	}
-	if len(installCandidates) == 0 {
-		log.Err("no Terraform versions found for constraints %s", constraintStr)
-		return nil
-	}
-
-	// We want to select the highest version that satisfies the constraint.
-	versionDownloader := installCandidates[len(installCandidates)-1]
-
-	// Get the Version object from the versionDownloader.
-	downloadVersion := versionDownloader.(*releases.ExactVersion).Version
 
 	return downloadVersion
 }
@@ -362,7 +333,7 @@ func (c *DefaultClient) EnsureVersion(log logging.SimpleLogging, v *version.Vers
 
 	var err error
 	c.versionsLock.Lock()
-	_, err = ensureVersion(log, c.downloader, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed)
+	_, err = ensureVersion(log, c.distribution, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed)
 	c.versionsLock.Unlock()
 	if err != nil {
 		return err
@@ -442,7 +413,7 @@ func (c *DefaultClient) prepCmd(log logging.SimpleLogging, v *version.Version, w
 	} else {
 		var err error
 		c.versionsLock.Lock()
-		binPath, err = ensureVersion(log, c.downloader, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed)
+		binPath, err = ensureVersion(log, c.distribution, c.versions, v, c.binDir, c.downloadBaseURL, c.downloadAllowed)
 		c.versionsLock.Unlock()
 		if err != nil {
 			return "", nil, err
@@ -515,7 +486,7 @@ func MustConstraint(v string) version.Constraints {
 // It will download this version if we don't have it.
 func ensureVersion(
 	log logging.SimpleLogging,
-	dl Downloader,
+	dist Distribution,
 	versions map[string]string,
 	v *version.Version,
 	binDir string,
@@ -529,7 +500,7 @@ func ensureVersion(
 	// This tf version might not yet be in the versions map even though it
 	// exists on disk. This would happen if users have manually added
 	// terraform{version} binaries. In this case we don't want to re-download.
-	binFile := "terraform" + v.String()
+	binFile := dist.BinName() + v.String()
 	if binPath, err := exec.LookPath(binFile); err == nil {
 		versions[v.String()] = binPath
 		return binPath, nil
@@ -544,22 +515,24 @@ func ensureVersion(
 	}
 	if !downloadsAllowed {
 		return "", fmt.Errorf(
-			"could not find terraform version %s in PATH or %s, and downloads are disabled",
+			"could not find %s version %s in PATH or %s, and downloads are disabled",
+			dist.BinName(),
 			v.String(),
 			binDir,
 		)
 	}
 
-	log.Info("could not find terraform version %s in PATH or %s", v.String(), binDir)
+	log.Info("could not find %s version %s in PATH or %s", dist.BinName(), v.String(), binDir)
+
+	log.Info("downloading %s version %s from download URL %s", dist.BinName(), v.String(), downloadURL)
 
-	log.Info("using Hashicorp's 'hc-install' to download Terraform version %s from download URL %s", v.String(), downloadURL)
-	execPath, err := dl.Install(binDir, downloadURL, v)
+	execPath, err := dist.Downloader().Install(context.Background(), binDir, downloadURL, v)
 
 	if err != nil {
 		return "", errors.Wrapf(err, "error downloading terraform version %s", v.String())
 	}
 
-	log.Info("Downloaded terraform %s to %s", v.String(), execPath)
+	log.Info("Downloaded %s %s to %s", dist.BinName(), v.String(), execPath)
 	versions[v.String()] = execPath
 	return execPath, nil
 }
@@ -622,35 +595,3 @@ func getVersion(tfBinary string) (*version.Version, error) {
 var rcFileContents = `credentials "%s" {
   token = %q
 }`
-
-type DefaultDownloader struct{}
-
-func (d *DefaultDownloader) Install(dir string, downloadURL string, v *version.Version) (string, error) {
-	installer := install.NewInstaller()
-	execPath, err := installer.Install(context.Background(), []src.Installable{
-		&releases.ExactVersion{
-			Product:    product.Terraform,
-			Version:    v,
-			InstallDir: dir,
-			ApiBaseURL: downloadURL,
-		},
-	})
-	if err != nil {
-		return "", err
-	}
-
-	// hc-install installs terraform binary as just "terraform".
-	// We need to rename it to terraform{version} to be consistent with current naming convention.
-	newPath := filepath.Join(dir, "terraform"+v.String())
-	if err := os.Rename(execPath, newPath); err != nil {
-		return "", err
-	}
-
-	return newPath, nil
-}
-
-// See go-getter.GetAny.
-func (d *DefaultDownloader) GetAny(dst, src string) error {
-	_, err := getter.GetAny(context.Background(), dst, src)
-	return err
-}
diff --git a/server/server.go b/server/server.go
index 217462b96e..af940f3787 100644
--- a/server/server.go
+++ b/server/server.go
@@ -421,8 +421,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		)
 	}
 
+	distribution := terraform.NewDistributionTerraform()
+	if userConfig.TFDistribution == "opentofu" {
+		distribution = terraform.NewDistributionOpenTofu()
+	}
+
 	terraformClient, err := terraform.NewClient(
 		logger,
+		distribution,
 		binDir,
 		cacheDir,
 		userConfig.TFEToken,
@@ -430,7 +436,6 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 		userConfig.DefaultTFVersion,
 		config.DefaultTFVersionFlag,
 		userConfig.TFDownloadURL,
-		&terraform.DefaultDownloader{},
 		userConfig.TFDownload,
 		userConfig.UseTFPluginCache,
 		projectCmdOutputHandler)
@@ -438,7 +443,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 	// are, then we don't error out because we don't have/want terraform
 	// installed on our CI system where the unit tests run.
 	if err != nil && flag.Lookup("test.v") == nil {
-		return nil, errors.Wrap(err, "initializing terraform")
+		return nil, errors.Wrap(err, fmt.Sprintf("initializing %s", userConfig.TFDistribution))
 	}
 	markdownRenderer := events.NewMarkdownRenderer(
 		gitlabClient.SupportsCommonMark(),
@@ -635,7 +640,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
 
 	policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner(
 		defaultTfVersion,
-		policy.NewConfTestExecutorWorkflow(logger, binDir, &terraform.DefaultDownloader{}),
+		policy.NewConfTestExecutorWorkflow(logger, binDir, &policy.ConfTestGoGetterVersionDownloader{}),
 	)
 
 	if err != nil {
diff --git a/server/user_config.go b/server/user_config.go
index 91ed090ac6..9a6247ae09 100644
--- a/server/user_config.go
+++ b/server/user_config.go
@@ -107,6 +107,7 @@ type UserConfig struct {
 	SSLCertFile                string          `mapstructure:"ssl-cert-file"`
 	SSLKeyFile                 string          `mapstructure:"ssl-key-file"`
 	RestrictFileList           bool            `mapstructure:"restrict-file-list"`
+	TFDistribution             string          `mapstructure:"tf-distribution"`
 	TFDownload                 bool            `mapstructure:"tf-download"`
 	TFDownloadURL              string          `mapstructure:"tf-download-url"`
 	TFEHostname                string          `mapstructure:"tfe-hostname"`


Test patch:
diff --git a/cmd/server_test.go b/cmd/server_test.go
index 5402b28ce5..cccf9cc055 100644
--- a/cmd/server_test.go
+++ b/cmd/server_test.go
@@ -136,6 +136,7 @@ var testFlags = map[string]interface{}{
 	SSLCertFileFlag:                  "cert-file",
 	SSLKeyFileFlag:                   "key-file",
 	RestrictFileList:                 false,
+	TFDistributionFlag:               "terraform",
 	TFDownloadFlag:                   true,
 	TFDownloadURLFlag:                "https://my-hostname.com",
 	TFEHostnameFlag:                  "my-hostname",
diff --git a/server/controllers/events/events_controller_e2e_test.go b/server/controllers/events/events_controller_e2e_test.go
index 86a72c766b..68e517709c 100644
--- a/server/controllers/events/events_controller_e2e_test.go
+++ b/server/controllers/events/events_controller_e2e_test.go
@@ -26,7 +26,9 @@ import (
 	"github.com/runatlantis/atlantis/server/core/runtime"
 	runtimemocks "github.com/runatlantis/atlantis/server/core/runtime/mocks"
 	"github.com/runatlantis/atlantis/server/core/runtime/policy"
+	mock_policy "github.com/runatlantis/atlantis/server/core/runtime/policy/mocks"
 	"github.com/runatlantis/atlantis/server/core/terraform"
+	terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks"
 	"github.com/runatlantis/atlantis/server/events"
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/events/mocks"
@@ -53,10 +55,6 @@ var mockPreWorkflowHookRunner *runtimemocks.MockPreWorkflowHookRunner
 
 var mockPostWorkflowHookRunner *runtimemocks.MockPostWorkflowHookRunner
 
-func (m *NoopTFDownloader) GetAny(_, _ string) error {
-	return nil
-}
-
 func (m *NoopTFDownloader) Install(_ string, _ string, _ *version.Version) (string, error) {
 	return "", nil
 }
@@ -1317,7 +1315,11 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers
 		ExecutableName: "atlantis",
 		AllowCommands:  allowCommands,
 	}
-	terraformClient, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", &NoopTFDownloader{}, true, false, projectCmdOutputHandler)
+
+	mockDownloader := terraform_mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	terraformClient, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "", "default-tf-version", "https://releases.hashicorp.com", true, false, projectCmdOutputHandler)
 	Ok(t, err)
 	boltdb, err := db.New(dataDir)
 	Ok(t, err)
@@ -1431,7 +1433,7 @@ func setupE2E(t *testing.T, repoDir string, opt setupOption) (events_controllers
 
 	Ok(t, err)
 
-	conftextExec := policy.NewConfTestExecutorWorkflow(logger, binDir, &NoopTFDownloader{})
+	conftextExec := policy.NewConfTestExecutorWorkflow(logger, binDir, mock_policy.NewMockDownloader())
 
 	// swapping out version cache to something that always returns local conftest
 	// binary
@@ -1881,4 +1883,7 @@ func ensureRunning014(t *testing.T) {
 //
 //	    Terraform v0.11.10
 //		   => 0.11.10
-var versionRegex = regexp.MustCompile("Terraform v(.*?)(\\s.*)?\n")
+//
+//	    OpenTofu v1.0.0
+//		   => 1.0.0
+var versionRegex = regexp.MustCompile("(?:Terraform|OpenTofu) v(.*?)(\\s.*)?\n")
diff --git a/server/core/runtime/policy/conftest_client.go b/server/core/runtime/policy/conftest_client.go
index 5218f883d2..dd69bba4cd 100644
--- a/server/core/runtime/policy/conftest_client.go
+++ b/server/core/runtime/policy/conftest_client.go
@@ -1,6 +1,7 @@
 package policy
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -10,13 +11,13 @@ import (
 	"encoding/json"
 	"regexp"
 
+	"github.com/hashicorp/go-getter/v2"
 	"github.com/hashicorp/go-multierror"
 	version "github.com/hashicorp/go-version"
 	"github.com/pkg/errors"
 	"github.com/runatlantis/atlantis/server/core/config/valid"
 	"github.com/runatlantis/atlantis/server/core/runtime/cache"
 	runtime_models "github.com/runatlantis/atlantis/server/core/runtime/models"
-	"github.com/runatlantis/atlantis/server/core/terraform"
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/events/models"
 	"github.com/runatlantis/atlantis/server/logging"
@@ -103,8 +104,21 @@ func (p *SourceResolverProxy) Resolve(policySet valid.PolicySet) (string, error)
 	}
 }
 
+//go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader
+
+type Downloader interface {
+	GetAny(dst, src string) error
+}
+
+type ConfTestGoGetterVersionDownloader struct{}
+
+func (c ConfTestGoGetterVersionDownloader) GetAny(dst, src string) error {
+	_, err := getter.GetAny(context.Background(), dst, src)
+	return err
+}
+
 type ConfTestVersionDownloader struct {
-	downloader terraform.Downloader
+	downloader Downloader
 }
 
 func (c ConfTestVersionDownloader) downloadConfTestVersion(v *version.Version, destPath string) (runtime_models.FilePath, error) {
@@ -142,7 +156,7 @@ type ConfTestExecutorWorkflow struct {
 	Exec                   runtime_models.Exec
 }
 
-func NewConfTestExecutorWorkflow(log logging.SimpleLogging, versionRootDir string, conftestDownloder terraform.Downloader) *ConfTestExecutorWorkflow {
+func NewConfTestExecutorWorkflow(log logging.SimpleLogging, versionRootDir string, conftestDownloder Downloader) *ConfTestExecutorWorkflow {
 	downloader := ConfTestVersionDownloader{
 		downloader: conftestDownloder,
 	}
diff --git a/server/core/runtime/policy/conftest_client_test.go b/server/core/runtime/policy/conftest_client_test.go
index c50875e996..3b2bbd0645 100644
--- a/server/core/runtime/policy/conftest_client_test.go
+++ b/server/core/runtime/policy/conftest_client_test.go
@@ -12,7 +12,6 @@ import (
 	"github.com/runatlantis/atlantis/server/core/runtime/cache/mocks"
 	models_mocks "github.com/runatlantis/atlantis/server/core/runtime/models/mocks"
 	conftest_mocks "github.com/runatlantis/atlantis/server/core/runtime/policy/mocks"
-	terraform_mocks "github.com/runatlantis/atlantis/server/core/terraform/mocks"
 	"github.com/runatlantis/atlantis/server/events/command"
 	"github.com/runatlantis/atlantis/server/logging"
 	. "github.com/runatlantis/atlantis/testing"
@@ -27,9 +26,11 @@ func TestConfTestVersionDownloader(t *testing.T) {
 
 	RegisterMockTestingT(t)
 
-	mockDownloader := terraform_mocks.NewMockDownloader()
+	mockDownloader := conftest_mocks.NewMockDownloader()
 
-	subject := ConfTestVersionDownloader{downloader: mockDownloader}
+	subject := ConfTestVersionDownloader{
+		downloader: mockDownloader,
+	}
 
 	t.Run("success", func(t *testing.T) {
 
diff --git a/server/core/terraform/distribution_test.go b/server/core/terraform/distribution_test.go
new file mode 100644
index 0000000000..dbd9433834
--- /dev/null
+++ b/server/core/terraform/distribution_test.go
@@ -0,0 +1,33 @@
+package terraform_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/runatlantis/atlantis/server/core/terraform"
+	. "github.com/runatlantis/atlantis/testing"
+)
+
+func TestOpenTofuBinName(t *testing.T) {
+	d := terraform.NewDistributionOpenTofu()
+	Equals(t, d.BinName(), "tofu")
+}
+
+func TestResolveOpenTofuVersions(t *testing.T) {
+	d := terraform.NewDistributionOpenTofu()
+	version, err := d.ResolveConstraint(context.Background(), "= 1.8.0")
+	Ok(t, err)
+	Equals(t, version.String(), "1.8.0")
+}
+
+func TestTerraformBinName(t *testing.T) {
+	d := terraform.NewDistributionTerraform()
+	Equals(t, d.BinName(), "terraform")
+}
+
+func TestResolveTerraformVersions(t *testing.T) {
+	d := terraform.NewDistributionTerraform()
+	version, err := d.ResolveConstraint(context.Background(), "= 1.9.3")
+	Ok(t, err)
+	Equals(t, version.String(), "1.9.3")
+}
diff --git a/server/core/terraform/downloader_test.go b/server/core/terraform/downloader_test.go
new file mode 100644
index 0000000000..b6080c4a90
--- /dev/null
+++ b/server/core/terraform/downloader_test.go
@@ -0,0 +1,46 @@
+package terraform_test
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"github.com/hashicorp/go-version"
+	. "github.com/petergtz/pegomock/v4"
+	"github.com/runatlantis/atlantis/cmd"
+	"github.com/runatlantis/atlantis/server/core/terraform"
+)
+
+func TestTerraformInstall(t *testing.T) {
+	d := &terraform.TerraformDownloader{}
+	RegisterMockTestingT(t)
+	binDir := t.TempDir()
+
+	v, _ := version.NewVersion("1.8.1")
+
+	newPath, err := d.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if _, err := os.Stat(newPath); os.IsNotExist(err) {
+		t.Errorf("Binary not found at %s", newPath)
+	}
+}
+
+func TestOpenTofuInstall(t *testing.T) {
+	d := &terraform.TofuDownloader{}
+	RegisterMockTestingT(t)
+	binDir := t.TempDir()
+
+	v, _ := version.NewVersion("1.8.0")
+
+	newPath, err := d.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)
+	if err != nil {
+		t.Fatalf("Unexpected error: %v", err)
+	}
+
+	if _, err := os.Stat(newPath); os.IsNotExist(err) {
+		t.Errorf("Binary not found at %s", newPath)
+	}
+}
diff --git a/server/core/terraform/terraform_client_test.go b/server/core/terraform/terraform_client_test.go
index 6903b75791..c60a5fb085 100644
--- a/server/core/terraform/terraform_client_test.go
+++ b/server/core/terraform/terraform_client_test.go
@@ -14,6 +14,7 @@
 package terraform_test
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -24,7 +25,6 @@ import (
 
 	version "github.com/hashicorp/go-version"
 	. "github.com/petergtz/pegomock/v4"
-	pegomock "github.com/petergtz/pegomock/v4"
 	"github.com/runatlantis/atlantis/cmd"
 	"github.com/runatlantis/atlantis/server/core/terraform"
 	"github.com/runatlantis/atlantis/server/core/terraform/mocks"
@@ -77,7 +77,10 @@ is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform
 	Ok(t, err)
 	defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))()
 
-	c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distibution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	c, err := terraform.NewClient(logger, distibution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Ok(t, err)
@@ -111,7 +114,10 @@ is 0.11.13. You can update by downloading from developer.hashicorp.com/terraform
 	Ok(t, err)
 	defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))()
 
-	c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Ok(t, err)
@@ -132,7 +138,10 @@ func TestNewClient_NoTF(t *testing.T) {
 	// Set PATH to only include our empty directory.
 	defer tempSetEnv(t, "PATH", tmp)()
 
-	_, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	_, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	ErrEquals(t, "terraform not found in $PATH. Set --default-tf-version or download terraform from https://developer.hashicorp.com/terraform/downloads", err)
 }
 
@@ -155,7 +164,10 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) {
 	Ok(t, err)
 	defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))()
 
-	c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, false, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, false, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Ok(t, err)
@@ -183,7 +195,10 @@ func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) {
 	Ok(t, err)
 	defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))()
 
-	c, err := terraform.NewClient(logging.NewNoopLogger(t), binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+	c, err := terraform.NewClient(logging.NewNoopLogger(t), distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Ok(t, err)
@@ -211,18 +226,19 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) {
 	defer tempSetEnv(t, "PATH", "")()
 
 	mockDownloader := mocks.NewMockDownloader()
-	When(mockDownloader.Install(Any[string](), Any[string](), Any[*version.Version]())).Then(func(params []pegomock.Param) pegomock.ReturnValues {
-		binPath := filepath.Join(params[0].(string), "terraform0.11.10")
+	When(mockDownloader.Install(Any[context.Context](), Any[string](), Any[string](), Any[*version.Version]())).Then(func(params []Param) ReturnValues {
+		binPath := filepath.Join(params[1].(string), "terraform0.11.10")
 		err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v0.11.10\n'"), 0700) // #nosec G306
-		return []pegomock.ReturnValue{binPath, err}
+		return []ReturnValue{binPath, err}
 	})
-	c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true, true, projectCmdOutputHandler)
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+	c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Ok(t, err)
 	Equals(t, "0.11.10", c.DefaultVersion().String())
 
-	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(binDir, cmd.DefaultTFDownloadURL, version.Must(version.NewVersion("0.11.10")))
+	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, version.Must(version.NewVersion("0.11.10")))
 
 	// Reset PATH so that it has sh.
 	Ok(t, os.Setenv("PATH", orig))
@@ -237,7 +253,9 @@ func TestNewClient_BadVersion(t *testing.T) {
 	logger := logging.NewNoopLogger(t)
 	_, binDir, cacheDir := mkSubDirs(t)
 	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
-	_, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, nil, true, true, projectCmdOutputHandler)
+	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+	_, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "malformed", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	ErrEquals(t, "Malformed version: malformed", err)
 }
 
@@ -257,14 +275,15 @@ func TestRunCommandWithVersion_DLsTF(t *testing.T) {
 	Ok(t, err)
 
 	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
 	// Set up our mock downloader to write a fake tf binary when it's called.
-	When(mockDownloader.Install(binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []pegomock.Param) pegomock.ReturnValues {
-		binPath := filepath.Join(params[0].(string), "terraform99.99.99")
+	When(mockDownloader.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []Param) ReturnValues {
+		binPath := filepath.Join(params[1].(string), "terraform99.99.99")
 		err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306
-		return []pegomock.ReturnValue{binPath, err}
+		return []ReturnValue{binPath, err}
 	})
 
-	c, err := terraform.NewClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true, true, projectCmdOutputHandler)
+	c, err := terraform.NewClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 	Equals(t, "0.11.10", c.DefaultVersion().String())
 
@@ -282,8 +301,10 @@ func TestEnsureVersion_downloaded(t *testing.T) {
 	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
 
 	mockDownloader := mocks.NewMockDownloader()
+	distibution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
 	downloadsAllowed := true
-	c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, downloadsAllowed, true, projectCmdOutputHandler)
+	c, err := terraform.NewTestClient(logger, distibution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Equals(t, "0.11.10", c.DefaultVersion().String())
@@ -291,17 +312,17 @@ func TestEnsureVersion_downloaded(t *testing.T) {
 	v, err := version.NewVersion("99.99.99")
 	Ok(t, err)
 
-	When(mockDownloader.Install(binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []pegomock.Param) pegomock.ReturnValues {
-		binPath := filepath.Join(params[0].(string), "terraform99.99.99")
+	When(mockDownloader.Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)).Then(func(params []Param) ReturnValues {
+		binPath := filepath.Join(params[1].(string), "terraform99.99.99")
 		err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306
-		return []pegomock.ReturnValue{binPath, err}
+		return []ReturnValue{binPath, err}
 	})
 
 	err = c.EnsureVersion(logger, v)
 
 	Ok(t, err)
 
-	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(binDir, cmd.DefaultTFDownloadURL, v)
+	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, cmd.DefaultTFDownloadURL, v)
 }
 
 // Test that EnsureVersion downloads terraform from a custom URL.
@@ -312,10 +333,11 @@ func TestEnsureVersion_downloaded_customURL(t *testing.T) {
 	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
 
 	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
 	downloadsAllowed := true
 	customURL := "http://releases.example.com"
 
-	c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, customURL, mockDownloader, downloadsAllowed, true, projectCmdOutputHandler)
+	c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, customURL, downloadsAllowed, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Equals(t, "0.11.10", c.DefaultVersion().String())
@@ -323,17 +345,17 @@ func TestEnsureVersion_downloaded_customURL(t *testing.T) {
 	v, err := version.NewVersion("99.99.99")
 	Ok(t, err)
 
-	When(mockDownloader.Install(binDir, customURL, v)).Then(func(params []pegomock.Param) pegomock.ReturnValues {
-		binPath := filepath.Join(params[0].(string), "terraform99.99.99")
+	When(mockDownloader.Install(context.Background(), binDir, customURL, v)).Then(func(params []Param) ReturnValues {
+		binPath := filepath.Join(params[1].(string), "terraform99.99.99")
 		err := os.WriteFile(binPath, []byte("#!/bin/sh\necho '\nTerraform v99.99.99\n'"), 0700) // #nosec G306
-		return []pegomock.ReturnValue{binPath, err}
+		return []ReturnValue{binPath, err}
 	})
 
 	err = c.EnsureVersion(logger, v)
 
 	Ok(t, err)
 
-	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(binDir, customURL, v)
+	mockDownloader.VerifyWasCalledEventually(Once(), 2*time.Second).Install(context.Background(), binDir, customURL, v)
 }
 
 // Test that EnsureVersion throws an error when downloads are disabled
@@ -344,9 +366,10 @@ func TestEnsureVersion_downloaded_downloadingDisabled(t *testing.T) {
 	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
 
 	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
 
 	downloadsAllowed := false
-	c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, downloadsAllowed, true, projectCmdOutputHandler)
+	c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, downloadsAllowed, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	Equals(t, "0.11.10", c.DefaultVersion().String())
@@ -475,9 +498,12 @@ terraform {
 			RegisterMockTestingT(t)
 			_, binDir, cacheDir := mkSubDirs(t)
 			projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
-
 			mockDownloader := mocks.NewMockDownloader()
-			c, err := terraform.NewTestClient(logger,
+			distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
+
+			c, err := terraform.NewTestClient(
+				logger,
+				distribution,
 				binDir,
 				cacheDir,
 				"",
@@ -485,7 +511,6 @@ terraform {
 				"",
 				cmd.DefaultTFVersionFlag,
 				cmd.DefaultTFDownloadURL,
-				mockDownloader,
 				downloadsAllowed,
 				true,
 				projectCmdOutputHandler)
@@ -515,32 +540,15 @@ terraform {
 	}
 }
 
-func TestInstall(t *testing.T) {
-	d := &terraform.DefaultDownloader{}
-	RegisterMockTestingT(t)
-	_, binDir, _ := mkSubDirs(t)
-
-	v, _ := version.NewVersion("1.8.1")
-
-	newPath, err := d.Install(binDir, cmd.DefaultTFDownloadURL, v)
-	if err != nil {
-		t.Fatalf("Unexpected error: %v", err)
-	}
-
-	if _, err := os.Stat(newPath); os.IsNotExist(err) {
-		t.Errorf("Binary not found at %s", newPath)
-	}
-}
-
 func TestExtractExactRegex(t *testing.T) {
 	logger := logging.NewNoopLogger(t)
 	RegisterMockTestingT(t)
 	_, binDir, cacheDir := mkSubDirs(t)
 	projectCmdOutputHandler := jobmocks.NewMockProjectCommandOutputHandler()
-
 	mockDownloader := mocks.NewMockDownloader()
+	distribution := terraform.NewDistributionTerraformWithDownloader(mockDownloader)
 
-	c, err := terraform.NewTestClient(logger, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, mockDownloader, true, true, projectCmdOutputHandler)
+	c, err := terraform.NewTestClient(logger, distribution, binDir, cacheDir, "", "", "0.11.10", cmd.DefaultTFVersionFlag, cmd.DefaultTFDownloadURL, true, true, projectCmdOutputHandler)
 	Ok(t, err)
 
 	tests := []struct {