diff --git a/provisioner/terraform/install.go b/provisioner/terraform/install.go new file mode 100644 index 0000000000000..6fdc6d978121d --- /dev/null +++ b/provisioner/terraform/install.go @@ -0,0 +1,80 @@ +package terraform + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/gofrs/flock" + "github.com/hashicorp/go-version" + "github.com/hashicorp/hc-install/product" + "github.com/hashicorp/hc-install/releases" + "golang.org/x/xerrors" + + "cdr.dev/slog" +) + +var ( + // TerraformVersion is the version of Terraform used internally + // when Terraform is not available on the system. + TerraformVersion = version.Must(version.NewVersion("1.3.0")) + + minTerraformVersion = version.Must(version.NewVersion("1.1.0")) + maxTerraformVersion = version.Must(version.NewVersion("1.3.0")) + + terraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") +) + +// Install implements a thread-safe, idempotent Terraform Install +// operation. +func Install(ctx context.Context, log slog.Logger, dir string, wantVersion *version.Version) (string, error) { + err := os.MkdirAll(dir, 0750) + if err != nil { + return "", err + } + + // Windows requires a separate lock file. + // See https://github.com/pinterest/knox/blob/master/client/flock_windows.go#L64 + // for precedent. + lockFilePath := filepath.Join(dir, "lock") + lock := flock.New(lockFilePath) + ok, err := lock.TryLockContext(ctx, time.Millisecond*100) + if !ok { + return "", xerrors.Errorf("could not acquire flock for %v: %w", lockFilePath, err) + } + defer lock.Close() + + binPath := filepath.Join(dir, product.Terraform.BinaryName()) + + hasVersion, err := versionFromBinaryPath(ctx, binPath) + if err == nil && hasVersion.Equal(wantVersion) { + return binPath, err + } + + installer := &releases.ExactVersion{ + InstallDir: dir, + Product: product.Terraform, + Version: TerraformVersion, + } + installer.SetLogger(slog.Stdlib(ctx, log, slog.LevelDebug)) + log.Debug( + ctx, + "installing terraform", + slog.F("dir", dir), + slog.F("version", TerraformVersion), + ) + + path, err := installer.Install(ctx) + if err != nil { + return "", xerrors.Errorf("install: %w", err) + } + + // Sanity-check: if path != binPath then future invocations of Install + // will fail. + if path != binPath { + return "", xerrors.Errorf("%s should be %s", path, binPath) + } + + return path, nil +} diff --git a/provisioner/terraform/install_test.go b/provisioner/terraform/install_test.go new file mode 100644 index 0000000000000..96e480b6c8c80 --- /dev/null +++ b/provisioner/terraform/install_test.go @@ -0,0 +1,71 @@ +package terraform_test + +import ( + "context" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/hashicorp/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/provisioner/terraform" +) + +func TestInstall(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + log := slogtest.Make(t, nil) + + // install spins off 8 installs with Version and waits for them all + // to complete. + install := func(version *version.Version) string { + var wg sync.WaitGroup + var path atomic.Pointer[string] + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + p, err := terraform.Install(ctx, log, dir, version) + assert.NoError(t, err) + path.Store(&p) + }() + } + wg.Wait() + if t.Failed() { + t.FailNow() + } + return *path.Load() + } + + binPath := install(terraform.TerraformVersion) + + checkBin := func() time.Time { + binInfo, err := os.Stat(binPath) + require.NoError(t, err) + require.Greater(t, binInfo.Size(), int64(0)) + return binInfo.ModTime() + } + + firstMod := checkBin() + + // Since we're using the same version the install should be idempotent. + install(terraform.TerraformVersion) + secondMod := checkBin() + require.Equal(t, firstMod, secondMod) + + // Ensure a new install happens when version changes + differentVersion := version.Must(version.NewVersion("1.2.0")) + // Sanity-check + require.NotEqual(t, differentVersion.String(), terraform.TerraformVersion.String()) + + install(differentVersion) + + thirdMod := checkBin() + require.Greater(t, thirdMod, secondMod) +} diff --git a/provisioner/terraform/provision_test.go b/provisioner/terraform/provision_test.go index 8ee534b6e9d4c..afac79d6777ad 100644 --- a/provisioner/terraform/provision_test.go +++ b/provisioner/terraform/provision_test.go @@ -5,6 +5,7 @@ package terraform_test import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -43,7 +44,9 @@ func setupProvisioner(t *testing.T, opts *provisionerServeOptions) (context.Cont _ = server.Close() cancelFunc() err := <-serverErr - assert.NoError(t, err) + if !errors.Is(err, context.Canceled) { + assert.NoError(t, err) + } }) go func() { serverErr <- terraform.Serve(ctx, &terraform.ServeOptions{ diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index 139ca5cae15eb..765eef168196d 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -7,31 +7,12 @@ import ( "time" "github.com/cli/safeexec" - "github.com/hashicorp/go-version" - "github.com/hashicorp/hc-install/product" - "github.com/hashicorp/hc-install/releases" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/provisionersdk" ) -var ( - // TerraformVersion is the version of Terraform used internally - // when Terraform is not available on the system. - TerraformVersion = version.Must(version.NewVersion("1.3.0")) - - minTerraformVersion = version.Must(version.NewVersion("1.1.0")) - maxTerraformVersion = version.Must(version.NewVersion("1.3.0")) - - terraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") - - installTerraform sync.Once - installTerraformExecPath string - // nolint:errname - installTerraformError error -) - const ( defaultExitTimeout = 5 * time.Minute ) @@ -98,21 +79,11 @@ func Serve(ctx context.Context, options *ServeOptions) error { return xerrors.Errorf("absolute binary context canceled: %w", err) } - // We don't want to install Terraform multiple times! - installTerraform.Do(func() { - installer := &releases.ExactVersion{ - InstallDir: options.CachePath, - Product: product.Terraform, - Version: TerraformVersion, - } - installer.SetLogger(slog.Stdlib(ctx, options.Logger, slog.LevelDebug)) - options.Logger.Debug(ctx, "installing terraform", slog.F("dir", options.CachePath), slog.F("version", TerraformVersion)) - installTerraformExecPath, installTerraformError = installer.Install(ctx) - }) - if installTerraformError != nil { - return xerrors.Errorf("install terraform: %w", installTerraformError) + binPath, err := Install(ctx, options.Logger, options.CachePath, TerraformVersion) + if err != nil { + return xerrors.Errorf("install terraform: %w", err) } - options.BinaryPath = installTerraformExecPath + options.BinaryPath = binPath } else { options.BinaryPath = absoluteBinary }