diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index 6d44de19ba1c1..6cf1754a286bd 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -197,7 +197,7 @@ jobs: - uses: hashicorp/setup-terraform@v2 with: - terraform_version: 1.1.2 + terraform_version: 1.1.9 terraform_wrapper: false - name: Test with Mock Database @@ -264,7 +264,7 @@ jobs: - uses: hashicorp/setup-terraform@v2 with: - terraform_version: 1.1.2 + terraform_version: 1.1.9 terraform_wrapper: false - name: Start PostgreSQL Database @@ -494,7 +494,7 @@ jobs: - uses: hashicorp/setup-terraform@v2 with: - terraform_version: 1.1.2 + terraform_version: 1.1.9 terraform_wrapper: false - uses: actions/setup-node@v3 diff --git a/cli/server.go b/cli/server.go index 3230031c4f927..7dc27a340c5cf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -376,7 +376,6 @@ func server() *cobra.Command { shutdownConnsCtx, shutdownConns := context.WithCancel(cmd.Context()) defer shutdownConns() go func() { - defer close(errCh) server := http.Server{ // These errors are typically noise like "TLS: EOF". Vault does similar: // https://github.com/hashicorp/vault/blob/e2490059d0711635e529a4efcbaa1b26998d6e1c/command/server.go#L2714 @@ -590,7 +589,7 @@ func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API, CachePath: cacheDir, Logger: logger, }) - if err != nil { + if err != nil && !xerrors.Is(err, context.Canceled) { errChan <- err } }() diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index 0ed000af9bb51..44b6d4d9e8275 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -104,11 +104,22 @@ func (e executor) checkMinVersion(ctx context.Context) error { } func (e executor) version(ctx context.Context) (*version.Version, error) { + return versionFromBinaryPath(ctx, e.binaryPath) +} + +func versionFromBinaryPath(ctx context.Context, binaryPath string) (*version.Version, error) { // #nosec - cmd := exec.CommandContext(ctx, e.binaryPath, "version", "-json") + cmd := exec.CommandContext(ctx, binaryPath, "version", "-json") out, err := cmd.Output() if err != nil { - return nil, err + select { + // `exec` library throws a `signal: killed`` error instead of the canceled context. + // Since we know the cause for the killed signal, we are throwing the relevant error here. + case <-ctx.Done(): + return nil, ctx.Err() + default: + return nil, err + } } vj := tfjson.VersionOutput{} err = json.Unmarshal(out, &vj) diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index 60c03ed0dbe34..4e0dd87189302 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -16,7 +16,9 @@ import ( // This is the exact version of Terraform used internally // when Terraform is missing on the system. -const terraformVersion = "1.1.9" +var terraformVersion = version.Must(version.NewVersion("1.1.9")) +var minTerraformVersion = version.Must(version.NewVersion("1.1.0")) +var maxTerraformVersion = version.Must(version.NewVersion("1.2.0")) var ( // The minimum version of Terraform supported by the provisioner. @@ -31,6 +33,8 @@ var ( }() ) +var terraformMinorVersionMismatch = xerrors.New("Terraform binary minor version mismatch.") + type ServeOptions struct { *provisionersdk.ServeOptions @@ -41,15 +45,51 @@ type ServeOptions struct { Logger slog.Logger } +func absoluteBinaryPath(ctx context.Context) (string, error) { + binaryPath, err := safeexec.LookPath("terraform") + if err != nil { + return "", xerrors.Errorf("Terraform binary not found: %w", err) + } + + // If the "coder" binary is in the same directory as + // the "terraform" binary, "terraform" is returned. + // + // We must resolve the absolute path for other processes + // to execute this properly! + absoluteBinary, err := filepath.Abs(binaryPath) + if err != nil { + return "", xerrors.Errorf("Terraform binary absolute path not found: %w", err) + } + + // Checking the installed version of Terraform. + version, err := versionFromBinaryPath(ctx, absoluteBinary) + if err != nil { + return "", xerrors.Errorf("Terraform binary get version failed: %w", err) + } + + if version.LessThan(minTerraformVersion) || version.GreaterThanOrEqual(maxTerraformVersion) { + return "", terraformMinorVersionMismatch + } + + return absoluteBinary, nil +} + // Serve starts a dRPC server on the provided transport speaking Terraform provisioner. func Serve(ctx context.Context, options *ServeOptions) error { if options.BinaryPath == "" { - binaryPath, err := safeexec.LookPath("terraform") + absoluteBinary, err := absoluteBinaryPath(ctx) if err != nil { + // This is an early exit to prevent extra execution in case the context is canceled. + // It generally happens in unit tests since this method is asynchronous and + // the unit test kills the app before this is complete. + if xerrors.Is(err, context.Canceled) { + return xerrors.Errorf("absolute binary context canceled: %w", err) + } + installer := &releases.ExactVersion{ InstallDir: options.CachePath, Product: product.Terraform, - Version: version.Must(version.NewVersion(terraformVersion)), + Version: terraformVersion, } execPath, err := installer.Install(ctx) @@ -58,15 +98,6 @@ func Serve(ctx context.Context, options *ServeOptions) error { } options.BinaryPath = execPath } else { - // If the "coder" binary is in the same directory as - // the "terraform" binary, "terraform" is returned. - // - // We must resolve the absolute path for other processes - // to execute this properly! - absoluteBinary, err := filepath.Abs(binaryPath) - if err != nil { - return xerrors.Errorf("absolute: %w", err) - } options.BinaryPath = absoluteBinary } } diff --git a/provisioner/terraform/serve_internal_test.go b/provisioner/terraform/serve_internal_test.go new file mode 100644 index 0000000000000..9daebf57225c3 --- /dev/null +++ b/provisioner/terraform/serve_internal_test.go @@ -0,0 +1,98 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" +) + +// nolint:paralleltest +func Test_absoluteBinaryPath(t *testing.T) { + type args struct { + ctx context.Context + } + tests := []struct { + name string + args args + terraformVersion string + expectedErr error + }{ + { + name: "TestCorrectVersion", + args: args{ctx: context.Background()}, + terraformVersion: "1.1.9", + expectedErr: nil, + }, + { + name: "TestOldVersion", + args: args{ctx: context.Background()}, + terraformVersion: "1.0.9", + expectedErr: terraformMinorVersionMismatch, + }, + { + name: "TestNewVersion", + args: args{ctx: context.Background()}, + terraformVersion: "1.2.9", + expectedErr: terraformMinorVersionMismatch, + }, + { + name: "TestMalformedVersion", + args: args{ctx: context.Background()}, + terraformVersion: "version", + expectedErr: xerrors.Errorf("Terraform binary get version failed: Malformed version: version"), + }, + } + // nolint:paralleltest + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Dummy terraform executable on Windows requires sh which isn't very practical.") + } + + // Create a temp dir with the binary + tempDir := t.TempDir() + terraformBinaryOutput := fmt.Sprintf(`#!/bin/sh + cat <<-EOF + { + "terraform_version": "%s", + "platform": "linux_amd64", + "provider_selections": {}, + "terraform_outdated": false + } + EOF`, tt.terraformVersion) + + // #nosec + err := os.WriteFile( + filepath.Join(tempDir, "terraform"), + []byte(terraformBinaryOutput), + 0770, + ) + require.NoError(t, err) + + // Add the binary to PATH + pathVariable := os.Getenv("PATH") + t.Setenv("PATH", strings.Join([]string{tempDir, pathVariable}, ":")) + + var expectedAbsoluteBinary string + if tt.expectedErr == nil { + expectedAbsoluteBinary = filepath.Join(tempDir, "terraform") + } + + actualAbsoluteBinary, actualErr := absoluteBinaryPath(tt.args.ctx) + + require.Equal(t, expectedAbsoluteBinary, actualAbsoluteBinary) + if tt.expectedErr == nil { + require.NoError(t, actualErr) + } else { + require.EqualError(t, actualErr, tt.expectedErr.Error()) + } + }) + } +}