From 45d0920479c66a8ff2bf1ef5cfa9359c0476c0a4 Mon Sep 17 00:00:00 2001 From: Henry Muru Paenga Date: Mon, 30 Sep 2024 14:13:20 +1300 Subject: [PATCH] feat: OpenTofu support (#4499) Co-authored-by: PePe Amengual <2208324+jamengual@users.noreply.github.com> Signed-off-by: a1k0u --- cmd/server.go | 20 +++ cmd/server_test.go | 1 + go.mod | 5 +- go.sum | 31 +++- runatlantis.io/docs/server-configuration.md | 10 ++ .../events/events_controller_e2e_test.go | 19 ++- server/core/runtime/policy/conftest_client.go | 20 ++- .../runtime/policy/conftest_client_test.go | 7 +- .../runtime/policy/mocks/mock_downloader.go | 112 +++++++++++++ server/core/terraform/distribution.go | 133 +++++++++++++++ server/core/terraform/distribution_test.go | 33 ++++ server/core/terraform/downloader.go | 70 ++++++++ server/core/terraform/downloader_test.go | 46 ++++++ .../core/terraform/mocks/mock_downloader.go | 117 +++++--------- .../terraform/mocks/mock_terraform_client.go | 152 ++++++++++-------- server/core/terraform/terraform_client.go | 123 ++++---------- .../core/terraform/terraform_client_test.go | 106 ++++++------ server/server.go | 11 +- server/user_config.go | 1 + 19 files changed, 717 insertions(+), 300 deletions(-) create mode 100644 server/core/runtime/policy/mocks/mock_downloader.go create mode 100644 server/core/terraform/distribution.go create mode 100644 server/core/terraform/distribution_test.go create mode 100644 server/core/terraform/downloader.go create mode 100644 server/core/terraform/downloader_test.go 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/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/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/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/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/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.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/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/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/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 { 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"`