diff --git a/go.mod b/go.mod index 95d189be..ba41d144 100644 --- a/go.mod +++ b/go.mod @@ -196,6 +196,7 @@ require ( github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hc-install v0.6.2 github.com/hashicorp/hcl v1.0.0 // indirect github.com/imdario/mergo v0.3.16 github.com/inconshreveable/mousetrap v1.0.1 // indirect @@ -268,7 +269,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect golang.org/x/arch v0.1.0 // indirect golang.org/x/crypto v0.16.0 // indirect - golang.org/x/mod v0.12.0 // indirect + golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect golang.org/x/sync v0.3.0 // indirect diff --git a/go.sum b/go.sum index 22205ced..f9a626d9 100644 --- a/go.sum +++ b/go.sum @@ -402,6 +402,8 @@ github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjG github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/hc-install v0.6.2 h1:V1k+Vraqz4olgZ9UzKiAcbman9i9scg9GgSt/U3mw/M= +github.com/hashicorp/hc-install v0.6.2/go.mod h1:2JBpd+NCFKiHiu/yYCGaPyPHhZLxXTpz8oreHa/a3Ps= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl/v2 v2.16.1 h1:BwuxEMD/tsYgbhIW7UuI3crjovf3MzuFWiVgiv57iHg= @@ -755,8 +757,8 @@ 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.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/pkg/cmd/destroy/options.go b/pkg/cmd/destroy/options.go index a2f7717f..22ca566e 100644 --- a/pkg/cmd/destroy/options.go +++ b/pkg/cmd/destroy/options.go @@ -15,6 +15,7 @@ import ( "kusionstack.io/kusion/pkg/engine/backend" "kusionstack.io/kusion/pkg/engine/operation" opsmodels "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime/terraform" "kusionstack.io/kusion/pkg/engine/states" "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/pkg/project" @@ -138,6 +139,15 @@ func (o *Options) preview( ) (*opsmodels.Changes, error) { log.Info("Start compute preview changes ...") + // Check and install terraform executable binary for + // resources with the type of Terraform. + tfInstaller := terraform.CLIInstaller{ + Intent: planResources, + } + if err := tfInstaller.CheckAndInstall(); err != nil { + return nil, err + } + pc := &operation.PreviewOperation{ Operation: opsmodels.Operation{ OperationType: opsmodels.DestroyPreview, diff --git a/pkg/cmd/preview/options.go b/pkg/cmd/preview/options.go index cb40d251..8f5a0fbc 100644 --- a/pkg/cmd/preview/options.go +++ b/pkg/cmd/preview/options.go @@ -17,6 +17,7 @@ import ( "kusionstack.io/kusion/pkg/engine/backend" "kusionstack.io/kusion/pkg/engine/operation" opsmodels "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime/terraform" "kusionstack.io/kusion/pkg/engine/states" "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/pkg/project" @@ -233,6 +234,15 @@ func Preview( ) (*opsmodels.Changes, error) { log.Info("Start compute preview changes ...") + // Check and install terraform executable binary for + // resources with the type of Terraform. + tfInstaller := terraform.CLIInstaller{ + Intent: planResources, + } + if err := tfInstaller.CheckAndInstall(); err != nil { + return nil, err + } + // Construct the preview operation pc := &operation.PreviewOperation{ Operation: opsmodels.Operation{ diff --git a/pkg/engine/runtime/terraform/install.go b/pkg/engine/runtime/terraform/install.go new file mode 100644 index 00000000..76a34075 --- /dev/null +++ b/pkg/engine/runtime/terraform/install.go @@ -0,0 +1,142 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/hashicorp/hc-install/product" + "github.com/hashicorp/hc-install/releases" + apiv1 "kusionstack.io/kusion/pkg/apis/core/v1" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/util/kfile" +) + +var ( + tfInstallSubDir = "terraform" + tfInstallTimeout = 3 * time.Minute +) + +type CLIInstaller struct { + Intent *apiv1.Intent +} + +// Check and install the terraform executable binary if it has not been downloaded. +func (installer *CLIInstaller) CheckAndInstall() error { + if len(installer.Intent.Resources) < 1 { + return nil + } + + for i, res := range installer.Intent.Resources { + if res.Type == apiv1.Terraform { + break + } + + if i == installer.Intent.Resources.Len() { + return nil + } + } + + if err := checkTerraformExecutable(); err != nil { + log.Warn("Terraform executable binary is not found") + + if err := installTerraform(); err != nil { + return err + } + + log.Info("Successfully installed terraform and set the executable path") + } + + return nil +} + +// check whether the terraform executable binary has been installed. +func checkTerraformExecutable() error { + if err := exec.Command("terraform", "--version").Run(); err == nil { + return nil + } + + installDir, err := getTerraformInstallDir() + if err != nil { + return err + } + + execPath := filepath.Join(installDir, "terraform") + if err := exec.Command(execPath, "--version").Run(); err != nil { + return err + } + + return setTerraformExecPathEnv(execPath) +} + +// install and set the environment variable of executable path for terraform binary, +// currently the latest version will be downloaded by default. +func installTerraform() error { + log.Info("Installing terraform binary with the latest version...") + + installDir, err := getTerraformInstallDir() + if err != nil { + return err + } + + installer := &releases.LatestVersion{ + Product: product.Terraform, + InstallDir: installDir, + Timeout: tfInstallTimeout, + } + + execPath, err := installer.Install(context.Background()) + if err != nil { + return err + } + + log.Infof("Successfully located terraform binary: %s\n", execPath) + + return setTerraformExecPathEnv(execPath) +} + +// set the environment variable of executable path for terraform binary, +// note that this env only takes effect for the current process. +func setTerraformExecPathEnv(execPath string) error { + if execPath == "" { + return fmt.Errorf("empty executable path for terraform binary") + } + + log.Info("Setting the environment variable of executable path for terraform...") + currentPath := os.Getenv("PATH") + + // select the path separator according to the operating system. + var pathSeparator string + if runtime.GOOS == "windows" { + pathSeparator = ";" + } else { + pathSeparator = ":" + } + + newPath := execPath + pathSeparator + currentPath + + return os.Setenv("PATH", newPath) +} + +// get the installation directory for terraform binary, and by default +// it is ~/.kusion/terraform. +func getTerraformInstallDir() (string, error) { + kusionDir, err := kfile.KusionDataFolder() + if err != nil { + return "", err + } + + installDir := filepath.Join(kusionDir, tfInstallSubDir) + + if _, err = os.Stat(installDir); os.IsNotExist(err) { + if err := os.Mkdir(installDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create terraform install directory: %v", err) + } + } + + return installDir, nil +} diff --git a/pkg/engine/runtime/terraform/install_test.go b/pkg/engine/runtime/terraform/install_test.go new file mode 100644 index 00000000..8f8c61b0 --- /dev/null +++ b/pkg/engine/runtime/terraform/install_test.go @@ -0,0 +1,93 @@ +package terraform + +import ( + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + v1 "kusionstack.io/kusion/pkg/apis/core/v1" +) + +func TestCLIInstaller_CheckAndInstall(t *testing.T) { + mockey.PatchConvey("NoResources", t, func() { + installer := &CLIInstaller{ + Intent: &v1.Intent{ + Resources: v1.Resources{}, + }, + } + err := installer.CheckAndInstall() + assert.Nil(t, err) + }) + + mockey.PatchConvey("NoTerraformResources", t, func() { + installer := &CLIInstaller{ + Intent: &v1.Intent{ + Resources: v1.Resources{ + v1.Resource{ + Type: v1.Kubernetes, + }, + }, + }, + } + err := installer.CheckAndInstall() + assert.Nil(t, err) + }) + + mockey.PatchConvey("ExistingTerraformExecutable", t, func() { + mockey.Mock(checkTerraformExecutable).To(func() error { + return nil + }).Build() + installer := &CLIInstaller{ + Intent: &v1.Intent{ + Resources: v1.Resources{ + v1.Resource{ + Type: v1.Terraform, + }, + }, + }, + } + err := installer.CheckAndInstall() + assert.Nil(t, err) + }) + + mockey.PatchConvey("InstallTerraformTimeout", t, func() { + mockey.Mock(checkTerraformExecutable).To(func() error { + return fmt.Errorf("terraform executable not found") + }).Build() + mockey.Mock(installTerraform).To(func() error { + return fmt.Errorf("install timeout") + }).Build() + installer := &CLIInstaller{ + Intent: &v1.Intent{ + Resources: v1.Resources{ + v1.Resource{ + Type: v1.Terraform, + }, + }, + }, + } + err := installer.CheckAndInstall() + assert.ErrorContains(t, err, "install timeout") + }) + + mockey.PatchConvey("SuccessfullyInstalled", t, func() { + mockey.Mock(checkTerraformExecutable).To(func() error { + return fmt.Errorf("terraform executable not found") + }).Build() + mockey.Mock(installTerraform).To(func() error { + return nil + }).Build() + installer := &CLIInstaller{ + Intent: &v1.Intent{ + Resources: v1.Resources{ + v1.Resource{ + Type: v1.Terraform, + }, + }, + }, + } + err := installer.CheckAndInstall() + assert.Nil(t, err) + }) +} diff --git a/pkg/util/pretty/spinner_text.go b/pkg/util/pretty/spinner_text.go index 3892819b..4c3aa3e0 100644 --- a/pkg/util/pretty/spinner_text.go +++ b/pkg/util/pretty/spinner_text.go @@ -24,4 +24,5 @@ var SpinnerT = pterm.SpinnerPrinter{ SuccessPrinter: &SuccessT, FailPrinter: &ErrorT, WarningPrinter: &WarningT, + InfoPrinter: &InfoT, }